nsauditor-ai 0.2.56 → 0.2.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +41 -40
  2. package/cli.mjs +171 -147
  3. package/mcp_server.mjs +64 -37
  4. package/package.json +1 -1
  5. package/plugin_manager.mjs +49 -8
  6. package/plugins/040_tls_cert_auditor.mjs +176 -56
  7. package/plugins/050_tribe_health.mjs +13 -34
  8. package/plugins/060_dns_sec_auditor.mjs +24 -30
  9. package/plugins/dns_scanner.mjs +16 -8
  10. package/plugins/ftp_banner_check.mjs +21 -13
  11. package/plugins/host_up_check.mjs +31 -72
  12. package/plugins/http_probe.mjs +93 -25
  13. package/plugins/mcp_scanner.mjs +15 -2
  14. package/plugins/netbios_scanner.mjs +32 -13
  15. package/plugins/opensearch_scanner.mjs +4 -0
  16. package/plugins/port_scanner.mjs +29 -49
  17. package/plugins/result_concluder.mjs +122 -31
  18. package/plugins/snmp_scanner.mjs +20 -8
  19. package/plugins/syn_scanner.mjs +2 -1
  20. package/plugins/upnp_scanner.mjs +82 -13
  21. package/plugins/webapp_detector.mjs +82 -15
  22. package/utils/conclusion_utils.mjs +13 -1
  23. package/utils/cookie_redaction.mjs +50 -0
  24. package/utils/delta_reporter.mjs +70 -25
  25. package/utils/executive_report.mjs +34 -7
  26. package/utils/export_csv.mjs +14 -22
  27. package/utils/mcp_call_digest.mjs +31 -0
  28. package/utils/net_validation.mjs +107 -37
  29. package/utils/output_dir.mjs +4 -2
  30. package/utils/plugin_discovery.mjs +4 -2
  31. package/utils/ports_spec.mjs +46 -0
  32. package/utils/report_md.mjs +49 -125
  33. package/utils/sarif.mjs +33 -93
  34. package/utils/scan_delta.mjs +173 -18
  35. package/utils/scan_delta_view.mjs +3 -3
  36. package/utils/scan_history.mjs +149 -0
  37. package/utils/scheduler.mjs +17 -5
  38. package/utils/service_flags.mjs +363 -0
  39. package/utils/watch_cycle.mjs +37 -0
  40. package/utils/webhook.mjs +2 -1
package/cli.mjs CHANGED
@@ -17,15 +17,17 @@ import { parseHostArg, parseHostFile } from './utils/host_iterator.mjs';
17
17
  import { buildSarifLog } from './utils/sarif.mjs';
18
18
  import { buildCsv } from './utils/export_csv.mjs';
19
19
  import { buildMarkdownReport } from './utils/report_md.mjs';
20
- import { recordScan, getLastScan, computeDiff, formatDiffReport, pruneForCE, HISTORY_FILE } from './utils/scan_history.mjs';
20
+ import { conclusionFindings, maxRank, severityRank as flagSeverityRank, OPEN_SERVICE_SEVERITY } from './utils/service_flags.mjs';
21
+ import { recordScan, getLastScan, computeDiff, formatDiffReport, pruneForCE, HISTORY_FILE, historyServiceEntry, historyHostEntry } from './utils/scan_history.mjs';
21
22
  import { aiBailMessage, computeAiTimeoutMs, aiFailureStubText, aiSummaryLine } from './utils/ai_stage.mjs';
22
23
  import { getTierFromEnv, loadLicense } from './utils/license.mjs';
23
24
  import { resolveCapabilities, hasCapability, inferRequiredTier, CAPABILITIES } from './utils/capabilities.mjs';
24
25
  import { createScheduler } from './utils/scheduler.mjs';
25
- import { buildDeltaReport, formatDeltaSummary, hasSignificantChanges } from './utils/delta_reporter.mjs';
26
+ import { watchCycle } from './utils/watch_cycle.mjs';
27
+ import { redactSetCookieLines } from './utils/cookie_redaction.mjs';
26
28
  import { sendWebhook, buildAlertPayload, isSafeWebhookUrl } from './utils/webhook.mjs';
27
29
  import { scrubByKey } from './utils/redact.mjs';
28
- import { isBlockedIp, resolveAndValidate } from './utils/net_validation.mjs';
30
+ import { isBlockedIp, resolveAndValidate, allowAllHosts, canonicalIp } from './utils/net_validation.mjs';
29
31
  import { getAllTechniques } from './utils/attack_map.mjs';
30
32
  import { TOOL_VERSION } from './utils/tool_version.mjs';
31
33
  import { resolveBaseOutDir, createHostOutDir, safeHost } from './utils/output_dir.mjs';
@@ -105,6 +107,9 @@ export function redactSensitiveForAI(input, targetHost) {
105
107
  s = s.replace(/\b(?:[0-9a-f]{1,4}:){2,7}[0-9a-f]{1,4}\b/gi, '[IPv6]');
106
108
  // The shared rule; this call site's POLICY keeps private addresses and masks public ones.
107
109
  s = scrubIPv4KeepingProductIds(s, (ip) => (isPrivateV4(ip) ? ip : '[IP]'));
110
+ // A scanned service's Set-Cookie VALUE is its session token: redacted here whatever a producer kept (1.3.0, both
111
+ // seams ruled — the webapp detector still reflects its headers into the result).
112
+ s = redactSetCookieLines(s);
108
113
  return s;
109
114
  };
110
115
 
@@ -718,6 +723,8 @@ export async function parseArgs(argv) {
718
723
  }
719
724
  const alertSev = get('alert-severity') || get('alert_severity') || null;
720
725
  args.alertSeverity = (alertSev && alertSev !== true) ? alertSev.toLowerCase() : 'high';
726
+ // R4: by default a watch cycle alerts only hosts whose scan CHANGED; this restores an alert on every cycle.
727
+ args.alertEveryCycle = !!(get('alert-every-cycle') || get('alert_every_cycle'));
721
728
 
722
729
  // Compliance: framework selector + scope file. Forwarded to EE's
723
730
  // runCompliancePhase via enrichScan(). No-op without an EE Enterprise license.
@@ -879,25 +886,37 @@ export function scanTargetRefusal({ cmd, host, hostFile } = {}) {
879
886
  // rather than per scanSingleHost() call (reviewer L1 fold).
880
887
  const CLOUD_SENTINEL_HOSTS = new Set(['aws', 'gcp', 'azure']);
881
888
 
882
- async function scanSingleHost(pm, host, plugins, opts, promptMode) {
883
- // SSRF guard — block loopback, private ranges, cloud metadata endpoints.
884
- // Set NSA_ALLOW_ALL_HOSTS=1 to scan RFC 1918 / private ranges (local network auditing).
885
- // Cloud-sentinel hosts (see CLOUD_SENTINEL_HOSTS above) skip the guard.
889
+ /**
890
+ * The CLI's scan-target SSRF guard (exported so tests drive the REAL code — 1.3.0 lane 1 E).
891
+ * Cloud sentinels (aws/gcp/azure) skip it. NSA_ALLOW_ALL_HOSTS (1/true/yes/on only) lifts the WHOLE
892
+ * guard on the CLI, loopback and metadata included — documented, and the local-network smoke tests
893
+ * scan loopback that way. Otherwise a literal is classified by value in any spelling, and anything
894
+ * that is not a literal is resolved with EVERY answer checked. Before 1.3.0 a digits-and-dots string
895
+ * skipped resolution (/^[\\d.:[\\]]+$/), so '6425673729' — which the OS reads as 127.0.0.1 — was
896
+ * scanned unchecked.
897
+ * @param {string} host
898
+ * @param {Record<string,string|undefined>} [env]
899
+ */
900
+ export async function assertScanTargetAllowed(host, env = process.env) {
886
901
  const isCloudSentinel = typeof host === 'string' && CLOUD_SENTINEL_HOSTS.has(host.toLowerCase());
887
-
888
- if (!process.env.NSA_ALLOW_ALL_HOSTS && !isCloudSentinel) {
889
- if (isBlockedIp(host)) {
890
- throw new Error(`Scanning blocked address range is not allowed: ${host}`);
891
- }
892
- // Hostname (not literal IP) — resolve and validate the resolved address
893
- if (!/^[\d.:[\]]+$/.test(host)) {
894
- try {
895
- await resolveAndValidate(host);
896
- } catch (err) {
897
- throw new Error(`Host rejected by SSRF guard: ${err.message}`);
898
- }
902
+ if (isCloudSentinel || allowAllHosts(env)) return;
903
+ if (isBlockedIp(host)) {
904
+ throw new Error(`Scanning blocked address range is not allowed: ${host}`);
905
+ }
906
+ if (!canonicalIp(host)) {
907
+ try {
908
+ await resolveAndValidate(host);
909
+ } catch (err) {
910
+ throw new Error(`Host rejected by SSRF guard: ${err.message}`);
899
911
  }
900
912
  }
913
+ }
914
+
915
+ export async function scanSingleHost(pm, host, plugins, opts, promptMode) {
916
+ // SSRF guard — block loopback, private ranges, cloud metadata endpoints.
917
+ // Set NSA_ALLOW_ALL_HOSTS=1 to scan RFC 1918 / private ranges (local network auditing).
918
+ // Cloud-sentinel hosts (see CLOUD_SENTINEL_HOSTS above) skip the guard.
919
+ await assertScanTargetAllowed(host);
901
920
 
902
921
  // `manifest` classifies every selected plugin as ran|skipped|timeout|error with a reason.
903
922
  // Discarding it is what makes "no findings" ambiguous between a clean estate and one whose
@@ -1034,6 +1053,9 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
1034
1053
 
1035
1054
  // --- Scan history: record & compare ---
1036
1055
  let scanDiff = null;
1056
+ // The per-host summary [ScanHistory] records — also what a --watch cycle compares (1.3.0 items 4 + 11). Null when the
1057
+ // history step failed before building it; the watch delta reads null as NOT COMPARED, never as an empty scan.
1058
+ let scanSummary = null;
1037
1059
  // Whether THIS host actually landed in the run record's `hostsWritten` — distinct from
1038
1060
  // whether the scan itself succeeded. `appendHostWritten` can fail independently (a
1039
1061
  // malformed argument, a disk fault) while the rest of the scan completes cleanly, and the
@@ -1107,15 +1129,8 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
1107
1129
  }
1108
1130
 
1109
1131
  const services = conclusion?.result?.services ?? [];
1110
- const serviceFindingsCount = services.reduce((n, svc) => {
1111
- if (svc.anonymousLogin === true) n++;
1112
- if (svc.axfrAllowed === true) n++;
1113
- if (Array.isArray(svc.weakAlgorithms)) n += svc.weakAlgorithms.length;
1114
- if (Array.isArray(svc.dangerousMethods)) n += svc.dangerousMethods.length;
1115
- const cves = svc.cves || svc.cve || [];
1116
- if (Array.isArray(cves)) n += cves.length;
1117
- return n;
1118
- }, 0);
1132
+ // The legacy fallback's service share, graded by the shared service-flag table (1.3.0 (s1)) like every other reader.
1133
+ const serviceFindingsCount = conclusionFindings(conclusion, host).length;
1119
1134
  // review fold R-1: cloud plugins emit findings on `results[].result.findings`,
1120
1135
  // NOT as service-level attrs — so a cloud (--host aws) scan recorded
1121
1136
  // findingsCount:0 in scan_history over a 201-finding scan (a false-clean
@@ -1155,7 +1170,7 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
1155
1170
  + 'legacy sum and marking the line as un-based, so no comparison is made across it.');
1156
1171
  }
1157
1172
 
1158
- const scanSummary = {
1173
+ scanSummary = {
1159
1174
  timestamp: new Date().toISOString(),
1160
1175
  host,
1161
1176
  servicesCount: services.length,
@@ -1171,10 +1186,9 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
1171
1186
  // unreachable in production while its unit tests stay green.
1172
1187
  tier: getTierFromEnv(),
1173
1188
  cloudFindingsCount,
1174
- services: services.map((s) => ({
1175
- port: s.port, protocol: s.protocol ?? 'tcp',
1176
- service: s.service ?? null, version: s.version ?? null,
1177
- })),
1189
+ services: services.map(historyServiceEntry),
1190
+ // 1.3.0 (s1) B: the basis stamp and the host-level service checks (a domain's DNS posture, wherever it landed).
1191
+ ...historyHostEntry(conclusion),
1178
1192
  };
1179
1193
 
1180
1194
  // Retrieve previous scan for this host before recording the new one
@@ -1201,6 +1215,11 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
1201
1215
  host, results, conclusion, ai_file_paths, ai_conclusion, ai_status, ai_error, scanDiff,
1202
1216
  // Additive, consumed only by main()'s top-level KEV/EPSS aggregation for the run record.
1203
1217
  hostAppended, kevDataAsOf, epssDataAsOf, nvdCache,
1218
+ // Additive (1.3.0 items 4 + 11): what a --watch cycle compares, host by host.
1219
+ scanSummary,
1220
+ // Additive (1.3.0 build 4, Gate 3-A F-1): the plugin manifest, so the Markdown report says an audit that did not run
1221
+ // — 060's decline of an IP address — the same way scan_host's does. The run record already carries it.
1222
+ pluginStatus,
1204
1223
  };
1205
1224
  }
1206
1225
 
@@ -1241,13 +1260,6 @@ function aggregateStoreLoad(writtenOutputs, field, label) {
1241
1260
 
1242
1261
  const SEVERITY_RANK = { critical: 4, high: 3, medium: 2, low: 1, info: 0 };
1243
1262
 
1244
- /**
1245
- * Determine the maximum severity level present in a conclusion's services.
1246
- * Checks security findings (anonymousLogin, axfrAllowed, weakAlgorithms,
1247
- * dangerousMethods, CVEs) as well as open service status.
1248
- * @param {object} conclusion
1249
- * @returns {number} highest severity rank found (0-4)
1250
- */
1251
1263
  async function readSecretFromStdin(keyName) {
1252
1264
  if (!process.stdin.isTTY) {
1253
1265
  // Piped input
@@ -1269,33 +1281,18 @@ async function readSecretFromStdin(keyName) {
1269
1281
  });
1270
1282
  }
1271
1283
 
1284
+ /**
1285
+ * The highest severity rank (0-4) among the findings a conclusion carries — the `--fail-on` gate.
1286
+ * @param {object} conclusion
1287
+ * @returns {number} highest severity rank found (0-4); any concluded scan is at least info (0)
1288
+ */
1272
1289
  export function maxSeverityInConclusion(conclusion) {
1273
- const services = conclusion?.result?.services || [];
1274
- let max = 0;
1275
-
1276
- for (const svc of services) {
1277
- // anonymousLogin or axfrAllowed → Critical
1278
- if (svc.anonymousLogin === true) max = Math.max(max, SEVERITY_RANK.critical);
1279
- if (svc.axfrAllowed === true) max = Math.max(max, SEVERITY_RANK.critical);
1280
-
1281
- // weakAlgorithms or dangerousMethods → Medium
1282
- if (Array.isArray(svc.weakAlgorithms) && svc.weakAlgorithms.length > 0) max = Math.max(max, SEVERITY_RANK.medium);
1283
- if (Array.isArray(svc.dangerousMethods) && svc.dangerousMethods.length > 0) max = Math.max(max, SEVERITY_RANK.medium);
1284
-
1285
- // CVEs
1286
- const cves = svc.cves || svc.cve || [];
1287
- if (Array.isArray(cves)) {
1288
- for (const cve of cves) {
1289
- const sev = typeof cve === 'string' ? 'high' : String(cve?.severity || 'high').toLowerCase();
1290
- max = Math.max(max, SEVERITY_RANK[sev] ?? SEVERITY_RANK.high);
1291
- }
1292
- }
1293
-
1294
- // Open service → Info (baseline)
1295
- if (svc.status === 'open') max = Math.max(max, SEVERITY_RANK.info);
1296
- }
1297
-
1298
- return max;
1290
+ // Every grade comes from the shared service-flag table (utils/service_flags.mjs, 1.3.0 (s1)) — the gate reads what the
1291
+ // Markdown report, SARIF and the CSV read, including a payload that landed in the conclusion's evidence. An open service
1292
+ // is graded OPEN_SERVICE_SEVERITY, the grade SARIF gives it ((s2)); any concluded scan is at least info.
1293
+ const open = (conclusion?.result?.services || []).some((s) => s.status === 'open');
1294
+ return Math.max(SEVERITY_RANK.info, open ? flagSeverityRank(OPEN_SERVICE_SEVERITY) : -1,
1295
+ maxRank(conclusionFindings(conclusion, '')));
1299
1296
  }
1300
1297
 
1301
1298
  /**
@@ -1637,7 +1634,7 @@ export async function runReport(args, caps) {
1637
1634
 
1638
1635
  export async function main(testHooks = {}) {
1639
1636
  const args = await parseArgs(process.argv);
1640
- const { cmd, host, plugins, insecureHttps, hostFile, parallel, failOn, outputFormat, watch, intervalMinutes, webhookUrl, alertSeverity, ports, compliance, complianceScope, complianceHistory, slaPolicy, attestWindow, framework, awsRegion, approvalArgs, feedArgs } = args;
1637
+ const { cmd, host, plugins, insecureHttps, hostFile, parallel, failOn, outputFormat, watch, intervalMinutes, webhookUrl, alertSeverity, alertEveryCycle, ports, compliance, complianceScope, complianceHistory, slaPolicy, attestWindow, framework, awsRegion, approvalArgs, feedArgs } = args;
1641
1638
 
1642
1639
  // Version: handled before license verification so it works without a key.
1643
1640
  // CE-0.1.30.1 — closes the discovery-flag UX gap where pre-fix
@@ -1681,27 +1678,41 @@ Scan options:
1681
1678
  a range such as 1-1000 is not parsed and adds nothing)
1682
1679
  --out <dir> Output directory for scan artifacts
1683
1680
  --parallel <n> Parallel host concurrency (default 1)
1684
- --fail-on <severity> Exit 1 if a gated flag ≥ severity: anonymous FTP / zone
1685
- transfer (critical; tested only with FTP_CHECK_ANON /
1686
- DNS_CHECK_AXFR set), weak SSH algorithms (medium); any
1687
- concluded scan is info. Dangerous HTTP methods are read
1688
- but never reach the conclusion today. Not SNMP community,
1689
- weak TLS, MCP checks, CVEs or agent findings — exit 0 is
1690
- not a clean host. Exit 2: unknown severity or no conclusion.
1681
+ --fail-on <severity> Exit 1 if a finding ≥ severity — the findings the md /
1682
+ SARIF / CSV reports count, graded by one shared table:
1683
+ SNMP default community, weak SSH / TLS, MCP checks, SMB
1684
+ null session, self-signed / expired certificates, the
1685
+ 040 / 050 / 060 audits (060: the domain's SPF / DMARC /
1686
+ NS), and Dangerous HTTP methods (medium) only where an
1687
+ Allow header was read. Anonymous FTP / zone transfer /
1688
+ null session only with FTP_CHECK_ANON / DNS_CHECK_AXFR /
1689
+ SMB_NULL_SESSION set; not tested never trips it; any
1690
+ concluded scan is info. Not CVEs or agent findings —
1691
+ exit 0 is not a clean host. Exit 2: unknown severity or
1692
+ no conclusion.
1691
1693
  --output-format <fmt> Additional report format: sarif | csv | md
1692
1694
  --insecure-https Skip TLS validation on probed HTTPS targets
1693
- --watch CTEM continuous ALERTING mode: re-scan on --interval,
1694
- compare each host with its previous scan ([ScanHistory]
1695
- lines); --webhook-url does NOT fire on a service, version or
1696
- finding change in this release. NOT an evidence cadence —
1695
+ --watch CTEM continuous ALERTING mode: re-scan on --interval and
1696
+ compare each host with the previous cycle. --webhook-url
1697
+ alerts a host whose scan changed (a service, its finding
1698
+ count or a service check, or a comparison that could not be
1699
+ made) and that has a finding at or above --alert-severity.
1700
+ The first cycle sets the baseline and alerts nobody; a host
1701
+ whose scan failed is reported on stdout and does not alert in
1702
+ this release. NOT an evidence cadence —
1697
1703
  it adds no retention or cross-run aggregation, skips SARIF/
1698
1704
  CSV/Markdown output and --fail-on, and dies with this process.
1699
1705
  Each tick is an ordinary scan, so with --compliance it writes
1700
1706
  that tick's artifacts; nothing relates them across ticks. For
1701
1707
  SOC 2 Type II history use a scheduler (cron/systemd/CI).
1708
+ Watch mode scans each distinct host once per cycle; a
1709
+ one-shot --host X,X scans X twice.
1702
1710
  --interval <minutes> Watch interval (default 60)
1703
1711
  --webhook-url <url> Send --watch alerts (must be public; private/loopback blocked)
1704
1712
  --alert-severity <sev> Min severity to alert on (default: high)
1713
+ --alert-every-cycle Alert every host with a finding at or above --alert-severity
1714
+ on every --watch cycle, the first included (default: only
1715
+ hosts whose scan changed)
1705
1716
  --compliance <framework> Map findings to controls. 'all' = all 8 frameworks, or a CSV of
1706
1717
  soc2,hipaa,nist-csf,pci-dss,iso-27001,cis-v8,gdpr,nist-800-171
1707
1718
  (aliases nist/pci/iso/cis, and 800-171 or cmmc for nist-800-171 --
@@ -2690,30 +2701,45 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
2690
2701
  }
2691
2702
  }
2692
2703
  } else if (subCmd === 'verify-call') {
2693
- // CE 0.1.36 (Thread L Phase 2): cryptographic ground truth for
2694
- // "did Claude actually call the MCP server, or hallucinate a
2695
- // response?" Server mints a fresh UUID per tools/call, embeds it
2696
- // in the response text, AND appends to ~/.nsauditor/mcp-calls.log.
2697
- // Customer pastes the UUID here; we grep the log. UUID present →
2698
- // proven real call. UUID absent → fabricated (or log was rotated/
2699
- // deleted; we say "unverifiable" rather than "fake").
2704
+ // 1.3.0 (s6): BIND THE RESPONSE TEXT, not only the id. The server logs each call's id and a digest of the response
2705
+ // body (utils/mcp_call_digest.mjs); this recomputes the digest from the text the user saved. The id alone proves
2706
+ // only that the server issued it once — an AI client that has seen an earlier response can paste a real id under
2707
+ // a fabricated one — so an id with no text is INDETERMINATE, never "verified".
2708
+ // Exit: 0 verified · 1 refuted (never issued, or the text does not match) · 2 usage · 3 indeterminate.
2700
2709
  const { readFile, stat } = await import('node:fs/promises');
2701
2710
  const { join: _join } = await import('node:path');
2702
2711
  const { homedir: _homedir } = await import('node:os');
2712
+ const { responseDigest, bodyOf } = await import('./utils/mcp_call_digest.mjs');
2703
2713
  const logPath = _join(_homedir(), '.nsauditor', 'mcp-calls.log');
2704
2714
  const uuid = rawArgs[2];
2705
- if (!uuid) {
2706
- console.error('Usage: nsauditor-ai mcp verify-call <uuid>');
2707
- console.error(' Paste the call_id from the MCP tool response footer.');
2715
+ const respAt = rawArgs.indexOf('--response');
2716
+ const respPath = respAt > 0 ? rawArgs[respAt + 1] : null;
2717
+ if (!uuid || (respAt > 0 && !respPath)) {
2718
+ console.error('Usage: nsauditor-ai mcp verify-call <uuid> --response <file|->');
2719
+ console.error(' Save the whole tool response (footer included) to a file, or pipe it in with --response -.');
2708
2720
  process.exit(2);
2709
2721
  }
2710
- // Conservative UUID v4 shape check — avoid grepping the log with
2711
- // arbitrary user input.
2722
+ // Conservative UUID v4 shape check — avoid grepping the log with arbitrary user input.
2712
2723
  if (!/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(uuid)) {
2713
2724
  console.error(`✗ Not a valid UUID: ${uuid}`);
2714
2725
  console.error(' Expected format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx');
2715
2726
  process.exit(2);
2716
2727
  }
2728
+ let responseText = null;
2729
+ if (respPath) {
2730
+ try {
2731
+ if (respPath === '-') {
2732
+ const chunks = [];
2733
+ for await (const c of process.stdin) chunks.push(c);
2734
+ responseText = Buffer.concat(chunks).toString('utf8');
2735
+ } else {
2736
+ responseText = await readFile(respPath, 'utf8');
2737
+ }
2738
+ } catch (err) {
2739
+ console.error(`✗ Could not read the response from ${respPath}: ${err.message}`);
2740
+ process.exit(2);
2741
+ }
2742
+ }
2717
2743
  let logExists = false;
2718
2744
  try { await stat(logPath); logExists = true; } catch { /* missing */ }
2719
2745
  if (!logExists) {
@@ -2726,33 +2752,50 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
2726
2752
  const raw = await readFile(logPath, 'utf8');
2727
2753
  // Look for an exact JSON-string match on call_id to avoid prefix collisions.
2728
2754
  const needle = `"call_id":"${uuid.toLowerCase()}"`;
2729
- const lines = raw.split('\n').filter((l) => l.includes(needle));
2730
- if (lines.length === 0) {
2755
+ const entries = raw.split('\n').filter((l) => l.includes(needle))
2756
+ .map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
2757
+ if (entries.length === 0) {
2731
2758
  console.log(`✗ call_id not found in ${logPath}`);
2732
2759
  console.log('');
2733
2760
  console.log(` ${uuid}`);
2734
2761
  console.log('');
2735
2762
  console.log(' This UUID was NOT issued by this MCP server. Most likely cause:');
2736
- console.log(' Claude Desktop fabricated the response without invoking the server.');
2737
- console.log(' (See README §"Verifying that Claude actually called the MCP server".)');
2763
+ console.log(' the AI client fabricated the response without invoking the server.');
2764
+ console.log(' (See docs/mcp-verification.md.)');
2738
2765
  process.exit(1);
2739
2766
  }
2740
- try {
2741
- const entry = JSON.parse(lines[lines.length - 1]);
2742
- console.log(`✓ Verified MCP call`);
2743
- console.log(` call_id: ${entry.call_id}`);
2744
- console.log(` tool: ${entry.tool}`);
2745
- console.log(` ts: ${entry.ts}`);
2746
- console.log(` log: ${logPath}`);
2747
- console.log('');
2748
- console.log(' This UUID was issued by the local MCP server, so the response');
2749
- console.log(' bearing it was a genuine tool call (not a hallucination).');
2750
- process.exit(0);
2751
- } catch {
2752
- console.log(`✓ Verified MCP call (matched ${lines.length} log line(s) for this UUID)`);
2767
+ const issued = entries.find((e) => e.tool) ?? entries[0];
2768
+ const ago = (() => {
2769
+ const ms = Date.now() - Date.parse(issued.ts ?? '');
2770
+ if (!Number.isFinite(ms)) return 'at an unrecorded time';
2771
+ const units = [['day', 86400000], ['hour', 3600000], ['minute', 60000]];
2772
+ for (const [u, n] of units) if (ms >= n) { const k = Math.floor(ms / n); return `${k} ${u}${k === 1 ? '' : 's'} ago`; }
2773
+ return 'less than a minute ago';
2774
+ })();
2775
+ const issuedLine = `${issued.tool ?? 'an unrecorded tool'}, issued ${ago} (${issued.ts ?? 'no timestamp'})`;
2776
+ const digests = entries.map((e) => e.digest).filter((d) => typeof d === 'string');
2777
+ if (digests.length === 0) {
2778
+ console.log(`? call_id ${uuid} was issued by this server for ${issued.tool ?? 'an unrecorded tool'} ${ago} (${issued.ts ?? 'no timestamp'}).`);
2779
+ console.log(' This call predates response binding: no digest of its response was recorded, so the text');
2780
+ console.log(' cannot be bound to it. The id alone does not verify a response.');
2781
+ process.exit(3);
2782
+ }
2783
+ if (responseText == null) {
2784
+ console.log(`? call_id ${uuid} was issued by this server for ${issued.tool ?? 'an unrecorded tool'} ${ago} (${issued.ts ?? 'no timestamp'}).`);
2785
+ console.log(' That alone does not verify the text you are reading: an id from an earlier response can be');
2786
+ console.log(' pasted under a fabricated one. Save the raw tool response and run:');
2787
+ console.log(` nsauditor-ai mcp verify-call ${uuid} --response <file>`);
2788
+ process.exit(3);
2789
+ }
2790
+ if (digests.includes(responseDigest(bodyOf(responseText)))) {
2791
+ console.log(`✓ This response text was produced by this server for that call — ${issuedLine}.`);
2753
2792
  console.log(` log: ${logPath}`);
2754
2793
  process.exit(0);
2755
2794
  }
2795
+ console.log(`✗ The text does not match what this server sent for that call (${issuedLine}).`);
2796
+ console.log(' Either it was altered after copying (rendering, re-wrapping) or it was not produced by this');
2797
+ console.log(' call — re-copy the RAW tool output and verify again.');
2798
+ process.exit(1);
2756
2799
  } else {
2757
2800
  console.log('Usage:');
2758
2801
  console.log(' nsauditor-ai mcp install-key Generate a new key, persist, print Claude config');
@@ -2761,7 +2804,7 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
2761
2804
  console.log(' nsauditor-ai mcp rotate-key Replace the stored key with a fresh one');
2762
2805
  console.log(' nsauditor-ai mcp status Show storage source without revealing the key');
2763
2806
  console.log(' nsauditor-ai mcp tier Print actual MCP server tier (ground truth, bypasses Claude AI synthesis)');
2764
- console.log(' nsauditor-ai mcp verify-call <uuid> Prove a tool response came from the real MCP server (not Claude hallucination)');
2807
+ console.log(' nsauditor-ai mcp verify-call <uuid> --response <file|-> Check that a saved tool response is the text this server produced for that call');
2765
2808
  console.log('');
2766
2809
  console.log('Environment variables:');
2767
2810
  console.log(` ${MCP_AUTH_ENV_VAR} Read by mcp_server.mjs at startup; client supplies via Claude config`);
@@ -3197,9 +3240,6 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
3197
3240
  // --- CTEM: continuous watch mode ---
3198
3241
  if (watch) {
3199
3242
  const intervalMs = intervalMinutes * 60 * 1000;
3200
- console.log(`[CTEM] Watch mode enabled. Interval: ${intervalMinutes}m, Concurrency: ${parallel}, Hosts: ${hosts.length}`);
3201
- if (webhookUrl) console.log(`[CTEM] Webhook URL: ${webhookUrl}, Alert severity: ${alertSeverity}`);
3202
-
3203
3243
  let previousCycleResults = null;
3204
3244
 
3205
3245
  const scheduler = createScheduler({
@@ -3216,44 +3256,20 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
3216
3256
  onCycleComplete: async (results) => {
3217
3257
  console.log(`[CTEM] Cycle complete. Scanned ${results.size} host(s).`);
3218
3258
 
3219
- // Build delta report
3220
- if (previousCycleResults) {
3221
- const delta = buildDeltaReport(results, previousCycleResults);
3222
- console.log(formatDeltaSummary(delta));
3223
-
3224
- // Send webhook alerts for significant changes
3225
- if (webhookUrl && hasSignificantChanges(delta)) {
3226
- const sevRank = SEVERITY_RANK[alertSeverity] ?? SEVERITY_RANK.high;
3227
-
3228
- for (const [h, scanOut] of results) {
3229
- if (!scanOut?.conclusion) continue;
3230
- const hostSev = maxSeverityInConclusion(scanOut.conclusion);
3231
- if (hostSev >= sevRank) {
3232
- const services = scanOut.conclusion?.result?.services || [];
3233
- const findings = services.filter((svc) => {
3234
- let svcSev = 0;
3235
- if (svc.anonymousLogin === true || svc.axfrAllowed === true) svcSev = SEVERITY_RANK.critical;
3236
- if (Array.isArray(svc.weakAlgorithms) && svc.weakAlgorithms.length) svcSev = Math.max(svcSev, SEVERITY_RANK.medium);
3237
- if (Array.isArray(svc.dangerousMethods) && svc.dangerousMethods.length) svcSev = Math.max(svcSev, SEVERITY_RANK.medium);
3238
- const cves = svc.cves || svc.cve || [];
3239
- if (Array.isArray(cves) && cves.length) svcSev = Math.max(svcSev, SEVERITY_RANK.high);
3240
- return svcSev >= sevRank;
3241
- });
3242
-
3243
- if (findings.length > 0) {
3244
- const payload = buildAlertPayload(h, findings, alertSeverity);
3245
- const webhookResult = await sendWebhook(webhookUrl, payload, { retries: 2, retryDelayMs: 1000 });
3246
- if (webhookResult.success) {
3247
- console.log(`[CTEM] Webhook alert sent for ${h}`);
3248
- } else {
3249
- console.warn(`[CTEM] Webhook alert failed for ${h}: ${webhookResult.error}`);
3250
- }
3251
- }
3252
- }
3259
+ // What stdout says and whom the webhook alerts, both from ONE predicate (utils/watch_cycle.mjs, 1.3.0 items 4 + 11).
3260
+ const { text, alerts } = watchCycle(results, previousCycleResults,
3261
+ { alertRank: SEVERITY_RANK[alertSeverity] ?? SEVERITY_RANK.high, everyCycle: alertEveryCycle });
3262
+ console.log(text ?? '[CTEM] First cycle complete — it establishes the baseline. Delta reporting begins on the next cycle.');
3263
+ if (webhookUrl) {
3264
+ for (const { host: h, findings } of alerts) {
3265
+ const payload = buildAlertPayload(h, findings, alertSeverity);
3266
+ const webhookResult = await sendWebhook(webhookUrl, payload, { retries: 2, retryDelayMs: 1000 });
3267
+ if (webhookResult.success) {
3268
+ console.log(`[CTEM] Webhook alert sent for ${h}`);
3269
+ } else {
3270
+ console.warn(`[CTEM] Webhook alert failed for ${h}: ${webhookResult.error}`);
3253
3271
  }
3254
3272
  }
3255
- } else {
3256
- console.log('[CTEM] First cycle complete. Delta reporting will begin on next cycle.');
3257
3273
  }
3258
3274
 
3259
3275
  previousCycleResults = results;
@@ -3270,6 +3286,13 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
3270
3286
  process.on('SIGINT', shutdown);
3271
3287
  process.on('SIGTERM', shutdown);
3272
3288
 
3289
+ // The banner reads the scheduler's own distinct list: a cycle scans each host once (1.3.0 lane 4, item 10).
3290
+ const dropped = scheduler.duplicatesDropped
3291
+ ? ` (${scheduler.duplicatesDropped} duplicate host(s) dropped — watch mode scans each host once per cycle)` : '';
3292
+ console.log(`[CTEM] Watch mode enabled. Interval: ${intervalMinutes}m, Concurrency: ${parallel}, Hosts: ${scheduler.hosts.length}${dropped}`);
3293
+ if (webhookUrl) console.log(`[CTEM] Webhook URL: ${webhookUrl}, Alert severity: ${alertSeverity}, alerting `
3294
+ + (alertEveryCycle ? 'every host with such a finding, every cycle' : 'hosts whose scan changed (the first cycle sets the baseline)'));
3295
+
3273
3296
  scheduler.start();
3274
3297
  return; // keep process alive via setInterval
3275
3298
  }
@@ -3476,6 +3499,7 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
3476
3499
  host: scanOut.host,
3477
3500
  conclusion: scanOut.conclusion,
3478
3501
  toolVersion: TOOL_VERSION,
3502
+ manifest: scanOut.pluginStatus ?? [],
3479
3503
  });
3480
3504
  const mdFileName = scanOutputs.length > 1
3481
3505
  ? `scan_${safeHost(scanOut.host)}.md`