security.azcomputerguru.com: scaffold the client security-assessment intake app

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-18 14:20:05 -07:00
parent ad1e32fdb0
commit 260be8c2ad
8 changed files with 545 additions and 0 deletions

View File

@@ -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/<acct>/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.

View File

@@ -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).

View File

@@ -0,0 +1 @@
config.php

View File

@@ -0,0 +1,126 @@
<?php
// Backend for the ACG security assessment intake. JSON API behind Cloudflare Access.
require __DIR__ . '/config.php';
header('Content-Type: application/json');
$email = $_SERVER['HTTP_CF_ACCESS_AUTHENTICATED_USER_EMAIL'] ?? '';
if (ALLOWED_EMAIL !== '' && strcasecmp($email, ALLOWED_EMAIL) !== 0) {
http_response_code(403); echo json_encode(['error' => '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 '<!doctype html><meta charset=utf-8><title>Assessment intake — ' . htmlspecialchars($r['business_name']) . '</title>';
echo '<style>body{font:14px/1.5 system-ui;max-width:820px;margin:30px auto;padding:0 16px;color:#0f172a}h1{margin-bottom:2px}h2{margin-top:26px;border-bottom:2px solid #e3e8ee;padding-bottom:4px}.r{display:grid;grid-template-columns:240px 1fr;gap:8px;padding:4px 0}.k{color:#64748b}@media print{body{margin:0}}</style>';
echo '<h1>' . htmlspecialchars($r['business_name'] ?: 'Client') . '</h1><div class=k>Security assessment intake · ' . $r['updated'] . '</div>';
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 .= '<div class=r><div class=k>' . htmlspecialchars($f['label']) . '</div><div>' . htmlspecialchars($v) . '</div></div>';
}
if ($rows) echo '<h2>' . htmlspecialchars($s['title']) . '</h2>' . $rows;
}
exit;
}
out(['error' => 'unknown action']);

View File

@@ -0,0 +1,15 @@
<?php
// Copy to config.php on the server (config.php is gitignored). Secrets live in the SOPS vault.
define('ALLOWED_EMAIL', 'mike@azcomputerguru.com'); // defense-in-depth; Cloudflare Access is the primary gate
define('DB_HOST', 'localhost');
define('DB_NAME', 'acgsec_assess');
define('DB_USER', 'acgsec_app');
define('DB_PASS', 'CHANGE_ME'); // vault: msp-tools/security-assessment-db
define('SYNCRO_BASE', 'https://computerguru.syncromsp.com/api/v1');
define('SYNCRO_KEY', 'CHANGE_ME'); // vault: msp-tools/syncro-mike (Mike's per-user key)
// Read-only "ComputerGuru Security Investigator" multi-tenant app (from the remediation tool):
define('M365_INVESTIGATOR_APP_ID', 'bfbc12a4-f0dd-4e12-b06d-997e7271e10c');
define('CONSENT_REDIRECT', 'https://security.azcomputerguru.com/consent-callback.php');
define('GOOGLE_CLIENT_ID', ''); // set once a Workspace read-only OAuth client exists
define('GOOGLE_SCOPES', 'https://www.googleapis.com/auth/admin.directory.user.readonly https://www.googleapis.com/auth/admin.reports.audit.readonly');
define('GOOGLE_REDIRECT', 'https://security.azcomputerguru.com/google-callback.php');

View File

@@ -0,0 +1,201 @@
<?php
// security.azcomputerguru.com — ACG Client Security Assessment intake.
// Access is gated by Cloudflare Access (only mike@azcomputerguru.com). Defense-in-depth:
// re-check the Cf-Access-Authenticated-User-Email header here too.
require __DIR__ . '/config.php';
$email = $_SERVER['HTTP_CF_ACCESS_AUTHENTICATED_USER_EMAIL'] ?? '';
if (ALLOWED_EMAIL !== '' && strcasecmp($email, ALLOWED_EMAIL) !== 0) {
http_response_code(403);
exit('Forbidden — this tool is restricted.');
}
?><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ACG Security Assessment</title>
<style>
:root{
--bg:#0f172a; --panel:#111c33; --surface:#ffffff; --ink:#0f172a; --ink-2:#475569; --ink-3:#94a3b8;
--line:#e3e8ee; --accent:#1d4ed8; --accent-soft:#eef4ff; --good:#16a34a; --good-bg:#dcfce7;
--warn:#b45309; --warn-bg:#fef3c7; --crit:#b91c1c; --crit-bg:#fee2e2;
--sans:Inter,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif; --r:8px;
}
*{box-sizing:border-box} html,body{height:100%;margin:0}
body{font-family:var(--sans);color:var(--ink);background:#f1f5f9;display:grid;grid-template-columns:260px 1fr;grid-template-rows:56px 1fr;grid-template-areas:"brand top" "rail main";height:100vh;overflow:hidden}
button,input,select,textarea{font-family:inherit}
/* brand + topbar */
.brand{grid-area:brand;background:var(--bg);color:#fff;display:flex;align-items:center;gap:10px;padding:0 18px;font-weight:700;letter-spacing:-.01em}
.brand .sh{width:22px;height:22px;border-radius:5px;background:linear-gradient(135deg,#3b82f6,#1d4ed8);display:inline-block}
.top{grid-area:top;background:#fff;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:14px;padding:0 18px}
.top .client{font-weight:600}.top .muted{color:var(--ink-3)}
.top .sp{flex:1}
.top button{height:34px;padding:0 13px;border:1px solid var(--line);border-radius:var(--r);background:#fff;cursor:pointer;font-size:13px;color:var(--ink)}
.top button:hover{background:#f1f5f9}.top button.pri{background:var(--accent);color:#fff;border-color:var(--accent)}
/* rail */
.rail{grid-area:rail;background:var(--panel);color:#cbd5e1;padding:14px 10px;overflow-y:auto}
.rail .step{display:flex;align-items:center;gap:11px;padding:9px 11px;border-radius:var(--r);cursor:pointer;font-size:13.5px;color:#cbd5e1}
.rail .step:hover{background:#1b2a47}
.rail .step.active{background:#1d4ed8;color:#fff}
.rail .step .n{width:22px;height:22px;border-radius:50%;background:#24344f;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;flex-shrink:0}
.rail .step.active .n{background:#fff;color:#1d4ed8}
.rail .step.done .n{background:var(--good);color:#fff}
.rail .step .pct{margin-left:auto;font-size:10.5px;color:#64748b}
.rail .step.active .pct{color:#bfdbfe}
/* main */
.main{grid-area:main;overflow-y:auto;padding:26px 34px;display:flex;justify-content:center}
.sheet{width:100%;max-width:760px}
.sec-h{display:flex;align-items:baseline;gap:12px;margin:0 0 4px}
.sec-h h1{font-size:21px;margin:0;letter-spacing:-.01em}
.sec-h .ix{font-size:12px;color:var(--ink-3);font-weight:600}
.intro{color:var(--ink-2);font-size:13.5px;margin:0 0 18px}
.field{margin-bottom:16px;background:#fff;border:1px solid var(--line);border-radius:10px;padding:13px 15px}
.field.crit{border-left:3px solid var(--crit)} .field.high{border-left:3px solid var(--warn)}
.field label{display:block;font-size:13.5px;font-weight:600;margin-bottom:6px}
.field .help{font-size:12px;color:var(--ink-3);margin-top:5px}
.field .tag-src{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;padding:1px 6px;border-radius:4px;margin-left:7px;vertical-align:middle}
.src-syncro{background:#e0e7ff;color:#3730a3}.src-rmm{background:#dcfce7;color:#166534}.src-scan{background:#f1f5f9;color:#64748b}.src-ask{background:#fff7ed;color:#9a3412}
.field input[type=text],.field input[type=email],.field input[type=number],.field input[type=tel],.field input[type=date],.field select,.field textarea{
width:100%;height:38px;border:1px solid #cbd5e1;border-radius:7px;padding:0 11px;font-size:14px;background:#fff;color:var(--ink)}
.field textarea{height:74px;padding:9px 11px;resize:vertical}
.field input:focus,.field select:focus,.field textarea:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
.opts{display:flex;flex-wrap:wrap;gap:7px}
.opt{font-size:13px;padding:7px 12px;border:1px solid #cbd5e1;border-radius:20px;background:#fff;cursor:pointer;user-select:none}
.opt.on{background:var(--accent);color:#fff;border-color:var(--accent)}
.lookup{display:flex;gap:8px}.lookup input{flex:1}
.lookup button{height:38px;padding:0 16px;border:0;border-radius:7px;background:var(--accent);color:#fff;font-weight:600;cursor:pointer}
.consent{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.consent button{height:36px;padding:0 13px;border:1px solid var(--accent);border-radius:7px;background:var(--accent-soft);color:var(--accent);font-weight:600;cursor:pointer}
.consent .link{font-size:12px;word-break:break-all;background:#f8fafc;border:1px dashed #cbd5e1;border-radius:6px;padding:7px 9px;flex:1;min-width:240px}
.consent .status{font-size:12px;font-weight:700;padding:2px 9px;border-radius:20px}
.consent .status.pending{background:var(--warn-bg);color:var(--warn)} .consent .status.done{background:var(--good-bg);color:var(--good)}
.navbar{display:flex;gap:10px;margin-top:24px;align-items:center}
.navbar .sp{flex:1}
.navbar button{height:40px;padding:0 20px;border-radius:8px;border:1px solid var(--line);background:#fff;cursor:pointer;font-size:14px;font-weight:600}
.navbar button.pri{background:var(--accent);color:#fff;border-color:var(--accent)}
.navbar button:disabled{opacity:.5;cursor:default}
.savedot{font-size:12px;color:var(--ink-3)}
.review .row{display:grid;grid-template-columns:230px 1fr;gap:10px;padding:7px 0;border-bottom:1px solid var(--line);font-size:13.5px}
.review .row .k{color:var(--ink-3)} .review .row .v{font-weight:500}
.review h3{margin:18px 0 4px;font-size:14px}
</style>
</head>
<body>
<div class="brand"><span class="sh"></span> ACG&nbsp;Security</div>
<div class="top">
<span class="client" id="clientName">No client selected</span>
<span class="muted" id="clientSub"></span>
<span class="sp"></span>
<span class="savedot" id="saveDot"></span>
<button id="btnList">Assessments</button>
<button class="pri" id="btnExport">Export</button>
</div>
<nav class="rail" id="rail"></nav>
<main class="main"><div class="sheet" id="sheet"></div></main>
<script>
const ICONS={building:'🏢',target:'🎯',key:'🔑',shield:'🛡️',monitor:'🖥️',globe:'🌐',users:'👥',cloud:'☁️',lock:'🔒'};
const state={q:null,sec:0,data:{},id:null,consent:{}};
const $=s=>document.querySelector(s);
const api=(action,body)=>fetch('api.php?action='+action,{method:body?'POST':'GET',headers:{'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined}).then(r=>r.json());
async function boot(){
state.q=await fetch('questions.json').then(r=>r.json());
const u=new URLSearchParams(location.search); if(u.get('id')) await load(u.get('id'));
renderRail(); renderSection();
}
function secPct(s){const ask=s.fields.filter(f=>!['hidden','auto'].includes(f.source||'')&&f.type!=='hidden');const done=ask.filter(f=>{const v=state.data[f.id];return Array.isArray(v)?v.length:(v!==undefined&&v!=='')}).length;return ask.length?Math.round(done/ask.length*100):0;}
function renderRail(){
$('#rail').innerHTML=state.q.sections.map((s,i)=>{
const pct=secPct(s);const cls=i===state.sec?'active':(pct===100?'done':'');
return `<div class="step ${cls}" data-i="${i}"><span class="n">${pct===100&&i!==state.sec?'✓':i+1}</span><span>${s.title}</span><span class="pct">${pct}%</span></div>`;
}).join('')+`<div class="step ${state.sec===state.q.sections.length?'active':''}" data-i="${state.q.sections.length}"><span class="n">✓</span><span>Review &amp; finish</span></div>`;
document.querySelectorAll('.rail .step').forEach(el=>el.onclick=()=>{state.sec=+el.dataset.i;renderRail();renderSection();});
}
function field(f){
if(f.type==='hidden')return'';
const v=state.data[f.id];
const src=f.source&&f.source!=='input'&&f.source!=='auto'?`<span class="tag-src src-${f.source}">${f.source}</span>`:'';
const cls='field'+(f.importance==='critical'?' crit':f.importance==='high'?' high':'');
let inner='';
if(f.type==='consent'){inner=consentField(f);}
else if(f.lookup){inner=`<div class="lookup"><input type="tel" id="fld_${f.id}" value="${v||''}" placeholder="(520) 555-1234"><button onclick="doLookup()">Look up</button></div>`;}
else if(f.type==='multiselect'){inner=`<div class="opts">`+f.options.map(o=>`<span class="opt ${(v||[]).includes(o)?'on':''}" data-f="${f.id}" data-o="${o}">${o}</span>`).join('')+`</div>`;}
else if(f.type==='select'){inner=`<select id="fld_${f.id}"><option value="">— select —</option>`+f.options.map(o=>`<option ${v===o?'selected':''}>${o}</option>`).join('')+`</select>`;}
else if(f.type==='boolean'){inner=`<div class="opts">${['Yes','No','Unsure'].map(o=>`<span class="opt ${v===o?'on':''}" data-rf="${f.id}" data-o="${o}">${o}</span>`).join('')}</div>`;}
else if(f.type==='textarea'){inner=`<textarea id="fld_${f.id}">${v||''}</textarea>`;}
else if(f.type==='tags'){inner=`<input type="text" id="fld_${f.id}" value="${Array.isArray(v)?v.join(', '):(v||'')}" placeholder="comma separated">`;}
else {const t=f.type==='number'?'number':f.type==='email'?'email':f.type==='date'?'date':'text';inner=`<input type="${t}" id="fld_${f.id}" value="${v||''}">`;}
return `<div class="${cls}"><label>${f.label}${src}</label>${inner}${f.help?`<div class="help">${f.help}</div>`:''}</div>`;
}
function consentField(f){
const c=state.consent[f.provider]||{};
const status=c.granted?`<span class="status done">✓ consented</span>`:c.url?`<span class="status pending">awaiting click</span>`:'';
return `<div class="consent"><button onclick="genConsent('${f.provider}')">${c.url?'Regenerate':'Generate'} ${f.provider==='m365'?'365':'Google'} link</button>${c.url?`<span class="link">${c.url}</span>`:''}${status}<button onclick="markConsent('${f.provider}')" title="Mark as consented">Mark done</button></div>`;
}
function renderSection(){
const secs=state.q.sections;
if(state.sec>=secs.length){return renderReview();}
const s=secs[state.sec];
$('#sheet').innerHTML=`<div class="sec-h"><span style="font-size:22px">${ICONS[s.icon]||''}</span><h1>${s.title}</h1><span class="ix">${state.sec+1} / ${secs.length}</span></div>${s.intro?`<p class="intro">${s.intro}</p>`:''}<div id="fields">${s.fields.map(field).join('')}</div>${navbar()}`;
wire(s);
}
function navbar(last){
return `<div class="navbar"><button onclick="go(-1)" ${state.sec===0?'disabled':''}>← Back</button><span class="sp"></span><button onclick="saveNow()">Save</button><button class="pri" onclick="go(1)">${last?'Done':'Next →'}</button></div>`;
}
function wire(s){
s.fields.forEach(f=>{
const el=document.getElementById('fld_'+f.id);
if(el)el.onchange=()=>{let v=el.value;if(f.type==='tags')v=v.split(',').map(x=>x.trim()).filter(Boolean);state.data[f.id]=v;debouncedSave();renderRail();};
});
document.querySelectorAll('.opt[data-f]').forEach(el=>el.onclick=()=>{const fid=el.dataset.f,o=el.dataset.o;const a=state.data[fid]||[];const i=a.indexOf(o);i<0?a.push(o):a.splice(i,1);state.data[fid]=a;el.classList.toggle('on');debouncedSave();renderRail();});
document.querySelectorAll('.opt[data-rf]').forEach(el=>el.onclick=()=>{const fid=el.dataset.rf;state.data[fid]=el.dataset.o;document.querySelectorAll(`.opt[data-rf="${fid}"]`).forEach(x=>x.classList.toggle('on',x===el));debouncedSave();renderRail();});
}
function go(d){state.sec=Math.max(0,Math.min(state.q.sections.length,state.sec+d));saveNow();renderRail();renderSection();window.scrollTo(0,0);}
async function doLookup(){
const phone=$('#fld_syncro_phone').value.trim(); if(!phone)return;
$('#clientSub').textContent='looking up…';
const r=await api('lookup',{phone});
if(r.error){$('#clientSub').textContent='not found: '+r.error;return;}
// prefill syncro-sourced fields
Object.assign(state.data,r.prefill||{});
state.data.syncro_phone=phone;
$('#clientName').textContent=r.prefill.business_name||'Client';
$('#clientSub').textContent=(r.prefill.address||'')+(r.rmm?` · ${r.rmm.workstations||0} ws / ${r.rmm.servers||0} srv`:'');
if(r.rmm){state.data.workstation_count=r.rmm.workstations;state.data.server_count=r.rmm.servers;}
saveNow();renderSection();renderRail();
}
async function genConsent(provider){
const r=await api('consent',{provider,domain:state.data.tenant_domain||(state.data.email_domains||[])[0]||'',customer_id:state.data.syncro_customer_id});
if(r.error){alert('Consent link failed: '+r.error);return;}
state.consent[provider]={url:r.url,granted:false};saveNow();renderSection();
}
function markConsent(provider){state.consent[provider]=Object.assign(state.consent[provider]||{},{granted:true});saveNow();renderSection();}
function renderReview(){
let html=`<div class="sec-h"><span style="font-size:22px">✅</span><h1>Review &amp; finish</h1></div><p class="intro">Confirm the captured intake. Export hands the audit work-list to the post-meeting scan.</p><div class="review">`;
for(const s of state.q.sections){
const rows=s.fields.filter(f=>f.type!=='hidden').map(f=>{let v=state.data[f.id];if(f.type==='consent'){const c=state.consent[f.provider]||{};v=c.granted?'✓ consented':(c.url?'link sent':'—');}if(Array.isArray(v))v=v.join(', ');return (v&&v!=='')?`<div class="row"><div class="k">${f.label}</div><div class="v">${v}</div></div>`:'';}).filter(Boolean).join('');
if(rows)html+=`<h3>${ICONS[s.icon]||''} ${s.title}</h3>${rows}`;
}
html+=`</div>${navbar(true)}`;
$('#sheet').innerHTML=html;
}
let saveT=null;
function debouncedSave(){clearTimeout(saveT);saveT=setTimeout(saveNow,800);}
async function saveNow(){
$('#saveDot').textContent='saving…';
const payload={id:state.id,phone:state.data.syncro_phone||'',business_name:state.data.business_name||'',data:state.data,consent:state.consent};
const r=await api('save',payload); if(r.id)state.id=r.id;
history.replaceState(null,'','?id='+state.id);
$('#saveDot').textContent='saved ✓';setTimeout(()=>$('#saveDot').textContent='',1500);
}
async function load(id){const r=await api('load&id='+id);if(r&&r.data){state.id=id;state.data=r.data;state.consent=r.consent||{};$('#clientName').textContent=state.data.business_name||'Client';}}
$('#btnExport').onclick=()=>{window.open('api.php?action=export&id='+(state.id||''),'_blank');};
$('#btnList').onclick=async()=>{const r=await api('list');alert((r.items||[]).map(x=>`#${x.id} ${x.business_name||x.phone} (${x.updated})`).join('\n')||'No assessments yet');};
boot();
</script>
</body>
</html>

View File

@@ -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." }
]
}
]
}

View File

@@ -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;