Compare commits
32 Commits
6260b5a488
...
cbb6f02c72
| Author | SHA1 | Date | |
|---|---|---|---|
| cbb6f02c72 | |||
| 4615877f1e | |||
| b5bc0409b0 | |||
| 3a7ac35d59 | |||
| 44280dfe98 | |||
| 05f7f1cde2 | |||
| 78d694848a | |||
| a35858058e | |||
| 94330e0ff9 | |||
| 7fb0d95af5 | |||
| bde5ad8706 | |||
| 31cdfa4b02 | |||
| 80065b13e4 | |||
| 5cd4e5c6fb | |||
| be1186aab0 | |||
| 344fbc31be | |||
| 6e42e39b2f | |||
| ac616ae3f5 | |||
| a1a09cd0fb | |||
| 42fa576692 | |||
| 10552b74f6 | |||
| 344c467be4 | |||
| ae9b49a75f | |||
| 2fc1b934eb | |||
| fc80a918ba | |||
| 89ffc6fbe0 | |||
| 88ae32e22e | |||
| d5499741ee | |||
| cfeec7f9f6 | |||
| 120bd8059b | |||
|
|
b7656258b7 | ||
|
|
d2529e9e80 |
@@ -368,8 +368,6 @@ into a conflict, and no synthesis lands in the live article without a review.
|
||||
|
||||
**5.0 Claim a per-article coord lock** (via the `coord` skill):
|
||||
`lock claim claudetools wiki/<type>/<slug> "wiki-compile <slug>" --ttl 1`.
|
||||
- **Capture the returned lock ID** — the claim prints `[coord] lock id=<uuid> ...`. Save it
|
||||
(`LOCK_ID=<uuid>`); the release in Phase 6 takes the **lock ID, not the resource path**.
|
||||
- The TTL auto-evicts a dead session's lock (no permanent stranding).
|
||||
- If the lock is **already held** → emit `[SKIP] wiki/<type>/<slug> is being compiled on
|
||||
another machine; try again shortly` and exit cleanly.
|
||||
@@ -411,8 +409,7 @@ git commit -m "wiki: compile ${SLUG} (${MODE})"
|
||||
git fetch origin && git rebase origin/main # serialized, but rebase defensively
|
||||
git push origin main
|
||||
# Release the per-article lock and clear staging (ALWAYS — even on an earlier abort):
|
||||
# NOTE: `lock release` takes the LOCK ID (captured at claim in 5.0), NOT the resource path.
|
||||
$PY "$CLAUDETOOLS_ROOT/.claude/skills/coord/scripts/coord.py" lock release "$LOCK_ID" 2>/dev/null || true
|
||||
$PY "$CLAUDETOOLS_ROOT/.claude/skills/coord/scripts/coord.py" lock release claudetools "wiki/${TYPE}/${SLUG}" 2>/dev/null || true
|
||||
rm -f "$CLAUDETOOLS_ROOT/.claude/wiki_staging/${TYPE}-${SLUG}.md"
|
||||
```
|
||||
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
- **Machine:** Howard-Home
|
||||
- **Role:** tech
|
||||
|
||||
> Continued same session — see `## Update: 15:21 PT` at the end for the Cascades wiki full recompile and Syncro-note credential vaulting that followed the DESKTOP-TRCIEJA diagnosis.
|
||||
|
||||
## Session Summary
|
||||
|
||||
Diagnosed slow performance on Lupe Sanchez's workstation **DESKTOP-TRCIEJA** (Cascades of Tucson), reported as the machine being slow when opening Excel files on the desktop. Located the agent via `rmm-search` (GuruRMM agent `c9bf1a2d-bfdc-401e-9cc8-f9e90bb19587`, online, Windows 11 Pro build 22000) and ran a full performance diagnostic over the GuruRMM agent in SYSTEM context.
|
||||
@@ -79,47 +77,3 @@ None discovered or created this session. RMM auth via existing vault path `infra
|
||||
- RMM API: `http://172.16.3.30:3001`; vault `infrastructure/gururmm-server.sops.yaml`.
|
||||
- Diagnostic command IDs: `c31c6552-...` (failed combined), `acb9eba4-...` (sanity), `bae771da-ce6e-4b14-be38-b66c07fd96bf` (recon).
|
||||
- Wiki: `wiki/clients/cascades-tucson.md` (line ~415: leftover Datto RMM/EDR cleanup item).
|
||||
|
||||
---
|
||||
|
||||
## Update: 15:21 PT — Cascades wiki full recompile + Syncro-note credential vaulting
|
||||
|
||||
### Session Summary (continuation)
|
||||
|
||||
After the DESKTOP-TRCIEJA diagnosis was saved and synced, ran `/wiki-compile client:cascades-tucson --full` (full recompile). Pulled live Syncro data (customer 20149445: 55.75 prepaid hrs, 0 open tickets, 29 assets, prepaid-block billing), discovered 55 client session logs, claimed the per-article coord lock, and delegated the synthesis to a Sonnet subagent. The subagent wrote the recompiled article to `.claude/wiki_staging/`; reviewed the staged diff (no raw secrets, no conflict markers, all sections intact, Patterns/History preserved — flagged diff lines were re-dated status lines, not deletions), applied staged->live (508->570 lines), updated the index row, committed/pushed, and released the lock.
|
||||
|
||||
While pulling Syncro data, found the Cascades customer-notes field is a large plaintext credential dump. Per Howard's instruction, vaulted the credentials that were NOT already vaulted (and did not overwrite existing entries, since the notes may be stale), and left the Syncro note unscrubbed. Compared each note credential against the existing 23 Cascades vault entries: created 4 new entries, skipped exact-match and stale-conflict creds. Howard confirmed the CSCNet WiFi password is `Ftfd85710#` (vault value), resolving a b/d discrepancy vs the Syncro note's `Ftfb85710#` typo; updated the wifi-cscnet entry's verification metadata.
|
||||
|
||||
### Key Decisions (continuation)
|
||||
|
||||
- **Full recompile via Sonnet subagent** (high-volume read across 55 logs + 508-line article) rather than inline — followed the wiki-compile skill's staged + serialized flow.
|
||||
- **Vault only non-duplicate, non-conflicting credentials.** Where the Syncro note's value differed from an already-vaulted entry, treated the vault as authoritative (note is older) and did NOT overwrite — only flagged.
|
||||
- **Did not scrub the Syncro note** (Howard's call).
|
||||
- **CSCNet password confirmed `Ftfd85710#`** by Howard; Syncro note `Ftfb...` is a transcription typo.
|
||||
|
||||
### Configuration Changes (continuation)
|
||||
|
||||
- `wiki/clients/cascades-tucson.md`: full recompile, 508 -> 570 lines (commit c76bdc4).
|
||||
- `wiki/index.md`: Cascades row refreshed (hours date 2026-06-18, added voice VLAN30/power-outage/DESKTOP-TRCIEJA highlights, date col -> 2026-06-18).
|
||||
- Vault — 4 NEW entries created (commit 39d7da4): `clients/cascades-tucson/m365-default-user-password`, `.../godaddy`, `.../kpax-agent`, `.../alis-api-microsoft-basic`. Each tagged with `source: Syncro customer notes 20149445; may be stale`.
|
||||
- Vault — `clients/cascades-tucson/wifi-cscnet` updated (commit 3db85ce): `password_last_verified=2026-06-18`, added `password_note` documenting the b/d typo and Howard's confirmation.
|
||||
|
||||
### Credentials & Secrets (continuation)
|
||||
|
||||
Vaulted from Syncro customer notes (customer 20149445). NOT reproduced here — see vault paths above. Mapping:
|
||||
- **NEW (vaulted):** M365 default new-user password; GoDaddy (user `cascadestucson`, customer # 191566536); KPAX print-monitoring PUK (Cartridge World, `cartridgeworld.kpax.cloud`); ALIS API basic-auth (`microsoft@cascadestucson`, used by Microsoft to call ALIS).
|
||||
- **Skipped, exact match already vaulted:** Synology NAS (`synology-cascadesds`, 192.168.0.120 admin).
|
||||
- **Skipped, vault has NEWER value (Syncro note stale, not overwritten):** `m365-sysadmin` (sysadmin@cascadestucson.com), `m365-admin` (admin@cascadestucson.com). Syncro note had old generic passwords; vault entries carry the post-2026-06-05 rotated passwords matching the live accounts.
|
||||
- **Resolved discrepancy:** CSCNet WiFi = `Ftfd85710#` (vault, confirmed by Howard); Syncro note's `Ftfb85710#` is a typo.
|
||||
|
||||
### Pending / Incomplete Tasks (continuation)
|
||||
|
||||
- Order replacement workstation for Lupe Sanchez (carried from main diagnosis).
|
||||
- Syncro note for Cascades (customer 20149445) still contains the full plaintext credential dump — left intentionally unscrubbed this session; scrub is a future decision.
|
||||
- M365 admin passwords in the Syncro note are stale — informational only; no action.
|
||||
|
||||
### Reference Information (continuation)
|
||||
|
||||
- Wiki commit: c76bdc4 (cascades-tucson full recompile). Vault commits: 39d7da4 (4 new entries), 3db85ce (wifi-cscnet verify).
|
||||
- Syncro customer: Cascades of Tucson, ID 20149445, 55.75 prepaid hrs, 0 open tickets, 29 assets.
|
||||
- Coord lock id (released): d2d08409-fb95-48f3-b8b3-f47402c3accc on resource wiki/clients/cascades-tucson.
|
||||
|
||||
@@ -17,10 +17,6 @@ Categories (the `[type]` tag): _(none)_ = skill/command execution failure ·
|
||||
|
||||
<!-- Append entries below this line -->
|
||||
|
||||
2026-06-18 | GURU-5070 | agy/search | gemini CLI threw ineligible/projectId setup error (throwIneligibleOrProjectIdError), empty response after 3 attempts [ctx: mode=search host=GURU-5070]
|
||||
|
||||
2026-06-18 | GURU-5070 | agy | gemini returned no response (empty after 3 attempts) [ctx: mode=search err= at process.processTicksAndRejections (node:internal/process/task_queues:104:]
|
||||
|
||||
2026-06-18 | Howard-Home | git/sync-temp-files | [friction] controller-query scratch (.sta.json/.dev.json/.q*) written to repo CWD got swept into the commit by sync.sh 'git add -A', then a stray locked .sta.json blocked the rebase. Fix: write API scratch OUTSIDE the repo (or use the already-ignored .tmp- prefix); gitignored the patterns [ctx: ref=howard-home /tmp friction family]
|
||||
|
||||
2026-06-18 | Howard-Home | rmm | [friction] agent returns exit -1 'Failed to execute command' on a ~7KB multi-line powershell body sent as one command; split into <2KB section scripts and each ran fine [ctx: host=DESKTOP-TRCIEJA agent=0.6.66]
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -30,17 +30,6 @@ try {
|
||||
DSCA3345_TEMPLATES = {};
|
||||
}
|
||||
|
||||
// 8B / SCM5B per-model templates recovered from the Hoffman API (same data gap as
|
||||
// DSCA33/45). Same superset schema plus a verbatim `ftHeader` (8B/5B use two Final-Test
|
||||
// header schemes: legacy "Measured Value" vs DSCA-style "Measured Value*"). Per-model
|
||||
// `validated` gate. SCM7B is NOT templated here (separate parse path). Loaded once.
|
||||
let MINED_8B5BSCM = {};
|
||||
try {
|
||||
MINED_8B5BSCM = require('../8b5bscm-templates.json');
|
||||
} catch (e) {
|
||||
MINED_8B5BSCM = {};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// DATA LINES: parameter names and units per family
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -191,7 +180,7 @@ function getSensorNum(sentype) {
|
||||
// Parse raw_data from DB record
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
function parseRawData(rawData, family, skipStepIfStatus) {
|
||||
function parseRawData(rawData, family) {
|
||||
if (!rawData) return null;
|
||||
|
||||
const lines = rawData.split('\n').map(l => l.trim()).filter(l => l.length > 0);
|
||||
@@ -224,15 +213,13 @@ function parseRawData(rawData, family, skipStepIfStatus) {
|
||||
}
|
||||
|
||||
// Next line: step response / placeholders.
|
||||
// SCM5B/8B: "0","0",value DSCT: just value. Many DSCA models — and the templated
|
||||
// 8B/5B subtypes (8B45/8B49/5B39/SCM5B33...) — OMIT this bare line and go straight to
|
||||
// the STATUS groups; consuming a STATUS group here drops a Final-Test row (the "lines
|
||||
// drop" defect). Skip consuming when the line is actually a STATUS group (PASS/FAIL)
|
||||
// for DSCA or any templated model (skipStepIfStatus); non-templated families keep the
|
||||
// original behavior byte-for-byte.
|
||||
// SCM5B/8B: "0","0",value DSCT: just value. Many DSCA models OMIT this bare
|
||||
// line and go straight to the STATUS groups; consuming a STATUS group here
|
||||
// drops a Final-Test row (the "lines drop" defect). For DSCA, skip consuming
|
||||
// when the line is actually a STATUS group (starts with PASS/FAIL).
|
||||
if (lineIdx < lines.length) {
|
||||
const looksLikeStatus = /^"?(PASS|FAIL)/i.test(lines[lineIdx].trim());
|
||||
if (!((family === 'DSCA' || skipStepIfStatus) && looksLikeStatus)) {
|
||||
if (!(family === 'DSCA' && looksLikeStatus)) {
|
||||
const parts = parseCSVLine(lines[lineIdx++]);
|
||||
const lastVal = parts[parts.length - 1];
|
||||
result.stepResponse = parseFloat(lastVal) || 0;
|
||||
@@ -545,9 +532,9 @@ function formatAccuracyLine(point, sensorNum, maxIn) {
|
||||
* to 3 decimals.
|
||||
* - DSCA45 (frequency input) prints stim as an UNSIGNED integer Hz; calc/meas/error SIGNED.
|
||||
*/
|
||||
function formatAccuracyLineDSCA3345(point, inputType, accOut) {
|
||||
function formatAccuracyLineDSCA3345(point, model, accOut) {
|
||||
const scale = /mA/.test(accOut || '') ? 1000 : 1;
|
||||
const isDSCA45 = inputType === 'freq'; // frequency-input (Hz stim); else AC-RMS
|
||||
const isDSCA45 = /^DSCA45/i.test((model || '').trim());
|
||||
// values were computed in QB single precision; recover the single before formatting
|
||||
// so last-digit rounding at the .5 boundary matches the original (Math.fround).
|
||||
const num = (val, decimals, signed) => ((signed && val >= 0) ? '+' : '') + Math.fround(val).toFixed(decimals);
|
||||
@@ -611,29 +598,24 @@ function generateExactDatasheet(record, specs) {
|
||||
// specs + accuracy label). Source is the staged-original set (dsca-templates) or
|
||||
// the Hoffman-mined set (dsca33-45-templates) for the families whose specs were
|
||||
// lost. No template -> do not guess; skip this cert.
|
||||
const modelKey = (record.model_number || '').trim();
|
||||
// Per-model template driving the Final-Test (+ accuracy) layout. DSCA uses the staged
|
||||
// set or the Hoffman-mined DSCA33/45 set (mined takes PRECEDENCE over a stale staged
|
||||
// entry); 8B/SCM5B use the Hoffman-mined 8b5bscm set. SCM7B is excluded (separate
|
||||
// parse path). `minedTpl` = the Hoffman-mined entry (accHeader/ftHeader + a per-model
|
||||
// `validated` gate).
|
||||
let tpl = null, minedTpl = null;
|
||||
if (family === 'DSCA') {
|
||||
minedTpl = DSCA3345_TEMPLATES[modelKey] || null;
|
||||
tpl = minedTpl || DSCA_TEMPLATES[modelKey] || null;
|
||||
if (!tpl) return null; // no DSCA template -> do not guess the layout
|
||||
} else if (family === '8B' || family === 'SCM5B') {
|
||||
minedTpl = MINED_8B5BSCM[modelKey] || null;
|
||||
tpl = minedTpl; // null -> falls through to the legacy DATA_LINES path
|
||||
}
|
||||
// Hoffman-mined models render only once byte-validated against their original (else
|
||||
// null) so an unverified render can't overwrite a live original. The validation
|
||||
// harness sets DSCA_VALIDATE_MODE to open the gate for its compare.
|
||||
if (minedTpl && minedTpl.accHeader && !minedTpl.validated && !process.env.DSCA_VALIDATE_MODE) return null;
|
||||
const dscaKey = (record.model_number || '').trim();
|
||||
// Hoffman-mined templates take PRECEDENCE: DSCA33/45 were also captured by the
|
||||
// STAGE 1 staged extractor (sometimes with accOut "?" and no accHeader), and that
|
||||
// stale entry must not shadow the authoritative Hoffman-mined one.
|
||||
const dscaTpl = (family === 'DSCA')
|
||||
? (DSCA3345_TEMPLATES[dscaKey] || DSCA_TEMPLATES[dscaKey] || null)
|
||||
: null;
|
||||
if (family === 'DSCA' && !dscaTpl) return null;
|
||||
// Hoffman-mined DSCA33/45 render only once the model is byte-validated against its
|
||||
// Hoffman original — otherwise stay null so an unverified render can't overwrite a
|
||||
// live original. The validation harness sets DSCA_VALIDATE_MODE to render
|
||||
// unvalidated models for the byte-compare; the live service never sets it.
|
||||
if (family === 'DSCA' && DSCA3345_TEMPLATES[dscaKey] && !DSCA3345_TEMPLATES[dscaKey].validated
|
||||
&& !process.env.DSCA_VALIDATE_MODE) return null;
|
||||
|
||||
const parsed = (family === 'SCM7B')
|
||||
? parse7BRawData(record.raw_data)
|
||||
: parseRawData(record.raw_data, family, !!tpl);
|
||||
: parseRawData(record.raw_data, family);
|
||||
if (!parsed) return null;
|
||||
if (family !== 'SCM7B' && parsed.accuracy.length < 5) return null;
|
||||
|
||||
@@ -652,14 +634,10 @@ function generateExactDatasheet(record, specs) {
|
||||
: record.test_date || '';
|
||||
|
||||
let modelName = specs ? specs.MODNAME : record.model_number;
|
||||
// 7B header prepends "SCM" to the model name; templated SCM5B do the same (the spec
|
||||
// MODNAME is stored as "5B49-03" but the original prints "SCM5B49-03").
|
||||
// 7B header prepends "SCM" to the model name
|
||||
if (family === 'SCM7B' && !modelName.toUpperCase().startsWith('SCM')) {
|
||||
modelName = 'SCM' + modelName;
|
||||
}
|
||||
if (tpl && family === 'SCM5B' && !modelName.toUpperCase().startsWith('SCM')) {
|
||||
modelName = 'SCM' + modelName;
|
||||
}
|
||||
|
||||
const lines = [];
|
||||
const TAB5 = ' '; // 4 spaces = TAB(5) in QB (0-indexed)
|
||||
@@ -689,12 +667,12 @@ function generateExactDatasheet(record, specs) {
|
||||
} else {
|
||||
lines.push(' ACCURACY TEST');
|
||||
lines.push('');
|
||||
if (tpl && Array.isArray(tpl.accHeader) && tpl.accHeader.length >= 2) {
|
||||
if (dscaTpl && Array.isArray(dscaTpl.accHeader) && dscaTpl.accHeader.length >= 2) {
|
||||
// DSCA33/45 (Hoffman-mined): the accuracy header carries model-specific tokens
|
||||
// the sensor-type logic can't synthesize (Vin (mVAC), Iin (AAC), Frequency (Hz),
|
||||
// Output (VDC)/(mADC)). Emit the verbatim 2-line header from the original.
|
||||
lines.push(tpl.accHeader[0]);
|
||||
lines.push(tpl.accHeader[1]);
|
||||
lines.push(dscaTpl.accHeader[0]);
|
||||
lines.push(dscaTpl.accHeader[1]);
|
||||
lines.push(TAB5 + '-'.repeat(10) + ' ' + '-'.repeat(11) + ' ' + '-'.repeat(11) + ' ' + '-'.repeat(10) + ' ' + '-'.repeat(8));
|
||||
} else {
|
||||
lines.push(' Calculated Measured');
|
||||
@@ -710,22 +688,15 @@ function generateExactDatasheet(record, specs) {
|
||||
}
|
||||
// DSCA labels its accuracy output column "Output (V)"/"Output (mA)" (from the
|
||||
// template) with '-' rule separators; 5B/8B/etc. use "Vout (V)" with '='.
|
||||
const accOut = (family === 'DSCA' && tpl) ? tpl.accOut : 'Vout (V)';
|
||||
const accOut = (family === 'DSCA' && dscaTpl) ? dscaTpl.accOut : 'Vout (V)';
|
||||
const accSep = (family === 'DSCA') ? '-' : '=';
|
||||
lines.push(' ' + inputHeader + ' ' + accOut + ' ' + accOut + '* Error (%) Status');
|
||||
lines.push(TAB5 + accSep.repeat(10) + ' ' + accSep.repeat(10) + ' ' + accSep.repeat(10) + ' ' + accSep.repeat(9) + ' ' + accSep.repeat(8));
|
||||
}
|
||||
|
||||
// For a mined template, the accuracy DATA columns follow the input type: frequency
|
||||
// (Hz) and AC (AAC/VAC) use the DSCA33/45 conventions (unsigned/integer stim, mA
|
||||
// scaling); standard voltage/current/temperature inputs use the legacy signed
|
||||
// formatter (sensor-type + maxIn from specs, which 8B/5B have).
|
||||
const accHdrStr = (tpl && Array.isArray(tpl.accHeader)) ? tpl.accHeader.join(' ') : '';
|
||||
const accInput = /\(Hz\)/.test(accHdrStr) ? 'freq'
|
||||
: (/AAC|VAC|mVAC/.test(accHdrStr) ? 'ac' : 'std');
|
||||
for (const point of parsed.accuracy) {
|
||||
if (tpl && Array.isArray(tpl.accHeader) && (accInput === 'freq' || accInput === 'ac')) {
|
||||
lines.push(formatAccuracyLineDSCA3345(point, accInput, tpl.accOut));
|
||||
if (dscaTpl && Array.isArray(dscaTpl.accHeader)) {
|
||||
lines.push(formatAccuracyLineDSCA3345(point, record.model_number, dscaTpl.accOut));
|
||||
continue;
|
||||
}
|
||||
lines.push(formatAccuracyLine(point, sensorNum, maxIn));
|
||||
@@ -737,12 +708,12 @@ function generateExactDatasheet(record, specs) {
|
||||
// QB column positions (1-indexed): TAB(31), TAB(47), TAB(60-speclen), TAB(61), TAB(71)
|
||||
lines.push(' FINAL TEST RESULTS');
|
||||
lines.push('');
|
||||
if (tpl) {
|
||||
// Template-driven Final-Test (DSCA, and Hoffman-mined 8B/SCM5B): the rows give
|
||||
// the parameter names + specs directly; the value-bearing raw_data STATUS groups
|
||||
// map positionally onto the spec-bearing rows (via slotMap when counts differ).
|
||||
// Spec-less pass/fail rows (240VAC Withstand / Hi-Pot) render PASS; spec-less
|
||||
// section sub-heads (Zero-Crossing/TTL) render with no status.
|
||||
if (family === 'DSCA') {
|
||||
// DSCA Final-Test renders from the per-model staged template: the rows give
|
||||
// the parameter names + specs (and accuracy label) directly; the value-bearing
|
||||
// raw_data STATUS groups map positionally onto the spec-bearing rows. Rows with
|
||||
// an empty spec (240VAC Withstand / Hi-Pot) carry no measured value and render
|
||||
// as PASS. Header/column scheme matches the staged originals.
|
||||
|
||||
// Value-bearing measurements in source order (drop "PASS"/"" padding entries).
|
||||
const measurements = [];
|
||||
@@ -750,42 +721,37 @@ function generateExactDatasheet(record, specs) {
|
||||
const m = formatMeasuredExact(s);
|
||||
if (m) measurements.push(m);
|
||||
}
|
||||
const specRowCount = tpl.rows.filter(r => (r.spec || '').trim()).length;
|
||||
const specRowCount = dscaTpl.rows.filter(r => (r.spec || '').trim()).length;
|
||||
// The simple positional zip is sound only when there is exactly one measured
|
||||
// value per spec-bearing row. When counts differ, the subtype measures slots the
|
||||
// template omits; use the per-model slotMap (absolute statusEntries index per
|
||||
// spec-bearing row). With no usable slotMap, skip rather than misalign.
|
||||
// value per spec-bearing row. When counts differ, this subtype measures slots
|
||||
// the template omits (e.g. an extra load pair); use the per-model slotMap
|
||||
// (absolute statusEntries index per spec-bearing row, derived from the staged
|
||||
// originals) to pull the right value. With no usable slotMap, skip rather than
|
||||
// misalign ("do not guess").
|
||||
const useSlot = (measurements.length !== specRowCount)
|
||||
&& Array.isArray(tpl.slotMap) && tpl.slotMap.length === specRowCount;
|
||||
&& Array.isArray(dscaTpl.slotMap) && dscaTpl.slotMap.length === specRowCount;
|
||||
if (measurements.length !== specRowCount && !useSlot) return null;
|
||||
|
||||
// Final-Test header: use the verbatim captured header when present (8B/5B use
|
||||
// two schemes — legacy "Measured Value" vs DSCA-style "Measured Value*"); else
|
||||
// the DSCA-style header. The separator is a rule line (cosmetic, canonicalized).
|
||||
if (tpl.ftHeader) {
|
||||
lines.push(tpl.ftHeader);
|
||||
} else {
|
||||
let h1 = setCol('', 12, 'Parameter');
|
||||
h1 = setCol(h1, 31, 'Measured Value*');
|
||||
h1 = setCol(h1, 51, 'Specification');
|
||||
h1 = setCol(h1, 69, 'Status');
|
||||
lines.push(h1);
|
||||
}
|
||||
let h1 = setCol('', 12, 'Parameter');
|
||||
h1 = setCol(h1, 31, 'Measured Value*');
|
||||
h1 = setCol(h1, 51, 'Specification');
|
||||
h1 = setCol(h1, 69, 'Status');
|
||||
lines.push(h1);
|
||||
let h2 = setCol('', 4, '='.repeat(25));
|
||||
h2 = setCol(h2, 31, '='.repeat(15));
|
||||
h2 = setCol(h2, 48, '='.repeat(19));
|
||||
h2 = setCol(h2, 69, '='.repeat(6));
|
||||
lines.push(h2);
|
||||
|
||||
const is3345 = Array.isArray(tpl.accHeader); // Hoffman-mined (DSCA33/45 + 8B/5B)
|
||||
const is3345 = Array.isArray(dscaTpl.accHeader); // Hoffman-mined DSCA33/45
|
||||
let mi = 0, si = 0;
|
||||
for (const row of tpl.rows) {
|
||||
for (const row of dscaTpl.rows) {
|
||||
const spec = (row.spec || '').trim();
|
||||
let line = setCol('', 4, row.name);
|
||||
if (spec) {
|
||||
const su = splitSpecUnit(spec);
|
||||
const m = useSlot
|
||||
? formatMeasuredExact(parsed.statusEntries[tpl.slotMap[si++]])
|
||||
? formatMeasuredExact(parsed.statusEntries[dscaTpl.slotMap[si++]])
|
||||
: measurements[mi++];
|
||||
if (m) {
|
||||
// measured value right-justified ending at col 38, unit at col 40.
|
||||
@@ -814,9 +780,9 @@ function generateExactDatasheet(record, specs) {
|
||||
// Footer load note ("Standard output load for test is ... ohms.") — printed
|
||||
// before the underline, only by the models whose staged original had it
|
||||
// (captured per-model in STAGE 1; not all current-output models print it).
|
||||
if (tpl.loadNote) {
|
||||
if (dscaTpl.loadNote) {
|
||||
lines.push('');
|
||||
lines.push(TAB5 + tpl.loadNote);
|
||||
lines.push(TAB5 + dscaTpl.loadNote);
|
||||
}
|
||||
} else {
|
||||
// QB: TAB(12); "Parameter"; TAB(30); "Measured Value"; TAB(51); "Specification "; TAB(70); "Status"
|
||||
@@ -871,11 +837,8 @@ function generateExactDatasheet(record, specs) {
|
||||
} // end non-DSCA Final Test Results
|
||||
|
||||
// ---- Footer ----
|
||||
// 240 VAC / Hi-Pot (conditional by family/model). Templated models already render
|
||||
// these as spec-less rows from the template, so skip the family-specific footer block.
|
||||
if (tpl) {
|
||||
// no footer Withstand/Hi-Pot block — handled in the template rows
|
||||
} else if (family === 'SCM5B') {
|
||||
// 240 VAC / Hi-Pot (conditional by family/model)
|
||||
if (family === 'SCM5B') {
|
||||
const mn = (modelName || '').trim();
|
||||
if (!mn.startsWith('SCM5BPT') && !mn.startsWith('SCM5B-1369')) {
|
||||
lines.push(TAB5 + '240 VAC Withstand' + ''.padEnd(49) + 'PASS');
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
// Enrich 8b5bscm-templates.json: capture each model's verbatim Final-Test HEADER line
|
||||
// (the "Parameter ... Measured Value[*] ... Specification ... Status" line) from its
|
||||
// _srcSerial Hoffman original, since 8B/5B use two different header schemes (legacy
|
||||
// "Measured Value" vs DSCA-style "Measured Value*"). Stored as `ftHeader`. --apply writes.
|
||||
const fs = require('fs'), https = require('https');
|
||||
const TPL = './8b5bscm-templates.json';
|
||||
const c = JSON.parse(fs.readFileSync('C:\\ProgramData\\dataforth-uploader\\credentials.json', 'utf8'));
|
||||
const APPLY = process.argv.includes('--apply');
|
||||
function req(m, uri, h, b) { return new Promise((res, rej) => { const u = new URL(uri); const r = https.request({ hostname: u.hostname, port: 443, path: u.pathname, method: m, headers: h, timeout: 30000 }, x => { let d = ''; x.on('data', c => d += c); x.on('end', () => { try { res(JSON.parse(d)); } catch { res({ _raw: d }); } }); }); r.on('error', rej); if (b) r.write(b); r.end(); }); }
|
||||
(async () => {
|
||||
const tpl = JSON.parse(fs.readFileSync(TPL, 'utf8'));
|
||||
const form = Object.entries({ grant_type: 'client_credentials', client_id: c.CF_CLIENT_ID, client_secret: c.CF_CLIENT_SECRET, scope: c.CF_SCOPE }).map(([k, v]) => encodeURIComponent(k) + '=' + encodeURIComponent(v)).join('&');
|
||||
const t = (await req('POST', c.CF_TOKEN_URL, { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(form) }, form)).access_token;
|
||||
let done = 0; const schemes = {};
|
||||
for (const m of Object.keys(tpl)) {
|
||||
const sn = tpl[m]._srcSerial; if (!sn) continue;
|
||||
const g = await req('GET', c.CF_API_BASE + '/api/v1/TestReportDataFiles/' + encodeURIComponent(sn), { Authorization: 'Bearer ' + t });
|
||||
if (!g.Content) continue;
|
||||
const L = g.Content.replace(/\r/g, '').split('\n');
|
||||
const fi = L.findIndex(l => /FINAL TEST RESULTS/.test(l)); if (fi < 0) continue;
|
||||
const hi = L.findIndex((l, i) => i > fi && /Parameter\s+Measured/.test(l)); if (hi < 0) continue;
|
||||
tpl[m].ftHeader = L[hi];
|
||||
const star = /Measured Value\*/.test(L[hi]) ? 'star' : 'plain';
|
||||
schemes[star] = (schemes[star] || 0) + 1;
|
||||
done++;
|
||||
}
|
||||
console.log('captured ftHeader for ' + done + ' models; header schemes: ' + JSON.stringify(schemes));
|
||||
if (APPLY) { fs.writeFileSync(TPL, JSON.stringify(tpl)); console.log('[APPLY] wrote ' + TPL); }
|
||||
else console.log('(dry run — pass --apply)');
|
||||
})().catch(e => { console.error('ERR', e.message); process.exit(1); });
|
||||
@@ -6,7 +6,7 @@ process.env.DSCA_VALIDATE_MODE = '1';
|
||||
const fs = require('fs'), https = require('https');
|
||||
const db = require('./database/db');
|
||||
const dse = require('./templates/datasheet-exact');
|
||||
const TPL = process.env.DSCA_TPL || './dsca33-45-templates.json';
|
||||
const TPL = './dsca33-45-templates.json';
|
||||
const c = JSON.parse(fs.readFileSync('C:\\ProgramData\\dataforth-uploader\\credentials.json', 'utf8'));
|
||||
const APPLY = process.argv.includes('--apply');
|
||||
const only = process.argv.slice(2).filter(a => !a.startsWith('--'));
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
// Fix 2 — validate DSCA33/45 Hoffman-mined renders against the live Hoffman originals.
|
||||
// For each model: render its _srcSerial (an already-uploaded unit) via the new render
|
||||
// path and content-normalized-compare it to GET /api/v1/TestReportDataFiles/{_srcSerial}.
|
||||
// --apply marks passing models `validated:true` in dsca33-45-templates.json (the render
|
||||
// gate). Read-only otherwise (no DB writes, no Hoffman writes).
|
||||
process.env.DSCA_VALIDATE_MODE = '1'; // open the render gate for the compare
|
||||
const fs = require('fs');
|
||||
const https = require('https');
|
||||
const db = require('./database/db');
|
||||
const { renderContent } = require('./database/render-datasheet');
|
||||
|
||||
const TPL_PATH = process.env.DSCA_TPL || './dsca33-45-templates.json';
|
||||
const CREDS_PATH = 'C:\\ProgramData\\dataforth-uploader\\credentials.json';
|
||||
const APPLY = process.argv.includes('--apply');
|
||||
const only = process.argv.slice(2).filter(a => !a.startsWith('--'));
|
||||
|
||||
function creds() { return JSON.parse(fs.readFileSync(CREDS_PATH, 'utf8')); }
|
||||
function httpReq(method, uri, headers, body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const u = new URL(uri);
|
||||
const req = https.request({ hostname: u.hostname, port: u.port || 443, path: u.pathname + u.search, method, headers, timeout: 30000 }, res => {
|
||||
let d = ''; res.on('data', c => d += c); res.on('end', () => { try { resolve({ status: res.statusCode, body: JSON.parse(d) }); } catch { resolve({ status: res.statusCode, body: { _raw: d } }); } });
|
||||
});
|
||||
req.on('error', reject); req.on('timeout', () => req.destroy(new Error('timeout')));
|
||||
if (body) req.write(body); req.end();
|
||||
});
|
||||
}
|
||||
async function getToken() {
|
||||
const c = creds();
|
||||
const form = Object.entries({ grant_type: 'client_credentials', client_id: c.CF_CLIENT_ID, client_secret: c.CF_CLIENT_SECRET, scope: c.CF_SCOPE })
|
||||
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&');
|
||||
const r = await httpReq('POST', c.CF_TOKEN_URL, { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(form) }, form);
|
||||
if (r.status !== 200 || !r.body.access_token) throw new Error('token fail ' + r.status);
|
||||
return r.body.access_token;
|
||||
}
|
||||
async function fetchOriginal(token, serial) {
|
||||
const c = creds();
|
||||
const r = await httpReq('GET', `${c.CF_API_BASE}/api/v1/TestReportDataFiles/${encodeURIComponent(serial)}`, { Authorization: 'Bearer ' + token });
|
||||
if (r.status !== 200) return null;
|
||||
return r.body && r.body.Content ? r.body.Content : null;
|
||||
}
|
||||
// content-normalize: collapse whitespace per line; DROP rule lines (pure separators —
|
||||
// runs of = ~ _ -) and blank lines. Rule lines carry no content and their
|
||||
// presence/position is the deferred cosmetic gap (e.g. the leading === letterhead line
|
||||
// the originals have and our renders omit), so removing them isolates real content.
|
||||
function norm(s) {
|
||||
return s.replace(/\r/g, '').split('\n')
|
||||
.map(l => l.trim())
|
||||
.filter(t => t.length > 0 && !(/^[=~_\- ]+$/.test(t) && /[=~_\-]/.test(t)))
|
||||
.map(t => t.replace(/\s+/g, ' '));
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const tpl = JSON.parse(fs.readFileSync(TPL_PATH, 'utf8'));
|
||||
const models = (only.length ? only : Object.keys(tpl)).filter(m => tpl[m]);
|
||||
const token = await getToken();
|
||||
const pass = [], fail = [], noOracle = [], noRec = [];
|
||||
for (const m of models) {
|
||||
const sn = tpl[m]._srcSerial;
|
||||
if (!sn) { noOracle.push(m); continue; }
|
||||
const original = await fetchOriginal(token, sn);
|
||||
if (!original) { noOracle.push(m + '(no Hoffman ' + sn + ')'); continue; }
|
||||
const rec = await db.queryOne('SELECT * FROM test_records WHERE serial_number=$1 AND model_number=$2 LIMIT 1', [sn, m]);
|
||||
if (!rec) { noRec.push(m + '(' + sn + ')'); continue; }
|
||||
let rendered; try { rendered = renderContent(rec); } catch (e) { rendered = null; }
|
||||
if (!rendered) { fail.push({ m, sn, reason: 'render null' }); continue; }
|
||||
const a = norm(rendered), b = norm(original);
|
||||
let diff = -1; const mx = Math.max(a.length, b.length);
|
||||
for (let i = 0; i < mx; i++) { if (a[i] !== b[i]) { diff = i; break; } }
|
||||
if (diff === -1) pass.push(m);
|
||||
else fail.push({ m, sn, line: diff, render: a[diff], golden: b[diff] });
|
||||
}
|
||||
console.log('\n=== DSCA33/45 Hoffman validation ===');
|
||||
console.log('PASS (' + pass.length + '): ' + pass.join(', '));
|
||||
console.log('\nFAIL (' + fail.length + '):');
|
||||
for (const f of fail) {
|
||||
if (f.reason) { console.log(' ' + f.m + ' (' + f.sn + '): ' + f.reason); continue; }
|
||||
console.log(' ' + f.m + ' (' + f.sn + ') first diff L' + f.line);
|
||||
console.log(' render: ' + JSON.stringify(f.render));
|
||||
console.log(' golden: ' + JSON.stringify(f.golden));
|
||||
}
|
||||
if (noOracle.length) console.log('\nNO ORACLE: ' + noOracle.join(', '));
|
||||
if (noRec.length) console.log('NO DB REC: ' + noRec.join(', '));
|
||||
|
||||
if (APPLY) {
|
||||
const passSet = new Set(pass);
|
||||
for (const m of Object.keys(tpl)) {
|
||||
if (passSet.has(m)) tpl[m].validated = true;
|
||||
else if (only.length === 0) delete tpl[m].validated; // full run: clear stale
|
||||
}
|
||||
fs.writeFileSync(TPL_PATH, JSON.stringify(tpl));
|
||||
console.log('\n[APPLY] marked validated on ' + pass.length + ' models in ' + TPL_PATH);
|
||||
} else {
|
||||
console.log('\n(dry run — pass --apply to mark validated)');
|
||||
}
|
||||
await db.close();
|
||||
})().catch(e => { console.error('ERR', e.message, e.stack); process.exit(1); });
|
||||
@@ -1,24 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,12 +0,0 @@
|
||||
# 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).
|
||||
@@ -1 +0,0 @@
|
||||
config.php
|
||||
@@ -1,126 +0,0 @@
|
||||
<?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']);
|
||||
@@ -1,15 +0,0 @@
|
||||
<?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');
|
||||
@@ -1,201 +0,0 @@
|
||||
<?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 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 & 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 & 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>
|
||||
@@ -1,155 +0,0 @@
|
||||
{
|
||||
"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." }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
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;
|
||||
@@ -47,8 +47,6 @@ sources:
|
||||
- clients/cascades-tucson/session-logs/2026-06/2026-06-16-howard-vertical-voice-vlan-plan.md
|
||||
- clients/cascades-tucson/docs/network/voice-vlan-cutover.md
|
||||
- clients/cascades-tucson/docs/network/voice-phone-inventory.md
|
||||
- clients/cascades-tucson/docs/network/network-logging-plan.md
|
||||
- clients/cascades-tucson/session-logs/2026-06/2026-06-18-howard-voice-vlan-migration-logging-plan.md
|
||||
- clients/cascades-tucson/reports/2026-06-16-unifi-full-audit.md
|
||||
- clients/cascades-tucson/reports/2026-06-16-2.4ghz-remediation-runbook.md
|
||||
- clients/cascades-tucson/docs/overview.md
|
||||
@@ -427,7 +425,6 @@ Syncro live pull 2026-06-18: **0 open tickets.** No hours drawn from the 2026-06
|
||||
- **[URGENT] Order replacement workstation for Lupe Sanchez (DESKTOP-TRCIEJA).** Decision made 2026-06-18. EOL Gateway ZX6971 / i3-2120 / 8 GB / Win11-unsupported. On new machine: provision GuruRMM + Bitdefender only; do NOT carry over the Datto stack.
|
||||
- **[URGENT] Rotate exposed Synology Cloud Signin Portal credential.** Vault commit 1fbc0e1 committed it plaintext; encrypted go-forward but credential is exposed in git history. Also verify MDM service account + WiFi CSCNet from that same commit were never plaintext.
|
||||
- **[IN PROGRESS] Voice VLAN (VLAN 30) AudioCodes cutover: 0/8 remaining.** 22/22 Poly + Vertical desktop DONE. Flip USW-16-PoE ports 1-8 native VLAN to VOICE + PoE power-cycle each AudioCodes to re-DHCP. Runbook: `docs/network/voice-vlan-cutover.md`; inventory: `docs/network/voice-phone-inventory.md`.
|
||||
- **[PLANNED] Network logging / observability (spec written, build later).** Diagnosis 2026-06-18: the UniFi controller retains **ZERO** client events/alarms for Cascades (7-day pull) and pfSense logs roll over in hours -> device drops/kicks/deauths are not captured, so the network is a black box after the fact. Plan: **Synology cascadesDS (DSM Log Center syslog server) as the on-site collector** (NOT CS-SERVER -- fragile EOL DC), with pfSense + UniFi-controller + AP syslog as sources and a 1-2 min `/stat/sta` client snapshotter to fill the controller's history gap. Optional later: Container Manager Graylog/Loki + Discord alerting. Spec: `docs/network/network-logging-plan.md`. Next: confirm Synology model/RAM/DSM.
|
||||
- **[PENDING] Wireless RF Phase 0 + Phase 1 (pending go-ahead + evening window):**
|
||||
- Phase 0 (safe anytime): pfSense ping-check off for 240 DHCP pools, disable 3 AM AP firmware auto-upgrade, enable full pfSense logging (DHCP/DNS/firewall/system/gateway) with rotation.
|
||||
- Phase 1 (windowed, per-zone, evening): combined per-AP radio_table PUT -- ng power medium (42 at-Low radios only, not the 24 disabled), na ht 40 (76 radios), na min_rssi -82 (69 at -77). Dry-run clean. Rollback auto-saved. Validate with watch-ap before/after.
|
||||
@@ -519,7 +516,6 @@ Syncro live pull 2026-06-18: **0 open tickets.** No hours drawn from the 2026-06
|
||||
| 2026-06-18 | **Power outage follow-ups: OpenVPN flapping root-caused; kitchen printer casualty resolved.** OpenVPN disconnect/reconnect cycle = configured `--inactive` idle timeout (~300s) on the pfSense server, not a fault. Fix proposed (raise/disable); not applied. Kitchen thermal printer (iPad POS) would not print post-outage -- booted during DHCP-down window, cached disconnected state; fixed by power-cycle. DHCP straggler sweep: 13/13 active senders completing, 0 stuck. |
|
||||
| 2026-06-18 | **Synology Drive sync architecture diagnosed; Team Folder migration plan produced.** Current Drive sync is Sync-user My Drive only (not the real shared folders). Real NAS shares (Server 1.9 G, Management 5.5 G, Public ~50 G, SalesDept ~23 G) are not mirrored. Plan: Team Folder Download-only tasks into `D:\Shares\_SynMigration\<share>` staging; pilot on `/volume1/Server`. No changes made. |
|
||||
| 2026-06-18 | **DESKTOP-TRCIEJA (Lupe Sanchez) performance diagnosed; replace-not-remediate decision.** Root causes: (a) EOL hardware -- Gateway ZX6971 AIO, Intel i3-2120 (2011, 2C/4T), 8 GB RAM, Win11 unsupported; (b) dual real-time AV -- ACG Bitdefender (keep) + leftover Datto stack (Datto RMM/CentraStage + Datto EDR/Infocyte + bundled DattoAV) both scanning every file on a 2-core CPU under memory pressure. OneDrive ruled out (desktop is local). Howard decided: no remediation; order replacement. Another instance of the fleet-wide leftover-Datto-stack cleanup. |
|
||||
| 2026-06-18 | **Voice VLAN 30: all 22 Poly phones migrated; network-logging spec written.** Completed the Poly cutover live -- all 22 WiFi phones re-keyed to the voice PPSK onto `10.0.30.202-.223` (per-phone location inventory in `docs/network/voice-phone-inventory.md`); first phone (Lauren Hasselman) dial-tone + outbound call verified. Vertical desktop fixed via port-16 bounce (controller API + CSRF) -> `10.0.30.201`. AudioCodes (8, wired) still pending (flip + PoE power-cycle). Separately, found the UniFi controller retains **ZERO** client events for Cascades (drop/kick history not captured) -> wrote a network-logging spec (`docs/network/network-logging-plan.md`): Synology Log Center on-site collector, pfSense+UniFi syslog sources, client snapshotter. Plan only -- build later. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ Run `/wiki-lint` to check for stale entries and broken backlinks.
|
||||
|
||||
| Article | Summary | Last Compiled |
|
||||
|---|---|---|
|
||||
| [Cascades of Tucson](clients/cascades-tucson.md) | Prepaid block $175/hr, **55.75 hrs remaining** (live 2026-06-18); senior living; active domain migration + HIPAA compliance project; single DC on aging R610 hardware; caregiver restricted-access model PROVEN 2026-06-05: Hybrid Entra Join + CA allow-list + ALIS SSO validated on NURSESTATION-PC/pilot.test; GPO `CSC - Caregiver Workstation` (shortcuts + printers) built + validated; GPO `CSC - Caregiver Device Lockdown` deployed (HIPAA auto-logoff, activates on reboot); INTUNE_A PendingInput tenant-wide (MS case open; GPO path used instead); folder-redirection root cause fixed 2026-06-08 (fdeploy.ini); shared mailboxes grievances@/Surveys@ created + delegated 2026-06-12 (#32417); Monday cutover to real caregivers pending; #32383 (bill.com/BOK chris.knight) Resolved; UniFi wifi RF (77 U7-Pro APs/~587 clients via UOS controller): 2.4GHz over-coverage = primary pain; pfSense ruled out as cause; Floor-4 power-down pilot applied 2026-06-16 (retry 13.2->9.5%); coverage-thin disable plan + 2.4 remediation runbook staged; DFS empirically clean; 6GHz untapped; CS-SERVER OS RAID-1 degraded 2026-06-15 (data-loss risk; cloud backup now started); Voice VLAN (VLAN 30) consolidation planned 2026-06-16 for Vertical phones + remote desktop (CSCNet confirmed a shared PPSK SSID); KPI dashboard for Ashley Jensen scoped 2026-06-17 (Power BI + SharePoint phased plan, parked); Voice VLAN 30 built + 22/22 Poly cut over 2026-06-17 (AudioCodes 0/8 pending); building power outage 2026-06-17 (pfSense on UPS surge-only side) full site down + recovered; DESKTOP-TRCIEJA (Lupe Sanchez) slow Excel diagnosed 2026-06-18 = EOL i3-2120 hardware + dual real-time AV (leftover Datto stack) -> replace machine; network-logging spec written 2026-06-18 (on-site Synology Log Center; UniFi retains 0 client events -- drop/kick history not captured); Syncro 0 open tickets | 2026-06-18 |
|
||||
| [Cascades of Tucson](clients/cascades-tucson.md) | Prepaid block $175/hr, **55.75 hrs remaining** (live 2026-06-18); senior living; active domain migration + HIPAA compliance project; single DC on aging R610 hardware; caregiver restricted-access model PROVEN 2026-06-05: Hybrid Entra Join + CA allow-list + ALIS SSO validated on NURSESTATION-PC/pilot.test; GPO `CSC - Caregiver Workstation` (shortcuts + printers) built + validated; GPO `CSC - Caregiver Device Lockdown` deployed (HIPAA auto-logoff, activates on reboot); INTUNE_A PendingInput tenant-wide (MS case open; GPO path used instead); folder-redirection root cause fixed 2026-06-08 (fdeploy.ini); shared mailboxes grievances@/Surveys@ created + delegated 2026-06-12 (#32417); Monday cutover to real caregivers pending; #32383 (bill.com/BOK chris.knight) Resolved; UniFi wifi RF (77 U7-Pro APs/~587 clients via UOS controller): 2.4GHz over-coverage = primary pain; pfSense ruled out as cause; Floor-4 power-down pilot applied 2026-06-16 (retry 13.2->9.5%); coverage-thin disable plan + 2.4 remediation runbook staged; DFS empirically clean; 6GHz untapped; CS-SERVER OS RAID-1 degraded 2026-06-15 (data-loss risk; cloud backup now started); Voice VLAN (VLAN 30) consolidation planned 2026-06-16 for Vertical phones + remote desktop (CSCNet confirmed a shared PPSK SSID); KPI dashboard for Ashley Jensen scoped 2026-06-17 (Power BI + SharePoint phased plan, parked); Voice VLAN 30 built + 22/22 Poly cut over 2026-06-17 (AudioCodes 0/8 pending); building power outage 2026-06-17 (pfSense on UPS surge-only side) full site down + recovered; DESKTOP-TRCIEJA (Lupe Sanchez) slow Excel diagnosed 2026-06-18 = EOL i3-2120 hardware + dual real-time AV (leftover Datto stack) -> replace machine; Syncro 0 open tickets | 2026-06-18 |
|
||||
| [Dataforth Corporation](clients/dataforth.md) | Prepaid block ~$2,099/mo, 34.5 hrs remaining; signal conditioning manufacturer; 64 DOS test stations; 2025 crypto attack recovery + incomplete restore (files dropped across shares — migration-gap audit in progress); 2026-03-27 phishing incident + MFA rollout; active test datasheet pipeline project; Neptune Exchange colocated at D2; 2026-06-04 SP1366 file recovery (19/20 PDFs restored from HGHAUBNER pre-attack backup); GuruRMM fleet 13→45 agents; 2026-06-02 Syncro asset reconciliation (78→20 keep/21 flag/28 remove/9 verify); fleet-wide Syncro agent break ~2025-10-06; Bitdefender phase-off in progress | 2026-06-04 |
|
||||
| [Instrumental Music Center](clients/instrumental-music-center.md) | Prepaid block $175/hr, 12.5 hrs remaining; music retail/repair; AIMsi POS on SQL Server 2019; phantom DC causing slow logons; GuruRMM enrolled (IMC1) | 2026-05-24 |
|
||||
| [Valley Wide Plastering](clients/valleywide.md) | Prepaid block, 10 hrs remaining; plastering/stucco contractor; HP DL360 Gen10 + XenServer; VB6 app modernization project; RDWeb brute-force incident; 11 Yealink phones pending | 2026-06-14 |
|
||||
|
||||
Reference in New Issue
Block a user