nsauditor-ai 0.2.55 → 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 (42) hide show
  1. package/README.md +76 -62
  2. package/cli.mjs +210 -179
  3. package/mcp_server.mjs +71 -31
  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 -1
  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 -30
  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/ee_load.mjs +45 -0
  26. package/utils/executive_report.mjs +48 -8
  27. package/utils/export_csv.mjs +14 -22
  28. package/utils/mcp_call_digest.mjs +31 -0
  29. package/utils/net_validation.mjs +107 -37
  30. package/utils/output_dir.mjs +70 -0
  31. package/utils/plugin_discovery.mjs +4 -2
  32. package/utils/ports_spec.mjs +46 -0
  33. package/utils/report_inputs.mjs +64 -1
  34. package/utils/report_md.mjs +51 -112
  35. package/utils/sarif.mjs +33 -93
  36. package/utils/scan_delta.mjs +514 -38
  37. package/utils/scan_delta_view.mjs +5 -3
  38. package/utils/scan_history.mjs +149 -0
  39. package/utils/scheduler.mjs +17 -5
  40. package/utils/service_flags.mjs +363 -0
  41. package/utils/watch_cycle.mjs +37 -0
  42. package/utils/webhook.mjs +2 -1
package/cli.mjs CHANGED
@@ -17,18 +17,21 @@ 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
- import { resolveBaseOutDir } from './utils/output_dir.mjs';
33
+ import { resolveBaseOutDir, createHostOutDir, safeHost } from './utils/output_dir.mjs';
34
+ import { loadEnterprise } from './utils/ee_load.mjs';
32
35
  import { deriveFindingsCount } from './utils/report_inputs.mjs';
33
36
  import { toCleanPath } from './utils/path_helpers.mjs';
34
37
  import { newRunId, writeRunStart, appendHostWritten, finalizeRunRecord, pruneRunRecordsForCE, aggregateNvdCache, abortReasonOf } from './utils/run_record.mjs';
@@ -45,19 +48,7 @@ const parseBool = (val, def = false) => {
45
48
  if (!s && def != null) return !!def;
46
49
  return ['true', '1', 'yes', 'on', 'y'].includes(s);
47
50
  };
48
- const nowStamp = () => {
49
- const d = new Date();
50
- const pad = (n) => String(n).padStart(2, '0');
51
- return (
52
- d.getFullYear().toString() +
53
- pad(d.getMonth() + 1) +
54
- pad(d.getDate()) + '_' +
55
- pad(d.getHours()) +
56
- pad(d.getMinutes()) +
57
- pad(d.getSeconds())
58
- );
59
- };
60
- const safeHost = (h) => String(h ?? 'unknown').replace(/[\/\\?%*:|"<>]/g, '_');
51
+ // `nowStamp` and `safeHost` live in utils/output_dir.mjs beside `createHostOutDir`, so the directory name has one definition.
61
52
  // toCleanPath imported from ./utils/path_helpers.mjs (consolidated in v0.1.20)
62
53
 
63
54
  /**
@@ -116,6 +107,9 @@ export function redactSensitiveForAI(input, targetHost) {
116
107
  s = s.replace(/\b(?:[0-9a-f]{1,4}:){2,7}[0-9a-f]{1,4}\b/gi, '[IPv6]');
117
108
  // The shared rule; this call site's POLICY keeps private addresses and masks public ones.
118
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);
119
113
  return s;
120
114
  };
121
115
 
@@ -157,17 +151,12 @@ async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basi
157
151
  : await resolveSecret(process.env.OPENAI_API_KEY);
158
152
  const key = keyRaw ? String(keyRaw).trim() : null;
159
153
 
160
- // Per-scan folder. Caller (scanSingleHost) may pass a pre-computed outDir so
161
- // that EE enrichment + compliance artifacts share the same folder as the AI
162
- // outputs — otherwise compute one here for legacy callers.
163
- let outDir = presetOutDir;
164
- if (!outDir) {
165
- const baseOutDir = resolveBaseOutDir();
166
- await fsp.mkdir(baseOutDir, { recursive: true });
167
- const ts = nowStamp();
168
- const runDir = `${safeHost(host)}_${ts}`;
169
- outDir = path.join(baseOutDir, runDir);
170
- }
154
+ // Per-scan folder — REQUIRED. Its only caller, scanSingleHost, creates it exclusively (createHostOutDir) so that EE
155
+ // enrichment, compliance artifacts and the AI outputs share one folder. ⚠️ The fallback that used to compose a
156
+ // directory here (`${safeHost(host)}_${nowStamp()}`, recursive mkdir) had no caller and was the same-second
157
+ // collision's shape kept warm (1.1.1, audit seat's fold); it is gone, and a missing outDir is an error.
158
+ if (!presetOutDir) throw new Error('maybeSendToOpenAI: outDir is required — scanSingleHost creates it with createHostOutDir');
159
+ const outDir = presetOutDir;
171
160
  await fsp.mkdir(outDir, { recursive: true });
172
161
 
173
162
  // Paths (fixed names inside per-scan folder)
@@ -734,6 +723,8 @@ export async function parseArgs(argv) {
734
723
  }
735
724
  const alertSev = get('alert-severity') || get('alert_severity') || null;
736
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'));
737
728
 
738
729
  // Compliance: framework selector + scope file. Forwarded to EE's
739
730
  // runCompliancePhase via enrichScan(). No-op without an EE Enterprise license.
@@ -895,25 +886,37 @@ export function scanTargetRefusal({ cmd, host, hostFile } = {}) {
895
886
  // rather than per scanSingleHost() call (reviewer L1 fold).
896
887
  const CLOUD_SENTINEL_HOSTS = new Set(['aws', 'gcp', 'azure']);
897
888
 
898
- async function scanSingleHost(pm, host, plugins, opts, promptMode) {
899
- // SSRF guard — block loopback, private ranges, cloud metadata endpoints.
900
- // Set NSA_ALLOW_ALL_HOSTS=1 to scan RFC 1918 / private ranges (local network auditing).
901
- // 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) {
902
901
  const isCloudSentinel = typeof host === 'string' && CLOUD_SENTINEL_HOSTS.has(host.toLowerCase());
903
-
904
- if (!process.env.NSA_ALLOW_ALL_HOSTS && !isCloudSentinel) {
905
- if (isBlockedIp(host)) {
906
- throw new Error(`Scanning blocked address range is not allowed: ${host}`);
907
- }
908
- // Hostname (not literal IP) — resolve and validate the resolved address
909
- if (!/^[\d.:[\]]+$/.test(host)) {
910
- try {
911
- await resolveAndValidate(host);
912
- } catch (err) {
913
- throw new Error(`Host rejected by SSRF guard: ${err.message}`);
914
- }
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}`);
915
911
  }
916
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);
917
920
 
918
921
  // `manifest` classifies every selected plugin as ran|skipped|timeout|error with a reason.
919
922
  // Discarding it is what makes "no findings" ambiguous between a clean estate and one whose
@@ -930,11 +933,11 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
930
933
  // Pre-compute the per-scan output folder so EE enrichment, compliance
931
934
  // artifacts, and AI outputs all land in the same directory. maybeSendToOpenAI
932
935
  // will reuse this presetOutDir below.
933
- const baseOutDir = resolveBaseOutDir();
934
- await fsp.mkdir(baseOutDir, { recursive: true });
935
- const ts = nowStamp();
936
- const outDir = path.join(baseOutDir, `${safeHost(host)}_${ts}`);
937
- await fsp.mkdir(outDir, { recursive: true });
936
+ //
937
+ // ⚠️ AN EXCLUSIVE CREATE (1.1.1 board): two scans of one host reaching this line in the same second used to share
938
+ // one directory and the later one overwrote the earlier one's evidence — see createHostOutDir. `opts.nowStamp` is
939
+ // a test seam (testHooks.nowStamp), absent in every real invocation.
940
+ const outDir = await createHostOutDir(resolveBaseOutDir(), host, opts?.nowStamp ? { stamp: opts.nowStamp } : {});
938
941
 
939
942
  // EE enrichment hook — no-op if @nsasoft/nsauditor-ai-ee is not installed
940
943
  // or the license tier doesn't grant intelligenceEngine. Compliance + outDir
@@ -971,11 +974,20 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
971
974
  let epssDataAsOf = null;
972
975
  // Enterprise's report of WHERE the NVD store/cache it read lives (board item 23) — null without EE.
973
976
  let nvdCache = null;
974
- try {
975
- // `opts.importEE`: test-only injection seam, mirroring `preflightNsauditorPosture`'s
976
- // `importEE` option elsewhere in this file — absent in every real invocation.
977
- ee = opts?.importEE ? await opts.importEE() : await import('@nsasoft/nsauditor-ai-ee');
978
- } catch { /* EE not installed — CE proceeds unchanged. The ONLY silent case. */ }
977
+ // ⚠️ "NOT INSTALLED" AND "INSTALLED BUT BROKEN" ARE TWO EVENTS (1.1.1, the audit seat's T1-c ruling). This used to be
978
+ // a bare `catch {}` read as "EE not installed — the ONLY silent case", and every import failure landed in it — measured
979
+ // at EE 174212f: Enterprise imported names this Community did not export, so below the floor the whole EE stage
980
+ // vanished without a word and the Pro delta read its rows RESOLVED. The package is RESOLVED first (utils/ee_load.mjs):
981
+ // absence stays silent; a failed load is named on stderr and recorded on the conclusion, beside `eeEnrichmentError`.
982
+ // `opts.importEE` / `opts.resolveEE`: test-only seams — absent in every real invocation.
983
+ const eeLoad = await loadEnterprise({ importEE: opts?.importEE ?? null, resolveEE: opts?.resolveEE ?? null });
984
+ ee = eeLoad.ee;
985
+ if (eeLoad.loadError) {
986
+ console.error(`[EE] Enterprise is installed but FAILED TO LOAD — this scan has no compliance report, no `
987
+ + `intelligence enrichment and no analysis-agent findings: ${eeLoad.loadError}`);
988
+ conclusion.result = conclusion.result || {};
989
+ conclusion.result.eeLoadError = eeLoad.loadError;
990
+ }
979
991
  // ⚠️ HOISTED DELIBERATELY, and only this value. `eeEnrichment` is `const` INSIDE the try below,
980
992
  // so reading it at the `appendHostWritten` call ~100 lines on is a ReferenceError — which the
981
993
  // suite caught as 16 failures rather than one, because every history comparison swallowed it as
@@ -1041,6 +1053,9 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
1041
1053
 
1042
1054
  // --- Scan history: record & compare ---
1043
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;
1044
1059
  // Whether THIS host actually landed in the run record's `hostsWritten` — distinct from
1045
1060
  // whether the scan itself succeeded. `appendHostWritten` can fail independently (a
1046
1061
  // malformed argument, a disk fault) while the rest of the scan completes cleanly, and the
@@ -1114,15 +1129,8 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
1114
1129
  }
1115
1130
 
1116
1131
  const services = conclusion?.result?.services ?? [];
1117
- const serviceFindingsCount = services.reduce((n, svc) => {
1118
- if (svc.anonymousLogin === true) n++;
1119
- if (svc.axfrAllowed === true) n++;
1120
- if (Array.isArray(svc.weakAlgorithms)) n += svc.weakAlgorithms.length;
1121
- if (Array.isArray(svc.dangerousMethods)) n += svc.dangerousMethods.length;
1122
- const cves = svc.cves || svc.cve || [];
1123
- if (Array.isArray(cves)) n += cves.length;
1124
- return n;
1125
- }, 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;
1126
1134
  // review fold R-1: cloud plugins emit findings on `results[].result.findings`,
1127
1135
  // NOT as service-level attrs — so a cloud (--host aws) scan recorded
1128
1136
  // findingsCount:0 in scan_history over a 201-finding scan (a false-clean
@@ -1162,7 +1170,7 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
1162
1170
  + 'legacy sum and marking the line as un-based, so no comparison is made across it.');
1163
1171
  }
1164
1172
 
1165
- const scanSummary = {
1173
+ scanSummary = {
1166
1174
  timestamp: new Date().toISOString(),
1167
1175
  host,
1168
1176
  servicesCount: services.length,
@@ -1178,10 +1186,9 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
1178
1186
  // unreachable in production while its unit tests stay green.
1179
1187
  tier: getTierFromEnv(),
1180
1188
  cloudFindingsCount,
1181
- services: services.map((s) => ({
1182
- port: s.port, protocol: s.protocol ?? 'tcp',
1183
- service: s.service ?? null, version: s.version ?? null,
1184
- })),
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),
1185
1192
  };
1186
1193
 
1187
1194
  // Retrieve previous scan for this host before recording the new one
@@ -1208,6 +1215,11 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
1208
1215
  host, results, conclusion, ai_file_paths, ai_conclusion, ai_status, ai_error, scanDiff,
1209
1216
  // Additive, consumed only by main()'s top-level KEV/EPSS aggregation for the run record.
1210
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,
1211
1223
  };
1212
1224
  }
1213
1225
 
@@ -1248,13 +1260,6 @@ function aggregateStoreLoad(writtenOutputs, field, label) {
1248
1260
 
1249
1261
  const SEVERITY_RANK = { critical: 4, high: 3, medium: 2, low: 1, info: 0 };
1250
1262
 
1251
- /**
1252
- * Determine the maximum severity level present in a conclusion's services.
1253
- * Checks security findings (anonymousLogin, axfrAllowed, weakAlgorithms,
1254
- * dangerousMethods, CVEs) as well as open service status.
1255
- * @param {object} conclusion
1256
- * @returns {number} highest severity rank found (0-4)
1257
- */
1258
1263
  async function readSecretFromStdin(keyName) {
1259
1264
  if (!process.stdin.isTTY) {
1260
1265
  // Piped input
@@ -1276,33 +1281,18 @@ async function readSecretFromStdin(keyName) {
1276
1281
  });
1277
1282
  }
1278
1283
 
1279
- function maxSeverityInConclusion(conclusion) {
1280
- const services = conclusion?.result?.services || [];
1281
- let max = 0;
1282
-
1283
- for (const svc of services) {
1284
- // anonymousLogin or axfrAllowed → Critical
1285
- if (svc.anonymousLogin === true) max = Math.max(max, SEVERITY_RANK.critical);
1286
- if (svc.axfrAllowed === true) max = Math.max(max, SEVERITY_RANK.critical);
1287
-
1288
- // weakAlgorithms or dangerousMethods → Medium
1289
- if (Array.isArray(svc.weakAlgorithms) && svc.weakAlgorithms.length > 0) max = Math.max(max, SEVERITY_RANK.medium);
1290
- if (Array.isArray(svc.dangerousMethods) && svc.dangerousMethods.length > 0) max = Math.max(max, SEVERITY_RANK.medium);
1291
-
1292
- // CVEs
1293
- const cves = svc.cves || svc.cve || [];
1294
- if (Array.isArray(cves)) {
1295
- for (const cve of cves) {
1296
- const sev = typeof cve === 'string' ? 'high' : String(cve?.severity || 'high').toLowerCase();
1297
- max = Math.max(max, SEVERITY_RANK[sev] ?? SEVERITY_RANK.high);
1298
- }
1299
- }
1300
-
1301
- // Open service → Info (baseline)
1302
- if (svc.status === 'open') max = Math.max(max, SEVERITY_RANK.info);
1303
- }
1304
-
1305
- return max;
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
+ */
1289
+ export function maxSeverityInConclusion(conclusion) {
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, '')));
1306
1296
  }
1307
1297
 
1308
1298
  /**
@@ -1398,7 +1388,9 @@ export async function preflightGrcIfRequested(env, opts = {}) {
1398
1388
  // test drive the real scan pipeline (including this function's own run-record wiring) against
1399
1389
  // a controlled, injected EE module — e.g. one whose `enrichScan` reports a specific KEV/EPSS
1400
1390
  // `dataAsOf` — without depending on the real `@nsasoft/nsauditor-ai-ee` package, a real store
1401
- // file, or the machine's own license/tier state being any particular thing.
1391
+ // file, or the machine's own license/tier state being any particular thing. An injected `importEE` counts as an
1392
+ // INSTALLED package (utils/ee_load.mjs): to describe one that is NOT installed, pass `testHooks.resolveEE` throwing —
1393
+ // a throwing `importEE` alone is now a package that is installed and FAILED to load, which the scan reports.
1402
1394
  // Reconstructs a single tier word ('ce' | 'pro' | 'enterprise') from the resolved capability
1403
1395
  // map — never from getTierFromEnv()'s own global cache. `runReport` below deliberately never
1404
1396
  // reads that cache: `caps` is passed in as an argument specifically so Pro is reachable from the
@@ -1642,7 +1634,7 @@ export async function runReport(args, caps) {
1642
1634
 
1643
1635
  export async function main(testHooks = {}) {
1644
1636
  const args = await parseArgs(process.argv);
1645
- 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;
1646
1638
 
1647
1639
  // Version: handled before license verification so it works without a key.
1648
1640
  // CE-0.1.30.1 — closes the discovery-flag UX gap where pre-fix
@@ -1682,23 +1674,45 @@ Scan options:
1682
1674
  incomplete-coverage notice); CloudTrail trail discovery,
1683
1675
  GuardDuty/Inspector and EC2 instances attempt every region.
1684
1676
  --plugins <list|all> Plugins to run (e.g. 001,003,020 or "all"; default: all)
1685
- --ports <range> Override port list (e.g. 22,80,443 or 1-1000)
1677
+ --ports <list> Ports ADDED to the default set (e.g. 22,80,443 or 161/udp;
1678
+ a range such as 1-1000 is not parsed and adds nothing)
1686
1679
  --out <dir> Output directory for scan artifacts
1687
1680
  --parallel <n> Parallel host concurrency (default 1)
1688
- --fail-on <severity> Exit non-zero if any finding ≥ severity
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.
1689
1693
  --output-format <fmt> Additional report format: sarif | csv | md
1690
1694
  --insecure-https Skip TLS validation on probed HTTPS targets
1691
- --watch CTEM continuous ALERTING mode: re-scan on --interval,
1692
- diff against the previous cycle, fire --webhook-url when a
1693
- change crosses --alert-severity. 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 —
1694
1703
  it adds no retention or cross-run aggregation, skips SARIF/
1695
1704
  CSV/Markdown output and --fail-on, and dies with this process.
1696
1705
  Each tick is an ordinary scan, so with --compliance it writes
1697
1706
  that tick's artifacts; nothing relates them across ticks. For
1698
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.
1699
1710
  --interval <minutes> Watch interval (default 60)
1700
- --webhook-url <url> Send delta alerts (must be public; private/loopback blocked)
1711
+ --webhook-url <url> Send --watch alerts (must be public; private/loopback blocked)
1701
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)
1702
1716
  --compliance <framework> Map findings to controls. 'all' = all 8 frameworks, or a CSV of
1703
1717
  soc2,hipaa,nist-csf,pci-dss,iso-27001,cis-v8,gdpr,nist-800-171
1704
1718
  (aliases nist/pci/iso/cis, and 800-171 or cmmc for nist-800-171 --
@@ -2687,30 +2701,45 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
2687
2701
  }
2688
2702
  }
2689
2703
  } else if (subCmd === 'verify-call') {
2690
- // CE 0.1.36 (Thread L Phase 2): cryptographic ground truth for
2691
- // "did Claude actually call the MCP server, or hallucinate a
2692
- // response?" Server mints a fresh UUID per tools/call, embeds it
2693
- // in the response text, AND appends to ~/.nsauditor/mcp-calls.log.
2694
- // Customer pastes the UUID here; we grep the log. UUID present →
2695
- // proven real call. UUID absent → fabricated (or log was rotated/
2696
- // 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.
2697
2709
  const { readFile, stat } = await import('node:fs/promises');
2698
2710
  const { join: _join } = await import('node:path');
2699
2711
  const { homedir: _homedir } = await import('node:os');
2712
+ const { responseDigest, bodyOf } = await import('./utils/mcp_call_digest.mjs');
2700
2713
  const logPath = _join(_homedir(), '.nsauditor', 'mcp-calls.log');
2701
2714
  const uuid = rawArgs[2];
2702
- if (!uuid) {
2703
- console.error('Usage: nsauditor-ai mcp verify-call <uuid>');
2704
- 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 -.');
2705
2720
  process.exit(2);
2706
2721
  }
2707
- // Conservative UUID v4 shape check — avoid grepping the log with
2708
- // arbitrary user input.
2722
+ // Conservative UUID v4 shape check — avoid grepping the log with arbitrary user input.
2709
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)) {
2710
2724
  console.error(`✗ Not a valid UUID: ${uuid}`);
2711
2725
  console.error(' Expected format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx');
2712
2726
  process.exit(2);
2713
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
+ }
2714
2743
  let logExists = false;
2715
2744
  try { await stat(logPath); logExists = true; } catch { /* missing */ }
2716
2745
  if (!logExists) {
@@ -2723,33 +2752,50 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
2723
2752
  const raw = await readFile(logPath, 'utf8');
2724
2753
  // Look for an exact JSON-string match on call_id to avoid prefix collisions.
2725
2754
  const needle = `"call_id":"${uuid.toLowerCase()}"`;
2726
- const lines = raw.split('\n').filter((l) => l.includes(needle));
2727
- 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) {
2728
2758
  console.log(`✗ call_id not found in ${logPath}`);
2729
2759
  console.log('');
2730
2760
  console.log(` ${uuid}`);
2731
2761
  console.log('');
2732
2762
  console.log(' This UUID was NOT issued by this MCP server. Most likely cause:');
2733
- console.log(' Claude Desktop fabricated the response without invoking the server.');
2734
- 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.)');
2735
2765
  process.exit(1);
2736
2766
  }
2737
- try {
2738
- const entry = JSON.parse(lines[lines.length - 1]);
2739
- console.log(`✓ Verified MCP call`);
2740
- console.log(` call_id: ${entry.call_id}`);
2741
- console.log(` tool: ${entry.tool}`);
2742
- console.log(` ts: ${entry.ts}`);
2743
- console.log(` log: ${logPath}`);
2744
- console.log('');
2745
- console.log(' This UUID was issued by the local MCP server, so the response');
2746
- console.log(' bearing it was a genuine tool call (not a hallucination).');
2747
- process.exit(0);
2748
- } catch {
2749
- 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}.`);
2750
2792
  console.log(` log: ${logPath}`);
2751
2793
  process.exit(0);
2752
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);
2753
2799
  } else {
2754
2800
  console.log('Usage:');
2755
2801
  console.log(' nsauditor-ai mcp install-key Generate a new key, persist, print Claude config');
@@ -2758,7 +2804,7 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
2758
2804
  console.log(' nsauditor-ai mcp rotate-key Replace the stored key with a fresh one');
2759
2805
  console.log(' nsauditor-ai mcp status Show storage source without revealing the key');
2760
2806
  console.log(' nsauditor-ai mcp tier Print actual MCP server tier (ground truth, bypasses Claude AI synthesis)');
2761
- 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');
2762
2808
  console.log('');
2763
2809
  console.log('Environment variables:');
2764
2810
  console.log(` ${MCP_AUTH_ENV_VAR} Read by mcp_server.mjs at startup; client supplies via Claude config`);
@@ -3156,6 +3202,10 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
3156
3202
 
3157
3203
  const opts = { insecureHttps };
3158
3204
  if (testHooks.importEE) opts.importEE = testHooks.importEE;
3205
+ if (testHooks.resolveEE) opts.resolveEE = testHooks.resolveEE;
3206
+ // `testHooks.nowStamp`: a test seam, absent in every real invocation — it pins the per-host directory's second so a
3207
+ // leg can drive the same-second collision through the shipped scan path.
3208
+ if (testHooks.nowStamp) opts.nowStamp = testHooks.nowStamp;
3159
3209
  if (ports) opts.ports = ports;
3160
3210
  if (compliance) opts.compliance = compliance;
3161
3211
  if (complianceScope) opts.complianceScope = complianceScope;
@@ -3190,9 +3240,6 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
3190
3240
  // --- CTEM: continuous watch mode ---
3191
3241
  if (watch) {
3192
3242
  const intervalMs = intervalMinutes * 60 * 1000;
3193
- console.log(`[CTEM] Watch mode enabled. Interval: ${intervalMinutes}m, Concurrency: ${parallel}, Hosts: ${hosts.length}`);
3194
- if (webhookUrl) console.log(`[CTEM] Webhook URL: ${webhookUrl}, Alert severity: ${alertSeverity}`);
3195
-
3196
3243
  let previousCycleResults = null;
3197
3244
 
3198
3245
  const scheduler = createScheduler({
@@ -3209,44 +3256,20 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
3209
3256
  onCycleComplete: async (results) => {
3210
3257
  console.log(`[CTEM] Cycle complete. Scanned ${results.size} host(s).`);
3211
3258
 
3212
- // Build delta report
3213
- if (previousCycleResults) {
3214
- const delta = buildDeltaReport(results, previousCycleResults);
3215
- console.log(formatDeltaSummary(delta));
3216
-
3217
- // Send webhook alerts for significant changes
3218
- if (webhookUrl && hasSignificantChanges(delta)) {
3219
- const sevRank = SEVERITY_RANK[alertSeverity] ?? SEVERITY_RANK.high;
3220
-
3221
- for (const [h, scanOut] of results) {
3222
- if (!scanOut?.conclusion) continue;
3223
- const hostSev = maxSeverityInConclusion(scanOut.conclusion);
3224
- if (hostSev >= sevRank) {
3225
- const services = scanOut.conclusion?.result?.services || [];
3226
- const findings = services.filter((svc) => {
3227
- let svcSev = 0;
3228
- if (svc.anonymousLogin === true || svc.axfrAllowed === true) svcSev = SEVERITY_RANK.critical;
3229
- if (Array.isArray(svc.weakAlgorithms) && svc.weakAlgorithms.length) svcSev = Math.max(svcSev, SEVERITY_RANK.medium);
3230
- if (Array.isArray(svc.dangerousMethods) && svc.dangerousMethods.length) svcSev = Math.max(svcSev, SEVERITY_RANK.medium);
3231
- const cves = svc.cves || svc.cve || [];
3232
- if (Array.isArray(cves) && cves.length) svcSev = Math.max(svcSev, SEVERITY_RANK.high);
3233
- return svcSev >= sevRank;
3234
- });
3235
-
3236
- if (findings.length > 0) {
3237
- const payload = buildAlertPayload(h, findings, alertSeverity);
3238
- const webhookResult = await sendWebhook(webhookUrl, payload, { retries: 2, retryDelayMs: 1000 });
3239
- if (webhookResult.success) {
3240
- console.log(`[CTEM] Webhook alert sent for ${h}`);
3241
- } else {
3242
- console.warn(`[CTEM] Webhook alert failed for ${h}: ${webhookResult.error}`);
3243
- }
3244
- }
3245
- }
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}`);
3246
3271
  }
3247
3272
  }
3248
- } else {
3249
- console.log('[CTEM] First cycle complete. Delta reporting will begin on next cycle.');
3250
3273
  }
3251
3274
 
3252
3275
  previousCycleResults = results;
@@ -3263,6 +3286,13 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
3263
3286
  process.on('SIGINT', shutdown);
3264
3287
  process.on('SIGTERM', shutdown);
3265
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
+
3266
3296
  scheduler.start();
3267
3297
  return; // keep process alive via setInterval
3268
3298
  }
@@ -3469,6 +3499,7 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
3469
3499
  host: scanOut.host,
3470
3500
  conclusion: scanOut.conclusion,
3471
3501
  toolVersion: TOOL_VERSION,
3502
+ manifest: scanOut.pluginStatus ?? [],
3472
3503
  });
3473
3504
  const mdFileName = scanOutputs.length > 1
3474
3505
  ? `scan_${safeHost(scanOut.host)}.md`