From 260be8c2ad70c6b6a849bad06ce2a5a84b753f29 Mon Sep 17 00:00:00 2001 From: Mike Swanson Date: Thu, 18 Jun 2026 14:20:05 -0700 Subject: [PATCH] security.azcomputerguru.com: scaffold the client security-assessment intake app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New MSP tool — single-assessor consult intake. Stack: PHP + MySQL on the IX cPanel host, gated by Cloudflare Access (only mike@azcomputerguru.com; app re-checks the Cf-Access-Authenticated-User-Email header). - app/questions.json — risk-ordered question framework (9 sections); each field tagged source=syncro/rmm/scan/ask so the consult asks only what a human knows and the post-meeting scan fills the technical reality. - app/index.php — wizard UI: Syncro phone lookup -> prefill, section rail with live progress, importance-colored question cards, in-meeting 365/Google consent links, review + export. - app/api.php — Syncro lookup-by-phone, save/load/list, consent-URL generation (reuses the read-only Security Investigator app bfbc12a4-...), HTML export. - app/schema.sql, config.sample.php, DEPLOY.md, README.md. Consent links let the client approve read-only 365/Google access during the consult so the audit scan runs afterward. Read-only by design (reads Syncro, generates consent; no tenant writes). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../msp-tools/security-assessment/DEPLOY.md | 24 +++ .../msp-tools/security-assessment/README.md | 12 ++ .../security-assessment/app/.gitignore | 1 + .../msp-tools/security-assessment/app/api.php | 126 +++++++++++ .../security-assessment/app/config.sample.php | 15 ++ .../security-assessment/app/index.php | 201 ++++++++++++++++++ .../security-assessment/app/questions.json | 155 ++++++++++++++ .../security-assessment/app/schema.sql | 11 + 8 files changed, 545 insertions(+) create mode 100644 projects/msp-tools/security-assessment/DEPLOY.md create mode 100644 projects/msp-tools/security-assessment/README.md create mode 100644 projects/msp-tools/security-assessment/app/.gitignore create mode 100644 projects/msp-tools/security-assessment/app/api.php create mode 100644 projects/msp-tools/security-assessment/app/config.sample.php create mode 100644 projects/msp-tools/security-assessment/app/index.php create mode 100644 projects/msp-tools/security-assessment/app/questions.json create mode 100644 projects/msp-tools/security-assessment/app/schema.sql diff --git a/projects/msp-tools/security-assessment/DEPLOY.md b/projects/msp-tools/security-assessment/DEPLOY.md new file mode 100644 index 00000000..bc55498c --- /dev/null +++ b/projects/msp-tools/security-assessment/DEPLOY.md @@ -0,0 +1,24 @@ +# Deploy — security.azcomputerguru.com + +## 1. cPanel / WHM (IX server, 172.16.3.10) +- Create subdomain `security.azcomputerguru.com` (docroot e.g. `/home//security`). +- Create MySQL DB `acgsec_assess` + user `acgsec_app`, grant all on the DB. Vault the password + (`msp-tools/security-assessment-db`). Import `app/schema.sql`. +- Upload `app/*` to the docroot. `cp config.sample.php config.php` and fill secrets (DB pass + + Mike's Syncro key from vault `msp-tools/syncro-mike`). + +## 2. Cloudflare DNS + Access +- DNS: `security` A/CNAME -> the IX origin, **proxied** (orange cloud). +- Zero Trust > Access > Applications: add self-hosted app for `security.azcomputerguru.com`, + policy = Allow, include `mike@azcomputerguru.com` (require MFA). Everyone else blocked. + The app reads `Cf-Access-Authenticated-User-Email` as a second check. + +## 3. Consent apps +- 365: register redirect URI `https://security.azcomputerguru.com/consent-callback.php` on the + Security Investigator app (bfbc12a4-...). Admin-consent link is generated per-tenant in-app. +- Google: create a Workspace read-only OAuth client, set GOOGLE_CLIENT_ID + redirect; scopes are + in config. (Stubbed until created.) + +## Notes +- Read-only by design: the app only *reads* Syncro and *generates* consent links. No tenant writes. +- Post-meeting scan consumes the export + the granted 365/Google consent to run the audit. diff --git a/projects/msp-tools/security-assessment/README.md b/projects/msp-tools/security-assessment/README.md new file mode 100644 index 00000000..2ade58dd --- /dev/null +++ b/projects/msp-tools/security-assessment/README.md @@ -0,0 +1,12 @@ +# ACG Security Assessment (security.azcomputerguru.com) + +Single-assessor intake tool: identify the client by Syncro phone number, prefill what we can, +walk Mike through a risk-ordered questionnaire during the consult, capture read-only 365/Google +consent on the spot, and export the audit work-list for the post-meeting automated scan. + +- `app/questions.json` — the question framework (sections, fields, sources, importance). +- `app/index.php` — the wizard UI (Cloudflare-Access gated). +- `app/api.php` — Syncro lookup, save/load, consent-link generation, export. +- `app/schema.sql` — MySQL schema. `DEPLOY.md` — hosting + Cloudflare Access steps. + +Stack: PHP + MySQL on the IX cPanel host, behind Cloudflare Access (only mike@azcomputerguru.com). diff --git a/projects/msp-tools/security-assessment/app/.gitignore b/projects/msp-tools/security-assessment/app/.gitignore new file mode 100644 index 00000000..4f4773fb --- /dev/null +++ b/projects/msp-tools/security-assessment/app/.gitignore @@ -0,0 +1 @@ +config.php diff --git a/projects/msp-tools/security-assessment/app/api.php b/projects/msp-tools/security-assessment/app/api.php new file mode 100644 index 00000000..944b2597 --- /dev/null +++ b/projects/msp-tools/security-assessment/app/api.php @@ -0,0 +1,126 @@ + 'forbidden']); exit; +} + +function db() { + static $pdo; + if (!$pdo) $pdo = new PDO('mysql:host=' . DB_HOST . ';dbname=' . DB_NAME . ';charset=utf8mb4', DB_USER, DB_PASS, + [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]); + return $pdo; +} +function body() { return json_decode(file_get_contents('php://input'), true) ?: []; } +function out($x) { echo json_encode($x); exit; } + +function syncro($path) { + $url = rtrim(SYNCRO_BASE, '/') . $path . (strpos($path, '?') === false ? '?' : '&') . 'api_key=' . SYNCRO_KEY; + $ch = curl_init($url); + curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 20, CURLOPT_HTTPHEADER => ['Accept: application/json']]); + $raw = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); + $raw = preg_replace('/[\x00-\x1F]/', ' ', $raw); // strip control chars Syncro leaks + return [$code, json_decode($raw, true)]; +} + +$action = $_GET['action'] ?? ''; + +// ---- Syncro client lookup by phone ---- +if ($action === 'lookup') { + $phone = preg_replace('/\D/', '', body()['phone'] ?? ''); + if (strlen($phone) < 7) out(['error' => 'phone too short']); + [$c, $res] = syncro('/customers?phone=' . urlencode($phone)); + $cust = $res['customers'][0] ?? null; + if (!$cust) { [$c, $res] = syncro('/customers?query=' . urlencode($phone)); $cust = $res['customers'][0] ?? null; } + if (!$cust) out(['error' => 'no Syncro customer for that phone']); + $domain = ''; + if (!empty($cust['email']) && strpos($cust['email'], '@') !== false) $domain = substr(strrchr($cust['email'], '@'), 1); + out(['prefill' => [ + 'business_name' => $cust['business_name'] ?: trim(($cust['firstname'] ?? '') . ' ' . ($cust['lastname'] ?? '')), + 'syncro_customer_id' => $cust['id'] ?? '', + 'address' => trim(($cust['address'] ?? '') . ' ' . ($cust['city'] ?? '') . ' ' . ($cust['state'] ?? '') . ' ' . ($cust['zip'] ?? '')), + 'primary_contact' => trim(($cust['firstname'] ?? '') . ' ' . ($cust['lastname'] ?? '')), + 'contact_email' => $cust['email'] ?? '', + 'email_domains' => $domain ? [$domain] : [], + 'tenant_domain' => $domain, + 'assessment_date' => date('Y-m-d'), + ], 'rmm' => null]); +} + +// ---- Consent link generation ---- +if ($action === 'consent') { + $b = body(); $provider = $b['provider'] ?? ''; $domain = trim($b['domain'] ?? ''); + if ($provider === 'm365') { + if (!$domain) out(['error' => 'need tenant domain']); + // Admin-consent prompt for our read-only Security Investigator app (multi-tenant). + out(['url' => 'https://login.microsoftonline.com/' . rawurlencode($domain) + . '/adminconsent?client_id=' . M365_INVESTIGATOR_APP_ID + . '&redirect_uri=' . rawurlencode(CONSENT_REDIRECT)]); + } + if ($provider === 'google') { + if (GOOGLE_CLIENT_ID === '') out(['error' => 'Google client not configured yet']); + out(['url' => 'https://accounts.google.com/o/oauth2/v2/auth?client_id=' . rawurlencode(GOOGLE_CLIENT_ID) + . '&response_type=code&access_type=offline&prompt=consent' + . '&scope=' . rawurlencode(GOOGLE_SCOPES) + . '&redirect_uri=' . rawurlencode(GOOGLE_REDIRECT) + . ($domain ? '&hd=' . rawurlencode($domain) : '')]); + } + out(['error' => 'unknown provider']); +} + +// ---- Save / upsert ---- +if ($action === 'save') { + $b = body(); + $data = json_encode($b['data'] ?? []); + $consent = json_encode($b['consent'] ?? []); + if (!empty($b['id'])) { + db()->prepare('UPDATE assessments SET phone=?, business_name=?, data=?, consent=?, updated=NOW() WHERE id=?') + ->execute([$b['phone'] ?? '', $b['business_name'] ?? '', $data, $consent, $b['id']]); + out(['id' => (int)$b['id']]); + } + db()->prepare('INSERT INTO assessments (phone, business_name, data, consent, created, updated) VALUES (?,?,?,?,NOW(),NOW())') + ->execute([$b['phone'] ?? '', $b['business_name'] ?? '', $data, $consent]); + out(['id' => (int)db()->lastInsertId()]); +} + +// ---- Load ---- +if ($action === 'load') { + $row = db()->prepare('SELECT data, consent FROM assessments WHERE id=?'); + $row->execute([$_GET['id'] ?? 0]); $r = $row->fetch(); + out($r ? ['data' => json_decode($r['data'], true), 'consent' => json_decode($r['consent'], true)] : ['error' => 'not found']); +} + +// ---- List ---- +if ($action === 'list') { + $rows = db()->query('SELECT id, phone, business_name, DATE_FORMAT(updated, "%Y-%m-%d %H:%i") updated FROM assessments ORDER BY updated DESC LIMIT 100')->fetchAll(); + out(['items' => $rows]); +} + +// ---- Export (HTML report-ready intake) ---- +if ($action === 'export') { + $row = db()->prepare('SELECT * FROM assessments WHERE id=?'); $row->execute([$_GET['id'] ?? 0]); $r = $row->fetch(); + if (!$r) { http_response_code(404); exit('not found'); } + $data = json_decode($r['data'], true); $consent = json_decode($r['consent'], true); + $q = json_decode(file_get_contents(__DIR__ . '/questions.json'), true); + header('Content-Type: text/html; charset=utf-8'); + echo 'Assessment intake — ' . htmlspecialchars($r['business_name']) . ''; + echo ''; + echo '

' . htmlspecialchars($r['business_name'] ?: 'Client') . '

Security assessment intake · ' . $r['updated'] . '
'; + foreach ($q['sections'] as $s) { + $rows = ''; + foreach ($s['fields'] as $f) { + if (($f['type'] ?? '') === 'hidden') continue; + $v = $data[$f['id']] ?? ''; + if (($f['type'] ?? '') === 'consent') { $cc = $consent[$f['provider']] ?? []; $v = !empty($cc['granted']) ? 'CONSENTED' : (!empty($cc['url']) ? 'link sent' : ''); } + if (is_array($v)) $v = implode(', ', $v); + if ($v !== '' && $v !== null) $rows .= '
' . htmlspecialchars($f['label']) . '
' . htmlspecialchars($v) . '
'; + } + if ($rows) echo '

' . htmlspecialchars($s['title']) . '

' . $rows; + } + exit; +} + +out(['error' => 'unknown action']); diff --git a/projects/msp-tools/security-assessment/app/config.sample.php b/projects/msp-tools/security-assessment/app/config.sample.php new file mode 100644 index 00000000..f2bcdd13 --- /dev/null +++ b/projects/msp-tools/security-assessment/app/config.sample.php @@ -0,0 +1,15 @@ + + + + + +ACG Security Assessment + + + +
ACG Security
+
+ No client selected + + + + + +
+ +
+ + + + diff --git a/projects/msp-tools/security-assessment/app/questions.json b/projects/msp-tools/security-assessment/app/questions.json new file mode 100644 index 00000000..7f2e6751 --- /dev/null +++ b/projects/msp-tools/security-assessment/app/questions.json @@ -0,0 +1,155 @@ +{ + "version": 1, + "title": "ACG Client Security Assessment", + "note": "Consult intake. Fields marked source=syncro/rmm prefill automatically; source=scan are filled by the post-meeting automated scan (do not ask). Ask only what a human knows.", + "sections": [ + { + "id": "identify", + "title": "Client", + "icon": "building", + "intro": "Enter the client's primary phone number to pull their record from Syncro.", + "fields": [ + { "id": "syncro_phone", "label": "Primary phone (Syncro lookup)", "type": "phone", "source": "input", "lookup": true, "required": true, "help": "Identifies the client. Pulls name, address, contact, domain, asset count." }, + { "id": "business_name", "label": "Business name", "type": "text", "source": "syncro" }, + { "id": "syncro_customer_id", "label": "Syncro customer ID", "type": "hidden", "source": "syncro" }, + { "id": "address", "label": "Address", "type": "text", "source": "syncro" }, + { "id": "primary_contact", "label": "Primary contact", "type": "text", "source": "syncro" }, + { "id": "contact_email", "label": "Contact email", "type": "email", "source": "syncro" }, + { "id": "assessment_date", "label": "Assessment date", "type": "date", "source": "auto" } + ] + }, + { + "id": "scope", + "title": "Scope & Context", + "icon": "target", + "intro": "Sets the scope and risk weighting for the whole audit.", + "fields": [ + { "id": "email_domains", "label": "Email / primary domain(s)", "type": "tags", "source": "syncro", "required": true, "help": "Used to target the 365/Google tenant + DNS/DMARC checks." }, + { "id": "industry", "label": "Industry", "type": "text", "source": "ask" }, + { "id": "employees", "label": "# employees", "type": "number", "source": "ask" }, + { "id": "users", "label": "# of computer/email users", "type": "number", "source": "ask" }, + { "id": "compliance", "label": "Compliance drivers", "type": "multiselect", "source": "ask", "importance": "critical", + "options": ["HIPAA", "PCI-DSS", "CMMC / DFARS", "SOC 2", "GLBA", "FTC Safeguards", "Cyber-insurance requirement", "None / unsure"], + "help": "Drives required controls and report framing." }, + { "id": "sensitive_data", "label": "Sensitive data handled", "type": "multiselect", "source": "ask", "importance": "critical", + "options": ["PHI (health)", "PII (personal)", "Cardholder / payment", "Financial records", "Legal / privileged", "Intellectual property", "None significant"] }, + { "id": "reason", "label": "Reason for assessment", "type": "select", "source": "ask", + "options": ["Proactive / baseline", "Cyber-insurance renewal", "Compliance requirement", "Recent incident / scare", "Client request / concern", "M&A / due diligence"] }, + { "id": "past_incidents", "label": "Known past incidents or breaches?", "type": "textarea", "source": "ask", "help": "Any ransomware, BEC, account takeover, data loss." } + ] + }, + { + "id": "identity", + "title": "Identity & Email", + "icon": "key", + "intro": "Top attack surface. Capture consent here so the scan can run after the meeting.", + "fields": [ + { "id": "mail_platform", "label": "Email platform", "type": "select", "source": "ask", "importance": "critical", + "options": ["Microsoft 365", "Google Workspace", "On-prem Exchange", "Hybrid (365 + on-prem)", "Other / unsure"], "drivesConsent": true }, + { "id": "tenant_domain", "label": "Tenant / workspace primary domain", "type": "text", "source": "syncro", "help": "Used to resolve the tenant ID + generate the consent link." }, + { "id": "mailbox_count", "label": "# mailboxes / users", "type": "number", "source": "ask" }, + { "id": "global_admins", "label": "# of global/super admins", "type": "number", "source": "ask", "help": "Shared admin accounts are a flag." }, + { "id": "shared_admin", "label": "Shared admin account(s) in use?", "type": "boolean", "source": "ask" }, + { "id": "mfa_belief", "label": "MFA enforced for all users? (their understanding)", "type": "select", "source": "ask", + "options": ["Yes, all users", "Admins only", "Some users", "No", "Unsure"], "help": "Scan verifies the reality." }, + { "id": "conditional_access", "label": "Conditional Access / security defaults?", "type": "select", "source": "ask", + "options": ["Conditional Access policies", "Security defaults on", "Neither", "Unsure"] }, + { "id": "mail_security_layer", "label": "Email security layer", "type": "multiselect", "source": "ask", + "options": ["Mailprotector / CloudFilter", "INKY", "Proofpoint", "Mimecast", "Native EOP / Defender", "None"] }, + { "id": "consent_365", "label": "365 read-only assessment consent", "type": "consent", "provider": "m365", "source": "consent", + "help": "Generates the admin-consent link for the client to approve on the spot." }, + { "id": "consent_google", "label": "Google Workspace read-only consent", "type": "consent", "provider": "google", "source": "consent" } + ] + }, + { + "id": "backup", + "title": "Backup & Recovery", + "icon": "shield", + "intro": "Business-critical: the difference between an incident and a catastrophe.", + "fields": [ + { "id": "backup_solution", "label": "Backup solution(s)", "type": "text", "source": "ask", "importance": "high" }, + { "id": "backup_scope", "label": "What is backed up", "type": "multiselect", "source": "ask", + "options": ["Servers", "Microsoft 365 / Google data", "Workstations", "NAS / file shares", "Nothing formal"] }, + { "id": "backup_offsite", "label": "Offsite / immutable copy?", "type": "select", "source": "ask", + "options": ["Yes, immutable/air-gapped", "Yes, offsite (mutable)", "Local only", "Unsure"] }, + { "id": "backup_tested", "label": "Last tested restore", "type": "select", "source": "ask", + "options": ["< 3 months", "3-12 months", "> 12 months / never", "Unsure"], "importance": "high" }, + { "id": "rto_rpo", "label": "Recovery expectations (RTO / RPO)", "type": "text", "source": "ask", "help": "How long down / how much data loss is tolerable." } + ] + }, + { + "id": "endpoints", + "title": "Endpoints", + "icon": "monitor", + "intro": "Counts prefill from GuruRMM where the client is enrolled.", + "fields": [ + { "id": "workstation_count", "label": "# workstations", "type": "number", "source": "rmm" }, + { "id": "server_count", "label": "# servers", "type": "number", "source": "rmm" }, + { "id": "os_mix", "label": "OS mix / any end-of-life?", "type": "multiselect", "source": "rmm", + "options": ["Windows 11", "Windows 10", "Windows 7/8 (EOL)", "Windows Server 2016+", "Server 2012/older (EOL)", "macOS", "Linux"], "importance": "high" }, + { "id": "edr", "label": "Endpoint protection / EDR", "type": "multiselect", "source": "ask", + "options": ["Microsoft Defender", "GuruRMM / managed AV", "SentinelOne", "CrowdStrike", "Other AV", "None / unmanaged"] }, + { "id": "patching", "label": "Patch management", "type": "select", "source": "ask", + "options": ["Managed (us / RMM)", "Managed (other MSP)", "WSUS / internal", "Manual / ad-hoc", "None"] }, + { "id": "disk_encryption", "label": "Disk encryption (BitLocker/FileVault)?", "type": "select", "source": "ask", + "options": ["Yes, enforced", "Some devices", "No", "Unsure"] }, + { "id": "local_admin", "label": "Users have local admin rights?", "type": "select", "source": "ask", + "options": ["No (standard users)", "Some", "Yes (most)", "Unsure"] } + ] + }, + { + "id": "network", + "title": "Network & Perimeter", + "icon": "globe", + "intro": "External exposure. Exposed RDP is the #1 ransomware entry point.", + "fields": [ + { "id": "firewall", "label": "Firewall make/model", "type": "text", "source": "ask" }, + { "id": "remote_access", "label": "Remote access method(s)", "type": "multiselect", "source": "ask", + "options": ["VPN (UniFi/firewall)", "RDP exposed to internet", "RMM remote (us)", "TeamViewer/AnyDesk", "RD Gateway", "None"], "importance": "critical" }, + { "id": "port_forwards", "label": "Known port-forwards / open inbound services?", "type": "textarea", "source": "ask", "help": "Scan verifies via external port check." }, + { "id": "guest_wifi", "label": "Guest WiFi separated from internal?", "type": "select", "source": "ask", + "options": ["Yes, isolated VLAN/SSID", "Shared with internal", "No guest WiFi", "Unsure"] }, + { "id": "sites", "label": "# of physical sites", "type": "number", "source": "ask" } + ] + }, + { + "id": "access", + "title": "Access & Operations", + "icon": "users", + "intro": "People and process — where most breaches actually start.", + "fields": [ + { "id": "password_policy", "label": "Password policy / password manager", "type": "select", "source": "ask", + "options": ["Enforced policy + password manager", "Policy only", "Password manager only", "Neither / unsure"] }, + { "id": "offboarding", "label": "Onboarding/offboarding process documented?", "type": "select", "source": "ask", + "options": ["Yes, documented", "Informal", "No"] }, + { "id": "service_accounts", "label": "Shared / service accounts?", "type": "textarea", "source": "ask" }, + { "id": "vendor_access", "label": "Third-party / vendor access to systems?", "type": "textarea", "source": "ask" }, + { "id": "awareness_training", "label": "Security awareness training?", "type": "select", "source": "ask", + "options": ["Yes, ongoing (KnowBe4/etc.)", "One-time", "None"], "importance": "high" } + ] + }, + { + "id": "cloud", + "title": "Cloud, SaaS & DNS", + "icon": "cloud", + "fields": [ + { "id": "file_sharing", "label": "File sharing / storage", "type": "multiselect", "source": "ask", + "options": ["SharePoint / OneDrive", "Google Drive", "Dropbox", "On-prem file server", "Egnyte / other"] }, + { "id": "critical_saas", "label": "Other critical SaaS apps", "type": "tags", "source": "ask", "help": "Line-of-business apps, accounting, CRM, etc." }, + { "id": "dns_control", "label": "Who controls DNS + domain registrar?", "type": "text", "source": "ask", "help": "Registrar hijack is a top BEC vector." } + ] + }, + { + "id": "physical", + "title": "Physical & Insurance", + "icon": "lock", + "fields": [ + { "id": "physical_security", "label": "Server/network gear physically secured?", "type": "select", "source": "ask", + "options": ["Locked room/rack", "Office only", "Open / exposed", "Unsure"] }, + { "id": "byod", "label": "BYOD / personal devices on company data?", "type": "select", "source": "ask", + "options": ["No / managed only", "Allowed, managed (MDM)", "Allowed, unmanaged", "Unsure"] }, + { "id": "cyber_insurance", "label": "Cyber-insurance carrier", "type": "text", "source": "ask", "help": "Carrier questionnaires often dictate required controls." } + ] + } + ] +} diff --git a/projects/msp-tools/security-assessment/app/schema.sql b/projects/msp-tools/security-assessment/app/schema.sql new file mode 100644 index 00000000..24a17199 --- /dev/null +++ b/projects/msp-tools/security-assessment/app/schema.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS assessments ( + id INT AUTO_INCREMENT PRIMARY KEY, + phone VARCHAR(32), + business_name VARCHAR(255), + data JSON, + consent JSON, + created DATETIME, + updated DATETIME, + INDEX idx_phone (phone), + INDEX idx_updated (updated) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;