nsauditor-ai 0.2.56 → 1.0.0

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 (46) hide show
  1. package/README.md +55 -47
  2. package/bin/nsauditor-ai.mjs +4 -2
  3. package/cli.mjs +477 -252
  4. package/mcp_server.mjs +93 -45
  5. package/package.json +2 -2
  6. package/plugin_manager.mjs +91 -10
  7. package/plugins/040_tls_cert_auditor.mjs +246 -61
  8. package/plugins/050_tribe_health.mjs +13 -34
  9. package/plugins/060_dns_sec_auditor.mjs +30 -32
  10. package/plugins/dns_scanner.mjs +16 -8
  11. package/plugins/ftp_banner_check.mjs +21 -13
  12. package/plugins/host_up_check.mjs +36 -73
  13. package/plugins/http_probe.mjs +113 -27
  14. package/plugins/mcp_scanner.mjs +15 -2
  15. package/plugins/netbios_scanner.mjs +32 -13
  16. package/plugins/opensearch_scanner.mjs +4 -0
  17. package/plugins/ping_checker.mjs +10 -4
  18. package/plugins/port_scanner.mjs +29 -49
  19. package/plugins/result_concluder.mjs +166 -33
  20. package/plugins/snmp_scanner.mjs +20 -8
  21. package/plugins/syn_scanner.mjs +6 -2
  22. package/plugins/tls_scanner.mjs +29 -2
  23. package/plugins/upnp_scanner.mjs +82 -13
  24. package/plugins/webapp_detector.mjs +88 -15
  25. package/utils/capabilities.mjs +2 -2
  26. package/utils/conclusion_utils.mjs +39 -1
  27. package/utils/cookie_redaction.mjs +50 -0
  28. package/utils/delta_reporter.mjs +70 -25
  29. package/utils/executive_report.mjs +34 -7
  30. package/utils/export_csv.mjs +14 -22
  31. package/utils/mcp_call_digest.mjs +31 -0
  32. package/utils/net_validation.mjs +124 -37
  33. package/utils/output_dir.mjs +4 -2
  34. package/utils/plugin_discovery.mjs +8 -2
  35. package/utils/ports_spec.mjs +46 -0
  36. package/utils/report_md.mjs +49 -125
  37. package/utils/sarif.mjs +33 -93
  38. package/utils/scan_delta.mjs +173 -18
  39. package/utils/scan_delta_view.mjs +3 -3
  40. package/utils/scan_history.mjs +149 -0
  41. package/utils/scan_pin.mjs +143 -0
  42. package/utils/scheduler.mjs +17 -5
  43. package/utils/service_flags.mjs +363 -0
  44. package/utils/validate.mjs +1 -1
  45. package/utils/watch_cycle.mjs +37 -0
  46. package/utils/webhook.mjs +119 -18
package/cli.mjs CHANGED
@@ -17,15 +17,18 @@ 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 { sendWebhook, buildAlertPayload, isSafeWebhookUrl } from './utils/webhook.mjs';
26
+ import { watchCycle } from './utils/watch_cycle.mjs';
27
+ import { redactSetCookieLines } from './utils/cookie_redaction.mjs';
28
+ import { sendWebhook, buildAlertPayload, isSafeWebhookUrl, ALERT_PAYLOADS, NOTIFY_FORMATS, NOTIFY_PAYLOADS, NOTIFY_SEVERITIES } from './utils/webhook.mjs';
27
29
  import { scrubByKey } from './utils/redact.mjs';
28
- import { isBlockedIp, resolveAndValidate } from './utils/net_validation.mjs';
30
+ import { isBlockedIp, resolveAndValidateAll, allowAllHosts, canonicalIp } from './utils/net_validation.mjs';
31
+ import { runPinned } from './utils/scan_pin.mjs';
29
32
  import { getAllTechniques } from './utils/attack_map.mjs';
30
33
  import { TOOL_VERSION } from './utils/tool_version.mjs';
31
34
  import { resolveBaseOutDir, createHostOutDir, safeHost } from './utils/output_dir.mjs';
@@ -86,6 +89,34 @@ export function scrubIPv4KeepingProductIds(str, replaceAddress) {
86
89
  * to compare a raw against its payload, which needs the real function rather than a re-spelling
87
90
  * of its rules. `tests/redact_product_ids.test.mjs` is that census.
88
91
  */
92
+ // ── AWS ACCOUNT IDs AND BARE 12-DIGIT IDS (EE 2.0.0) ──────────────────────────────────────────────────────────────────
93
+ // Marketing task #204's real AWS run measured the account ID 20 times in one payload — bare, in bucket names, an SQS URL,
94
+ // six SNS ARNs and an IAM user ARN — and the operator ruled "State it now, fix in 2.0.0". TWO PASSES, as the architect
95
+ // seat ruled: (1) an ID found in a CERTAIN context — an ARN's account field in any partition, the account segment of an
96
+ // `amazonaws.com/<12 digits>` URL — is an account, and every literal occurrence of it is masked as [ACCOUNT_ID], inside
97
+ // resource names too (the names stay: remediation text that cannot name the bucket is useless to the customer); (2) any
98
+ // OTHER bare 12-digit run is masked with a NEUTRAL label, [REDACTED_ID] — a 12-digit number in a router banner is not an AWS
99
+ // account, so masking it is the safe direction and naming it would mislead the model and the auditor reading the payload.
100
+ // tests/redact_aws_account_ids.test.mjs; the false-positive census is tests/redact_product_ids.test.mjs.
101
+ export const ACCOUNT_ID_MASK = '[ACCOUNT_ID]';
102
+ export const BARE_ID_MASK = '[REDACTED_ID]';
103
+ const ARN_ACCOUNT_RE = /\barn:aws(?:-[a-z]+)*:[a-z0-9-]*:[a-z0-9-]*:(\d{12})(?=:)/gi;
104
+ const URL_ACCOUNT_RE = /\bamazonaws\.com(?:\.cn)?\/(\d{12})(?!\d)/gi;
105
+ const BARE_12_DIGIT_RE = /\b\d{12}\b/g;
106
+ /** The AWS account IDs a payload carries in a CERTAIN context — ARN account fields and AWS URL segments, anywhere in it. */
107
+ export function awsAccountIdsIn(input) {
108
+ const ids = new Set();
109
+ const visit = (v) => {
110
+ if (typeof v === 'string') {
111
+ for (const m of v.matchAll(ARN_ACCOUNT_RE)) ids.add(m[1]);
112
+ for (const m of v.matchAll(URL_ACCOUNT_RE)) ids.add(m[1]);
113
+ } else if (Array.isArray(v)) v.forEach(visit);
114
+ else if (v && typeof v === 'object') Object.values(v).forEach(visit);
115
+ };
116
+ visit(input);
117
+ return ids;
118
+ }
119
+
89
120
  export function redactSensitiveForAI(input, targetHost) {
90
121
  const DROP_KEYS = new Set([
91
122
  'ip6', 'deviceWebPage', 'deviceWebPageInstruction',
@@ -97,14 +128,22 @@ export function redactSensitiveForAI(input, targetHost) {
97
128
  /^172\.(1[6-9]|2\d|3[0-1])\./.test(ip) ||
98
129
  /^192\.168\./.test(ip);
99
130
 
131
+ const accountIds = [...awsAccountIdsIn(input)];
132
+
100
133
  const scrubString = (str) => {
101
134
  let s = String(str);
135
+ for (const id of accountIds) s = s.split(id).join(ACCOUNT_ID_MASK);
102
136
  s = s.replace(/\bSerial\s*[:=]\s*[A-Za-z0-9._-]+/gi, 'Serial=[REDACTED_HIDDEN]');
103
137
  s = s.replace(/\b(?:[0-9a-f]{2}:){5}[0-9a-f]{2}\b/gi, '[MAC]'); // MAC
104
138
  s = s.replace(/\bfe80::[0-9a-f:]+\b/gi, '[FE80::/64]'); // IPv6 link-local
105
139
  s = s.replace(/\b(?:[0-9a-f]{1,4}:){2,7}[0-9a-f]{1,4}\b/gi, '[IPv6]');
106
140
  // The shared rule; this call site's POLICY keeps private addresses and masks public ones.
107
141
  s = scrubIPv4KeepingProductIds(s, (ip) => (isPrivateV4(ip) ? ip : '[IP]'));
142
+ // A scanned service's Set-Cookie VALUE is its session token: redacted here whatever a producer kept (1.3.0, both
143
+ // seams ruled — the webapp detector still reflects its headers into the result).
144
+ s = redactSetCookieLines(s);
145
+ // LAST, so every rule above sees the input it always saw (a `Serial: <12 digits>` stays the serial rule's).
146
+ s = s.replace(BARE_12_DIGIT_RE, BARE_ID_MASK);
108
147
  return s;
109
148
  };
110
149
 
@@ -126,18 +165,117 @@ export function redactSensitiveForAI(input, targetHost) {
126
165
  return walk(input);
127
166
  }
128
167
 
168
+ /**
169
+ * The payload the AI stage writes to scan_response_ai_payload.json and sends: every redaction step, in order, in ONE function
170
+ * that maybeSendToOpenAI calls, so the file IS this function's output and docs/openai.md's list is driven through the real
171
+ * pipeline (EE 2.0.0, the architect seat's S1 — a test that composed the rules with a policy spelled in the TEST proved the
172
+ * rules, never the call site).
173
+ */
174
+ export function buildAIPayload({ host, hostOsHint = null, summary, results, conclusion, detectedSerial = null, redact = true, env = process.env }) {
175
+ let payloadForAI = {
176
+ host,
177
+ host_os_hint: hostOsHint,
178
+ summary, // include full; redactor will mask
179
+ services: conclusion?.result?.services ?? [],
180
+ evidence: conclusion?.result?.evidence ?? [],
181
+ _meta: {
182
+ resultsCount: Array.isArray(results) ? results.length : (results ? 1 : 0),
183
+ serialFound: !!detectedSerial
184
+ }
185
+ };
186
+
187
+ if (redact) {
188
+ // `_meta.redactor` names what ran: 'builtin', Community's redactor, the same at every tier. Until EE 2.0.0 a Pro-tier
189
+ // branch here deferred to a `globalThis.redactSensitiveForAI` hook that neither package installs, and the record started
190
+ // as 'fallback' and stayed there, so every normal run said "fallback" (marketing task #204, finding 3). The branch is
191
+ // deleted (the architect seat's ruling), and so is its retry: the redactor builds a new object and never modifies its
192
+ // input, so a second call on the same input throws again. A throw propagates to maybeSendToOpenAI, which has then written
193
+ // and sent nothing.
194
+ const used = 'builtin';
195
+ payloadForAI = redactSensitiveForAI(payloadForAI, host);
196
+
197
+ // additional key-based scrubbing (CONFIDENTIAL_KEYWORDS=serial,password,token)
198
+ const keywords = String(env.CONFIDENTIAL_KEYWORDS || '')
199
+ .split(',')
200
+ .map((s) => s.trim().toLowerCase())
201
+ .filter(Boolean);
202
+ if (keywords.length) payloadForAI = scrubByKey(payloadForAI, keywords, '[REDACTED_HIDDEN]');
203
+
204
+ // Redact top-level host field (private IPs survive scrubString)
205
+ if (typeof payloadForAI.host === 'string') {
206
+ payloadForAI.host = '[REDACTED_HOST]';
207
+ }
208
+
209
+ // Redact any remaining IP addresses in the summary field
210
+ if (typeof payloadForAI.summary === 'string') {
211
+ // ⚠️ THE SAME RULE, A DIFFERENT POLICY: every address goes, private ones included. It
212
+ // carried its own copy of the address regex — the copy that was NOT fixed when the
213
+ // identifier defect was found in the redactor, which is exactly how the second instance
214
+ // of a class arrives. Measured at the time: neither live payload's summary carries an
215
+ // `EE-` token, so this was a latent duplicate rather than a live defect.
216
+ payloadForAI.summary = scrubIPv4KeepingProductIds(payloadForAI.summary, () => '[REDACTED_HOST]');
217
+ }
218
+
219
+ // Also redact private IPs in nested service/evidence strings
220
+ const privateIpRe = /\b(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[0-1])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3})\b/g;
221
+ function scrubPrivateIps(obj) {
222
+ if (typeof obj === 'string') return obj.replace(privateIpRe, '[REDACTED_IP]');
223
+ if (Array.isArray(obj)) return obj.map(scrubPrivateIps);
224
+ if (obj && typeof obj === 'object') {
225
+ const out = {};
226
+ for (const [k, v] of Object.entries(obj)) out[k] = scrubPrivateIps(v);
227
+ return out;
228
+ }
229
+ return obj;
230
+ }
231
+ payloadForAI.services = scrubPrivateIps(payloadForAI.services);
232
+ payloadForAI.evidence = scrubPrivateIps(payloadForAI.evidence);
233
+
234
+ payloadForAI = { ...payloadForAI, _meta: { ...(payloadForAI?._meta || {}), wasRedacted: true, redactor: used } };
235
+ } else {
236
+ payloadForAI = { ...payloadForAI, _meta: { ...(payloadForAI?._meta || {}), wasRedacted: false } };
237
+ }
238
+
239
+ // Ensure placeholder only if a serial was detected
240
+ if (payloadForAI?._meta?.serialFound && detectedSerial) {
241
+ const s = String(payloadForAI.summary ?? '').trim();
242
+ if (!/\bSerial\s*[:=]/i.test(s)) {
243
+ payloadForAI.summary = `${s} Serial: [REDACTED_HIDDEN]`;
244
+ } else {
245
+ payloadForAI.summary = s.replace(/\bSerial\s*[:=]\s*([A-Za-z0-9._-]+)/i, 'Serial: [REDACTED_HIDDEN]');
246
+ }
247
+ }
248
+
249
+ return payloadForAI;
250
+ }
251
+
129
252
  /* ------------------------- OpenAI & reporting -------------------------- */
130
253
 
254
+ /**
255
+ * The OpenAI model used when OPENAI_MODEL is unset (EE 2.0.0, marketing task #203 — the small model it named until then was a generation stale). Chosen on
256
+ * 2026-10-11 against OpenAI's own pages, read raw: https://developers.openai.com/api/docs/models lists `gpt-6-luna` as
257
+ * "Our most efficient model for focused, high-volume tasks" ($0.10 / $0.50 per MTok, 128K max output, 1.05M context), and
258
+ * https://developers.openai.com/api/docs/deprecations maps the previous small model (`gpt-5.4-nano`) to it. The request
259
+ * sends only `model` and `input`, so no parameter a newer model refuses rides with it.
260
+ */
261
+ export const DEFAULT_OPENAI_MODEL = 'gpt-6-luna';
262
+
263
+ /** The model the AI step asks for: the provider's own variable, or its default. */
264
+ export function aiModelFor(env = process.env) {
265
+ const aiProvider = (env.AI_PROVIDER || 'openai').toLowerCase().trim();
266
+ return aiProvider === 'claude'
267
+ ? toCleanPath(env.ANTHROPIC_MODEL || 'claude-sonnet-4-6')
268
+ : aiProvider === 'ollama'
269
+ ? toCleanPath(env.OLLAMA_MODEL || 'llama3')
270
+ : toCleanPath(env.OPENAI_MODEL || DEFAULT_OPENAI_MODEL);
271
+ }
272
+
131
273
  async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basic', outDir: presetOutDir = null, runId = null, pluginStatus = [] }) {
132
274
  // --- env & opts -----------------------------------------------------------
133
275
  const sendEnabled = parseBool(process.env.AI_ENABLED);
134
276
  const redactEnabled = parseBool(process.env.OPENAI_REDACT, true);
135
277
  const aiProvider = (process.env.AI_PROVIDER || 'openai').toLowerCase().trim();
136
- const model = aiProvider === 'claude'
137
- ? toCleanPath(process.env.ANTHROPIC_MODEL || 'claude-sonnet-4-6')
138
- : aiProvider === 'ollama'
139
- ? toCleanPath(process.env.OLLAMA_MODEL || 'llama3')
140
- : toCleanPath(process.env.OPENAI_MODEL || 'gpt-4o-mini');
278
+ const model = aiModelFor(process.env);
141
279
  const { resolveSecret } = await import('./utils/keychain.mjs');
142
280
  const keyRaw = aiProvider === 'claude'
143
281
  ? await resolveSecret(process.env.ANTHROPIC_API_KEY)
@@ -158,6 +296,7 @@ async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basi
158
296
  const adminRawPath = path.join(outDir, 'scan_conclusion_raw.json');
159
297
  const adminHtmlPath = path.join(outDir, 'scan_conclusion_raw.html');
160
298
  const aiPayloadPath = path.join(outDir, 'scan_response_ai_payload.json');
299
+ const aiSystemPromptPath = path.join(outDir, 'scan_response_ai_system_prompt.txt');
161
300
  const aiResponsePath = path.join(outDir, 'scan_response_ai.json');
162
301
  const aiTxtPath = path.join(outDir, 'scan_response_ai.txt');
163
302
  const aiHtmlPath = path.join(outDir, 'scan_response_ai.html');
@@ -255,91 +394,8 @@ async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basi
255
394
  }
256
395
 
257
396
  // --- Build sanitized payload for AI ---------------------------------------
258
- let payloadForAI = {
259
- host,
260
- host_os_hint: hostOsHint,
261
- summary: summaryWithFullSerial, // include full; redactor will mask
262
- services: conclusion?.result?.services ?? [],
263
- evidence: conclusion?.result?.evidence ?? [],
264
- _meta: {
265
- resultsCount: Array.isArray(results) ? results.length : (results ? 1 : 0),
266
- serialFound: !!detectedSerial
267
- }
268
- };
269
-
270
- if (redactEnabled) {
271
- let used = 'fallback';
272
- try {
273
- // Only allow external redaction override for Pro/Enterprise tiers.
274
- // CE always uses the built-in redact pipeline to preserve the ZDE guarantee.
275
- const redactCaps = resolveCapabilities(getTierFromEnv());
276
- if (hasCapability(redactCaps, 'enhancedRedaction') && typeof globalThis.redactSensitiveForAI === 'function') {
277
- let out = globalThis.redactSensitiveForAI(payloadForAI);
278
- if (out && typeof out.then === 'function') out = await out;
279
- if (typeof out === 'string') out = JSON.parse(out);
280
- if (!out || typeof out !== 'object') throw new Error('external redactor returned non-object');
281
- payloadForAI = out;
282
- used = 'external';
283
- } else {
284
- payloadForAI = redactSensitiveForAI(payloadForAI, host);
285
- }
286
- } catch (e) {
287
- console.warn('[OpenAI] Redaction failed, using fallback:', e?.message || e);
288
- payloadForAI = redactSensitiveForAI(payloadForAI, host);
289
- used = 'fallback';
290
- }
291
-
292
- // additional key-based scrubbing (CONFIDENTIAL_KEYWORDS=serial,password,token)
293
- const keywords = String(process.env.CONFIDENTIAL_KEYWORDS || '')
294
- .split(',')
295
- .map((s) => s.trim().toLowerCase())
296
- .filter(Boolean);
297
- if (keywords.length) payloadForAI = scrubByKey(payloadForAI, keywords, '[REDACTED_HIDDEN]');
298
-
299
- // Redact top-level host field (private IPs survive scrubString)
300
- if (typeof payloadForAI.host === 'string') {
301
- payloadForAI.host = '[REDACTED_HOST]';
302
- }
303
-
304
- // Redact any remaining IP addresses in the summary field
305
- if (typeof payloadForAI.summary === 'string') {
306
- // ⚠️ THE SAME RULE, A DIFFERENT POLICY: every address goes, private ones included. It
307
- // carried its own copy of the address regex — the copy that was NOT fixed when the
308
- // identifier defect was found in the redactor, which is exactly how the second instance
309
- // of a class arrives. Measured at the time: neither live payload's summary carries an
310
- // `EE-` token, so this was a latent duplicate rather than a live defect.
311
- payloadForAI.summary = scrubIPv4KeepingProductIds(payloadForAI.summary, () => '[REDACTED_HOST]');
312
- }
313
-
314
- // Also redact private IPs in nested service/evidence strings
315
- const privateIpRe = /\b(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[0-1])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3})\b/g;
316
- function scrubPrivateIps(obj) {
317
- if (typeof obj === 'string') return obj.replace(privateIpRe, '[REDACTED_IP]');
318
- if (Array.isArray(obj)) return obj.map(scrubPrivateIps);
319
- if (obj && typeof obj === 'object') {
320
- const out = {};
321
- for (const [k, v] of Object.entries(obj)) out[k] = scrubPrivateIps(v);
322
- return out;
323
- }
324
- return obj;
325
- }
326
- payloadForAI.services = scrubPrivateIps(payloadForAI.services);
327
- payloadForAI.evidence = scrubPrivateIps(payloadForAI.evidence);
328
-
329
- payloadForAI = { ...payloadForAI, _meta: { ...(payloadForAI?._meta || {}), wasRedacted: true, redactor: used } };
330
- } else {
331
- payloadForAI = { ...payloadForAI, _meta: { ...(payloadForAI?._meta || {}), wasRedacted: false } };
332
- }
333
-
334
- // Ensure placeholder only if a serial was detected
335
- if (payloadForAI?._meta?.serialFound && detectedSerial) {
336
- const s = String(payloadForAI.summary ?? '').trim();
337
- if (!/\bSerial\s*[:=]/i.test(s)) {
338
- payloadForAI.summary = `${s} Serial: [REDACTED_HIDDEN]`;
339
- } else {
340
- payloadForAI.summary = s.replace(/\bSerial\s*[:=]\s*([A-Za-z0-9._-]+)/i, 'Serial: [REDACTED_HIDDEN]');
341
- }
342
- }
397
+ const payloadForAI = buildAIPayload({ host, hostOsHint, summary: summaryWithFullSerial, results, conclusion, detectedSerial,
398
+ redact: redactEnabled });
343
399
 
344
400
  // --- Bail out early if sending disabled -----------------------------------
345
401
  const providerLabel = aiProvider === 'claude' ? 'Claude' : aiProvider === 'ollama' ? 'Ollama' : 'OpenAI';
@@ -374,6 +430,17 @@ async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basi
374
430
  const eeBlock = conclusion?.result?.eeEnrichment?.enrichedPrompt;
375
431
  if (eeBlock) promptText = eeBlock + '\n\n---\n\n' + promptText;
376
432
 
433
+ // --- Write the SYSTEM PROMPT we plan to send, beside the payload (EE 2.0.0, marketing task #204 finding 2) ---
434
+ // On Pro and Enterprise the intelligence block above travels here, not in the payload, so the payload file alone was not
435
+ // the record of what left the machine. Written BEFORE the request, as the payload is: the two files together are the
436
+ // request's content (the model name and the key are the rest).
437
+ try {
438
+ await fsp.writeFile(aiSystemPromptPath, promptText, 'utf8');
439
+ console.log(`[${providerLabel}] Wrote AI system prompt:`, aiSystemPromptPath);
440
+ } catch (e) {
441
+ console.warn(`[${providerLabel}] Failed to write AI system prompt:`, e?.message || e);
442
+ }
443
+
377
444
  // --- Send to AI provider ---------------------------------------------------
378
445
  let aiConclusionText = null;
379
446
  // review fold 8: contain the serialization — hoisting userContent above the
@@ -718,6 +785,51 @@ export async function parseArgs(argv) {
718
785
  }
719
786
  const alertSev = get('alert-severity') || get('alert_severity') || null;
720
787
  args.alertSeverity = (alertSev && alertSev !== true) ? alertSev.toLowerCase() : 'high';
788
+ // R4: by default a watch cycle alerts only hosts whose scan CHANGED; this restores an alert on every cycle.
789
+ args.alertEveryCycle = !!(get('alert-every-cycle') || get('alert_every_cycle'));
790
+
791
+ // ── EE 2.0.0 (cargo 3, D3/D5): the drift notification and the watch alert's payload mode ─────────────────────────
792
+ // Every refusal THROWS with code EFLAGVALUE (mapped to exit 2 at the entry point), never `process.exit`: parseArgs is
793
+ // exported and driven in-process by the suite, and an exit here kills the runner mid-file (the EFLAGSHAPE lesson).
794
+ const flagValue = (msg) => Object.assign(new Error(msg), { code: 'EFLAGVALUE' });
795
+ const oneOf = (flag, allowed, dflt) => {
796
+ const v = get(flag);
797
+ if (v === undefined) return dflt;
798
+ if (v === true || !allowed.includes(String(v).toLowerCase())) throw flagValue(`--${flag} must be one of ${allowed.join(' | ')}`);
799
+ return String(v).toLowerCase();
800
+ };
801
+ const notifyUrl = get('notify-webhook');
802
+ if (notifyUrl === true) throw flagValue('--notify-webhook requires a URL');
803
+ args.notifyFormat = oneOf('notify-format', NOTIFY_FORMATS, null);
804
+ args.notifySeverity = oneOf('notify-severity', NOTIFY_SEVERITIES, null);
805
+ args.notifyPayload = oneOf('notify-payload', NOTIFY_PAYLOADS, null);
806
+ if (notifyUrl === undefined) {
807
+ const stray = [['notify-format', args.notifyFormat], ['notify-severity', args.notifySeverity], ['notify-payload', args.notifyPayload]]
808
+ .filter(([, v]) => v !== null).map(([f]) => `--${f}`);
809
+ if (stray.length) throw flagValue(`${stray.join(', ')} configure the drift notification and need --notify-webhook <url>`);
810
+ args.notifyWebhook = null;
811
+ } else {
812
+ if (args.watch) {
813
+ throw flagValue('--notify-webhook sends the drift file of a ONE-SHOT scan, and --watch writes none; '
814
+ + 'watch-mode alerts use --webhook-url');
815
+ }
816
+ // https ONLY (the architect seat's read, F3): the URL is a credential and the body is scan content, so neither
817
+ // crosses the network in the clear. --webhook-url is unchanged.
818
+ let notifyProtocol = null;
819
+ try { notifyProtocol = new URL(notifyUrl).protocol; } catch { /* not a URL — the SSRF check below refuses it */ }
820
+ if (notifyProtocol === 'http:') {
821
+ throw flagValue('--notify-webhook must be an https URL: it carries its own credential, and the drift it sends is scan content');
822
+ }
823
+ // The SAME boundary check --webhook-url gets: public destinations only, DNS-resolved.
824
+ if (!(await isSafeWebhookUrl(notifyUrl))) {
825
+ throw flagValue('--notify-webhook rejected: private/loopback/metadata addresses are not allowed');
826
+ }
827
+ args.notifyWebhook = notifyUrl;
828
+ }
829
+ args.alertPayload = oneOf('alert-payload', ALERT_PAYLOADS, 'full');
830
+ if (get('alert-payload') !== undefined && !args.webhookUrl) {
831
+ throw flagValue('--alert-payload shapes the --watch webhook alert and needs --webhook-url <url>');
832
+ }
721
833
 
722
834
  // Compliance: framework selector + scope file. Forwarded to EE's
723
835
  // runCompliancePhase via enrichScan(). No-op without an EE Enterprise license.
@@ -858,6 +970,16 @@ export async function parseArgs(argv) {
858
970
  * @param {{ cmd?: string, host?: string|true, hostFile?: string }} args parseArgs' result
859
971
  * @returns {{ message: string, code: number }|null}
860
972
  */
973
+ /**
974
+ * EE 2.0.0: --notify-webhook sends the drift of a COMPLIANCE scan, so a run with no framework — from --compliance or
975
+ * from COMPLIANCE_FRAMEWORKS, which --env may have loaded — would accept the flag and send nothing, ever. Refused.
976
+ */
977
+ export function notifyRefusal({ notifyWebhook, compliance, envCompliance } = {}) {
978
+ if (!notifyWebhook || compliance || envCompliance) return null;
979
+ return { code: 2, message: 'Fatal: --notify-webhook sends the drift file of a compliance scan; name a framework '
980
+ + 'with --compliance (or COMPLIANCE_FRAMEWORKS)' };
981
+ }
982
+
861
983
  export function scanTargetRefusal({ cmd, host, hostFile } = {}) {
862
984
  if (cmd !== 'scan') return { message: `Unknown command: ${cmd}`, code: 2 };
863
985
  if (!hostFile && !host) return { message: 'Fatal: --host or --host-file is required', code: 2 };
@@ -879,30 +1001,56 @@ export function scanTargetRefusal({ cmd, host, hostFile } = {}) {
879
1001
  // rather than per scanSingleHost() call (reviewer L1 fold).
880
1002
  const CLOUD_SENTINEL_HOSTS = new Set(['aws', 'gcp', 'azure']);
881
1003
 
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.
1004
+ /**
1005
+ * The CLI's scan-target SSRF guard (exported so tests drive the REAL code — 1.3.0 lane 1 E).
1006
+ * Cloud sentinels (aws/gcp/azure) skip it. NSA_ALLOW_ALL_HOSTS (1/true/yes/on only) lifts the WHOLE
1007
+ * guard on the CLI, loopback and metadata included — documented, and the local-network smoke tests
1008
+ * scan loopback that way. Otherwise a literal is classified by value in any spelling, and anything
1009
+ * that is not a literal is resolved with EVERY answer checked. Before 1.3.0 a digits-and-dots string
1010
+ * skipped resolution (/^[\\d.:[\\]]+$/), so '6425673729' — which the OS reads as 127.0.0.1 — was
1011
+ * scanned unchecked.
1012
+ *
1013
+ * PINNED SINCE EE 2.0.0 (R1b — the operator's ruling "CLI pins too"): it RETURNS the answer set it resolved, and
1014
+ * scanSingleHost runs the plugins inside `runPinned` with it, so the scan connects to the addresses this check looked at.
1015
+ * NSA_ALLOW_ALL_HOSTS relaxes WHICH answers are admitted, never whether one may change between the check and the connect:
1016
+ * under it the name is resolved WITHOUT a refusal and still pinned (an unresolvable name scans unpinned, as before).
1017
+ * @param {string} host
1018
+ * @param {Record<string,string|undefined>} [env]
1019
+ * @returns {Promise<Array<{ address: string, family: 4|6 }> | null>} the answers to pin; null for a literal or a sentinel
1020
+ */
1021
+ export async function assertScanTargetAllowed(host, env = process.env) {
886
1022
  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
- }
1023
+ if (isCloudSentinel) return null;
1024
+ if (allowAllHosts(env)) {
1025
+ if (canonicalIp(host)) return null;
1026
+ try { return await resolveAndValidateAll(host, { checkAnswers: false }); } catch { return null; }
1027
+ }
1028
+ if (isBlockedIp(host)) {
1029
+ throw new Error(`Scanning blocked address range is not allowed: ${host}`);
1030
+ }
1031
+ if (!canonicalIp(host)) {
1032
+ try {
1033
+ return await resolveAndValidateAll(host);
1034
+ } catch (err) {
1035
+ throw new Error(`Host rejected by SSRF guard: ${err.message}`);
899
1036
  }
900
1037
  }
1038
+ return null;
1039
+ }
1040
+
1041
+ export async function scanSingleHost(pm, host, plugins, opts, promptMode) {
1042
+ // SSRF guard — block loopback, private ranges, cloud metadata endpoints.
1043
+ // NSA_ALLOW_ALL_HOSTS=1 lifts this whole guard for local network auditing — loopback and metadata included (the MCP
1044
+ // server's guard admits private ranges only).
1045
+ // Cloud-sentinel hosts (see CLOUD_SENTINEL_HOSTS above) skip the guard.
1046
+ const answers = await assertScanTargetAllowed(host);
901
1047
 
902
1048
  // `manifest` classifies every selected plugin as ran|skipped|timeout|error with a reason.
903
1049
  // Discarding it is what makes "no findings" ambiguous between a clean estate and one whose
904
1050
  // plugins never loaded — see utils/run_record.mjs.
905
- const { results, conclusion, manifest: pluginStatus = [] } = await pm.run(host, plugins || 'all', opts);
1051
+ // R1b: the plugins connect to the addresses the check resolved (utils/scan_pin.mjs), never to a second answer.
1052
+ const { results, conclusion, manifest: pluginStatus = [] } = await runPinned(answers ? { [host]: answers } : null,
1053
+ () => pm.run(host, plugins || 'all', opts));
906
1054
 
907
1055
  // Enrich conclusion with MITRE ATT&CK technique mapping
908
1056
  const techniques = getAllTechniques(conclusion);
@@ -993,6 +1141,18 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
993
1141
  // delta as findings RESOLVED — measured on a live run, three such rows.
994
1142
  pluginStatus,
995
1143
  onWarn: (msg) => console.warn(`[EE] ${msg}`),
1144
+ // EE 2.0.0 (the drift file). The run id and the root the run records are written under (the SAME resolver
1145
+ // `appendHostWritten` below uses), so EE compares with the run `report --since prior` names; and `watch`, true
1146
+ // only from the watch loop, where EE writes no drift file. Pinned by tests/ee_drift_forwarding.test.mjs.
1147
+ runId: opts?.runId ?? null,
1148
+ runRecordRoot: resolveBaseOutDir(),
1149
+ watch: opts?.watch === true,
1150
+ // EE 2.0.0 (cargo 3, D3/D4): the opt-in drift notification, forwarded verbatim; EE validates every value again
1151
+ // and sends only on a compared drift that crossed a trigger. Pinned by tests/cli_notify_flags.test.mjs.
1152
+ notifyWebhook: opts?.notifyWebhook ?? null,
1153
+ notifyFormat: opts?.notifyFormat ?? null,
1154
+ notifySeverity: opts?.notifySeverity ?? null,
1155
+ notifyPayload: opts?.notifyPayload ?? null,
996
1156
  }) : null;
997
1157
  hostScopeScanned = eeEnrichment?.scopeScanned ?? null;
998
1158
  // ⚠️ Read from `exploitIntel.stores`, NOT a top-level `exploit` key — measured against
@@ -1034,6 +1194,9 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
1034
1194
 
1035
1195
  // --- Scan history: record & compare ---
1036
1196
  let scanDiff = null;
1197
+ // The per-host summary [ScanHistory] records — also what a --watch cycle compares (1.3.0 items 4 + 11). Null when the
1198
+ // history step failed before building it; the watch delta reads null as NOT COMPARED, never as an empty scan.
1199
+ let scanSummary = null;
1037
1200
  // Whether THIS host actually landed in the run record's `hostsWritten` — distinct from
1038
1201
  // whether the scan itself succeeded. `appendHostWritten` can fail independently (a
1039
1202
  // malformed argument, a disk fault) while the rest of the scan completes cleanly, and the
@@ -1107,15 +1270,8 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
1107
1270
  }
1108
1271
 
1109
1272
  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);
1273
+ // The legacy fallback's service share, graded by the shared service-flag table (1.3.0 (s1)) like every other reader.
1274
+ const serviceFindingsCount = conclusionFindings(conclusion, host).length;
1119
1275
  // review fold R-1: cloud plugins emit findings on `results[].result.findings`,
1120
1276
  // NOT as service-level attrs — so a cloud (--host aws) scan recorded
1121
1277
  // findingsCount:0 in scan_history over a 201-finding scan (a false-clean
@@ -1155,7 +1311,7 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
1155
1311
  + 'legacy sum and marking the line as un-based, so no comparison is made across it.');
1156
1312
  }
1157
1313
 
1158
- const scanSummary = {
1314
+ scanSummary = {
1159
1315
  timestamp: new Date().toISOString(),
1160
1316
  host,
1161
1317
  servicesCount: services.length,
@@ -1171,10 +1327,9 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
1171
1327
  // unreachable in production while its unit tests stay green.
1172
1328
  tier: getTierFromEnv(),
1173
1329
  cloudFindingsCount,
1174
- services: services.map((s) => ({
1175
- port: s.port, protocol: s.protocol ?? 'tcp',
1176
- service: s.service ?? null, version: s.version ?? null,
1177
- })),
1330
+ services: services.map(historyServiceEntry),
1331
+ // 1.3.0 (s1) B: the basis stamp and the host-level service checks (a domain's DNS posture, wherever it landed).
1332
+ ...historyHostEntry(conclusion),
1178
1333
  };
1179
1334
 
1180
1335
  // Retrieve previous scan for this host before recording the new one
@@ -1201,6 +1356,11 @@ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
1201
1356
  host, results, conclusion, ai_file_paths, ai_conclusion, ai_status, ai_error, scanDiff,
1202
1357
  // Additive, consumed only by main()'s top-level KEV/EPSS aggregation for the run record.
1203
1358
  hostAppended, kevDataAsOf, epssDataAsOf, nvdCache,
1359
+ // Additive (1.3.0 items 4 + 11): what a --watch cycle compares, host by host.
1360
+ scanSummary,
1361
+ // 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
1362
+ // — 060's decline of an IP address — the same way scan_host's does. The run record already carries it.
1363
+ pluginStatus,
1204
1364
  };
1205
1365
  }
1206
1366
 
@@ -1241,13 +1401,6 @@ function aggregateStoreLoad(writtenOutputs, field, label) {
1241
1401
 
1242
1402
  const SEVERITY_RANK = { critical: 4, high: 3, medium: 2, low: 1, info: 0 };
1243
1403
 
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
1404
  async function readSecretFromStdin(keyName) {
1252
1405
  if (!process.stdin.isTTY) {
1253
1406
  // Piped input
@@ -1269,33 +1422,18 @@ async function readSecretFromStdin(keyName) {
1269
1422
  });
1270
1423
  }
1271
1424
 
1425
+ /**
1426
+ * The highest severity rank (0-4) among the findings a conclusion carries — the `--fail-on` gate.
1427
+ * @param {object} conclusion
1428
+ * @returns {number} highest severity rank found (0-4); any concluded scan is at least info (0)
1429
+ */
1272
1430
  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;
1431
+ // Every grade comes from the shared service-flag table (utils/service_flags.mjs, 1.3.0 (s1)) — the gate reads what the
1432
+ // Markdown report, SARIF and the CSV read, including a payload that landed in the conclusion's evidence. An open service
1433
+ // is graded OPEN_SERVICE_SEVERITY, the grade SARIF gives it ((s2)); any concluded scan is at least info.
1434
+ const open = (conclusion?.result?.services || []).some((s) => s.status === 'open');
1435
+ return Math.max(SEVERITY_RANK.info, open ? flagSeverityRank(OPEN_SERVICE_SEVERITY) : -1,
1436
+ maxRank(conclusionFindings(conclusion, '')));
1299
1437
  }
1300
1438
 
1301
1439
  /**
@@ -1637,7 +1775,7 @@ export async function runReport(args, caps) {
1637
1775
 
1638
1776
  export async function main(testHooks = {}) {
1639
1777
  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;
1778
+ const { cmd, host, plugins, insecureHttps, hostFile, parallel, failOn, outputFormat, watch, intervalMinutes, webhookUrl, alertSeverity, alertEveryCycle, alertPayload, notifyWebhook, notifyFormat, notifySeverity, notifyPayload, ports, compliance, complianceScope, complianceHistory, slaPolicy, attestWindow, framework, awsRegion, approvalArgs, feedArgs } = args;
1641
1779
 
1642
1780
  // Version: handled before license verification so it works without a key.
1643
1781
  // CE-0.1.30.1 — closes the discovery-flag UX gap where pre-fix
@@ -1676,32 +1814,57 @@ Scan options:
1676
1814
  region-scoped checks use AWS_REGION, else one region (with an
1677
1815
  incomplete-coverage notice); CloudTrail trail discovery,
1678
1816
  GuardDuty/Inspector and EC2 instances attempt every region.
1679
- --plugins <list|all> Plugins to run (e.g. 001,003,020 or "all"; default: all)
1817
+ --plugins <list|all> Plugins to run (e.g. 001,003,011 or "all"; default: all)
1680
1818
  --ports <list> Ports ADDED to the default set (e.g. 22,80,443 or 161/udp;
1681
1819
  a range such as 1-1000 is not parsed and adds nothing)
1682
1820
  --out <dir> Output directory for scan artifacts
1683
1821
  --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.
1822
+ --fail-on <severity> Exit 1 if a finding ≥ severity — the findings the md /
1823
+ SARIF / CSV reports count, graded by one shared table:
1824
+ SNMP default community, weak SSH / TLS, MCP checks, SMB
1825
+ null session, self-signed / expired certificates, the
1826
+ 040 / 050 / 060 audits (060: the domain's SPF / DMARC /
1827
+ NS), and Dangerous HTTP methods (medium) only where an
1828
+ Allow header was read. Anonymous FTP / zone transfer /
1829
+ null session only with FTP_CHECK_ANON / DNS_CHECK_AXFR
1830
+ or DNS_AUDIT_AXFR_CHECK (060) / SMB_NULL_SESSION set;
1831
+ not tested never trips it; any concluded scan is info.
1832
+ Not CVEs or agent findings — exit 0 is not a clean host.
1833
+ Exit 2: unknown severity or no conclusion.
1691
1834
  --output-format <fmt> Additional report format: sarif | csv | md
1692
1835
  --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 —
1836
+ --watch CTEM continuous ALERTING mode: re-scan on --interval and
1837
+ compare each host with the previous cycle. --webhook-url
1838
+ alerts a host whose scan changed (a service, its finding
1839
+ count or a service check, or a comparison that could not be
1840
+ made) and that has a finding at or above --alert-severity.
1841
+ The first cycle sets the baseline and alerts nobody; a host
1842
+ whose scan failed is reported on stdout and does not alert in
1843
+ this release. NOT an evidence cadence —
1697
1844
  it adds no retention or cross-run aggregation, skips SARIF/
1698
1845
  CSV/Markdown output and --fail-on, and dies with this process.
1699
1846
  Each tick is an ordinary scan, so with --compliance it writes
1700
1847
  that tick's artifacts; nothing relates them across ticks. For
1701
1848
  SOC 2 Type II history use a scheduler (cron/systemd/CI).
1849
+ Watch mode scans each distinct host once per cycle; a
1850
+ one-shot --host X,X scans X twice.
1702
1851
  --interval <minutes> Watch interval (default 60)
1703
1852
  --webhook-url <url> Send --watch alerts (must be public; private/loopback blocked)
1704
1853
  --alert-severity <sev> Min severity to alert on (default: high)
1854
+ --alert-every-cycle Alert every host with a finding at or above --alert-severity
1855
+ on every --watch cycle, the first included (default: only
1856
+ hosts whose scan changed)
1857
+ --alert-payload <mode> full (default) | minimal — minimal drops the host, service and
1858
+ description from each --watch alert
1859
+ --notify-webhook <url> Enterprise, one-shot scans with --compliance only (never --watch):
1860
+ POST the scan's drift (scan_drift_<fw>.json) when a control goes
1861
+ to fail, a control loses measurement, or a new violation is at or
1862
+ above --notify-severity. Public https URLs only; off by default
1863
+ --notify-format <fmt> generic (default) | slack | teams (a Teams Workflows webhook)
1864
+ --notify-severity <sev> critical | high (default) | medium | low | info — for new
1865
+ violations only
1866
+ --notify-payload <mode> minimal (default: no hostnames, resources, account ids or
1867
+ finding text) | full
1705
1868
  --compliance <framework> Map findings to controls. 'all' = all 8 frameworks, or a CSV of
1706
1869
  soc2,hipaa,nist-csf,pci-dss,iso-27001,cis-v8,gdpr,nist-800-171
1707
1870
  (aliases nist/pci/iso/cis, and 800-171 or cmmc for nist-800-171 --
@@ -1810,7 +1973,9 @@ Environment:
1810
1973
  NSA_MCP_AUTH_KEY MCP server auth key — read by mcp_server at startup;
1811
1974
  client supplies via Claude Desktop config env block
1812
1975
  NSA_MCP_AUTH_DISABLE=1 Skip MCP auth check (CI/dev escape hatch — emits warn)
1813
- NSA_ALLOW_ALL_HOSTS=1 Permit RFC1918 / loopback (local-network auditing)
1976
+ NSA_ALLOW_ALL_HOSTS=1 Local-network auditing. CLI: lifts the whole scan-entry
1977
+ guard, loopback and metadata included. MCP server:
1978
+ admits RFC 1918 / CGNAT / fc00::/7 only
1814
1979
  CLOUD_PROVIDER=aws|gcp|azure Required for cloud scanner plugins (020/021/022/023/030)
1815
1980
  AI_PROVIDER=openai|claude|ollama AI provider for report generation
1816
1981
  NSAUDITOR_TSA_URL RFC 3161 timestamp authority for compliance attestation.
@@ -2690,30 +2855,45 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
2690
2855
  }
2691
2856
  }
2692
2857
  } 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").
2858
+ // 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
2859
+ // body (utils/mcp_call_digest.mjs); this recomputes the digest from the text the user saved. The id alone proves
2860
+ // only that the server issued it once — an AI client that has seen an earlier response can paste a real id under
2861
+ // a fabricated one — so an id with no text is INDETERMINATE, never "verified".
2862
+ // Exit: 0 verified · 1 refuted (never issued, or the text does not match) · 2 usage · 3 indeterminate.
2700
2863
  const { readFile, stat } = await import('node:fs/promises');
2701
2864
  const { join: _join } = await import('node:path');
2702
2865
  const { homedir: _homedir } = await import('node:os');
2866
+ const { responseDigest, bodyOf } = await import('./utils/mcp_call_digest.mjs');
2703
2867
  const logPath = _join(_homedir(), '.nsauditor', 'mcp-calls.log');
2704
2868
  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.');
2869
+ const respAt = rawArgs.indexOf('--response');
2870
+ const respPath = respAt > 0 ? rawArgs[respAt + 1] : null;
2871
+ if (!uuid || (respAt > 0 && !respPath)) {
2872
+ console.error('Usage: nsauditor-ai mcp verify-call <uuid> --response <file|->');
2873
+ console.error(' Save the whole tool response (footer included) to a file, or pipe it in with --response -.');
2708
2874
  process.exit(2);
2709
2875
  }
2710
- // Conservative UUID v4 shape check — avoid grepping the log with
2711
- // arbitrary user input.
2876
+ // Conservative UUID v4 shape check — avoid grepping the log with arbitrary user input.
2712
2877
  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
2878
  console.error(`✗ Not a valid UUID: ${uuid}`);
2714
2879
  console.error(' Expected format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx');
2715
2880
  process.exit(2);
2716
2881
  }
2882
+ let responseText = null;
2883
+ if (respPath) {
2884
+ try {
2885
+ if (respPath === '-') {
2886
+ const chunks = [];
2887
+ for await (const c of process.stdin) chunks.push(c);
2888
+ responseText = Buffer.concat(chunks).toString('utf8');
2889
+ } else {
2890
+ responseText = await readFile(respPath, 'utf8');
2891
+ }
2892
+ } catch (err) {
2893
+ console.error(`✗ Could not read the response from ${respPath}: ${err.message}`);
2894
+ process.exit(2);
2895
+ }
2896
+ }
2717
2897
  let logExists = false;
2718
2898
  try { await stat(logPath); logExists = true; } catch { /* missing */ }
2719
2899
  if (!logExists) {
@@ -2726,33 +2906,50 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
2726
2906
  const raw = await readFile(logPath, 'utf8');
2727
2907
  // Look for an exact JSON-string match on call_id to avoid prefix collisions.
2728
2908
  const needle = `"call_id":"${uuid.toLowerCase()}"`;
2729
- const lines = raw.split('\n').filter((l) => l.includes(needle));
2730
- if (lines.length === 0) {
2909
+ const entries = raw.split('\n').filter((l) => l.includes(needle))
2910
+ .map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
2911
+ if (entries.length === 0) {
2731
2912
  console.log(`✗ call_id not found in ${logPath}`);
2732
2913
  console.log('');
2733
2914
  console.log(` ${uuid}`);
2734
2915
  console.log('');
2735
2916
  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".)');
2917
+ console.log(' the AI client fabricated the response without invoking the server.');
2918
+ console.log(' (See docs/mcp-verification.md.)');
2738
2919
  process.exit(1);
2739
2920
  }
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)`);
2921
+ const issued = entries.find((e) => e.tool) ?? entries[0];
2922
+ const ago = (() => {
2923
+ const ms = Date.now() - Date.parse(issued.ts ?? '');
2924
+ if (!Number.isFinite(ms)) return 'at an unrecorded time';
2925
+ const units = [['day', 86400000], ['hour', 3600000], ['minute', 60000]];
2926
+ for (const [u, n] of units) if (ms >= n) { const k = Math.floor(ms / n); return `${k} ${u}${k === 1 ? '' : 's'} ago`; }
2927
+ return 'less than a minute ago';
2928
+ })();
2929
+ const issuedLine = `${issued.tool ?? 'an unrecorded tool'}, issued ${ago} (${issued.ts ?? 'no timestamp'})`;
2930
+ const digests = entries.map((e) => e.digest).filter((d) => typeof d === 'string');
2931
+ if (digests.length === 0) {
2932
+ console.log(`? call_id ${uuid} was issued by this server for ${issued.tool ?? 'an unrecorded tool'} ${ago} (${issued.ts ?? 'no timestamp'}).`);
2933
+ console.log(' This call predates response binding: no digest of its response was recorded, so the text');
2934
+ console.log(' cannot be bound to it. The id alone does not verify a response.');
2935
+ process.exit(3);
2936
+ }
2937
+ if (responseText == null) {
2938
+ console.log(`? call_id ${uuid} was issued by this server for ${issued.tool ?? 'an unrecorded tool'} ${ago} (${issued.ts ?? 'no timestamp'}).`);
2939
+ console.log(' That alone does not verify the text you are reading: an id from an earlier response can be');
2940
+ console.log(' pasted under a fabricated one. Save the raw tool response and run:');
2941
+ console.log(` nsauditor-ai mcp verify-call ${uuid} --response <file>`);
2942
+ process.exit(3);
2943
+ }
2944
+ if (digests.includes(responseDigest(bodyOf(responseText)))) {
2945
+ console.log(`✓ This response text was produced by this server for that call — ${issuedLine}.`);
2753
2946
  console.log(` log: ${logPath}`);
2754
2947
  process.exit(0);
2755
2948
  }
2949
+ console.log(`✗ The text does not match what this server sent for that call (${issuedLine}).`);
2950
+ console.log(' Either it was altered after copying (rendering, re-wrapping) or it was not produced by this');
2951
+ console.log(' call — re-copy the RAW tool output and verify again.');
2952
+ process.exit(1);
2756
2953
  } else {
2757
2954
  console.log('Usage:');
2758
2955
  console.log(' nsauditor-ai mcp install-key Generate a new key, persist, print Claude config');
@@ -2761,7 +2958,7 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
2761
2958
  console.log(' nsauditor-ai mcp rotate-key Replace the stored key with a fresh one');
2762
2959
  console.log(' nsauditor-ai mcp status Show storage source without revealing the key');
2763
2960
  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)');
2961
+ 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
2962
  console.log('');
2766
2963
  console.log('Environment variables:');
2767
2964
  console.log(` ${MCP_AUTH_ENV_VAR} Read by mcp_server.mjs at startup; client supplies via Claude config`);
@@ -3191,18 +3388,29 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
3191
3388
  opts.complianceTrackSla = true;
3192
3389
  }
3193
3390
  if (awsRegionIntent) opts.awsRegionIntent = awsRegionIntent;
3391
+ // EE 2.0.0: the drift notification needs a framework to drift — refused here, where --env has already loaded.
3392
+ const notifyRefused = notifyRefusal({ notifyWebhook, compliance, envCompliance: process.env.COMPLIANCE_FRAMEWORKS });
3393
+ if (notifyRefused) {
3394
+ console.error(notifyRefused.message);
3395
+ process.exit(notifyRefused.code);
3396
+ }
3397
+ if (notifyWebhook) Object.assign(opts, { notifyWebhook, notifyFormat, notifySeverity, notifyPayload });
3194
3398
  const pm = await PluginManager.create(`${__dirname}/plugins`);
3399
+ // Before the watch loop, the run record or any host: a --plugins list that names no plugin is refused by name.
3400
+ const selectionRefused = pluginSelectionRefusal(plugins, pm);
3401
+ if (selectionRefused) throw selectionRefused;
3195
3402
  const promptMode = String(process.env.OPENAI_PROMPT_MODE || 'basic').toLowerCase().trim();
3196
3403
 
3197
3404
  // --- CTEM: continuous watch mode ---
3198
3405
  if (watch) {
3199
3406
  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
3407
  let previousCycleResults = null;
3408
+ // EE 2.0.0: every scan in this loop tells Enterprise it is a watch cycle, so no drift file is written per cycle (the
3409
+ // drift file is the one-shot path's; the operator's ruling). `opts.runId` is already unset here.
3410
+ opts.watch = true;
3204
3411
 
3205
- const scheduler = createScheduler({
3412
+ // `testHooks._createScheduler`: a test seam, absent in every real invocation — it lets a leg run one watch cycle.
3413
+ const scheduler = (testHooks?._createScheduler ?? createScheduler)({
3206
3414
  intervalMs,
3207
3415
  hosts,
3208
3416
  parallel,
@@ -3216,44 +3424,21 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
3216
3424
  onCycleComplete: async (results) => {
3217
3425
  console.log(`[CTEM] Cycle complete. Scanned ${results.size} host(s).`);
3218
3426
 
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
- }
3427
+ // What stdout says and whom the webhook alerts, both from ONE predicate (utils/watch_cycle.mjs, 1.3.0 items 4 + 11).
3428
+ const { text, alerts } = watchCycle(results, previousCycleResults,
3429
+ { alertRank: SEVERITY_RANK[alertSeverity] ?? SEVERITY_RANK.high, everyCycle: alertEveryCycle });
3430
+ console.log(text ?? '[CTEM] First cycle complete — it establishes the baseline. Delta reporting begins on the next cycle.');
3431
+ if (webhookUrl) {
3432
+ for (const { host: h, findings } of alerts) {
3433
+ const payload = buildAlertPayload(h, findings, alertSeverity, { payload: alertPayload });
3434
+ // `testHooks._sendWebhook`: a test seam, absent in every real invocation.
3435
+ const webhookResult = await (testHooks?._sendWebhook ?? sendWebhook)(webhookUrl, payload, { retries: 2, retryDelayMs: 1000 });
3436
+ if (webhookResult.success) {
3437
+ console.log(`[CTEM] Webhook alert sent for ${h}`);
3438
+ } else {
3439
+ console.warn(`[CTEM] Webhook alert failed for ${h}: ${webhookResult.error}`);
3253
3440
  }
3254
3441
  }
3255
- } else {
3256
- console.log('[CTEM] First cycle complete. Delta reporting will begin on next cycle.');
3257
3442
  }
3258
3443
 
3259
3444
  previousCycleResults = results;
@@ -3270,6 +3455,14 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
3270
3455
  process.on('SIGINT', shutdown);
3271
3456
  process.on('SIGTERM', shutdown);
3272
3457
 
3458
+ // The banner reads the scheduler's own distinct list: a cycle scans each host once (1.3.0 lane 4, item 10).
3459
+ const dropped = scheduler.duplicatesDropped
3460
+ ? ` (${scheduler.duplicatesDropped} duplicate host(s) dropped — watch mode scans each host once per cycle)` : '';
3461
+ console.log(`[CTEM] Watch mode enabled. Interval: ${intervalMinutes}m, Concurrency: ${parallel}, Hosts: ${scheduler.hosts.length}${dropped}`);
3462
+ // The HOST only: a Slack or Teams webhook URL carries its own credential in the path or the query (EE 2.0.0).
3463
+ if (webhookUrl) console.log(`[CTEM] Webhook: ${new URL(webhookUrl).host}, Alert severity: ${alertSeverity}, alerting `
3464
+ + (alertEveryCycle ? 'every host with such a finding, every cycle' : 'hosts whose scan changed (the first cycle sets the baseline)'));
3465
+
3273
3466
  scheduler.start();
3274
3467
  return; // keep process alive via setInterval
3275
3468
  }
@@ -3476,6 +3669,7 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
3476
3669
  host: scanOut.host,
3477
3670
  conclusion: scanOut.conclusion,
3478
3671
  toolVersion: TOOL_VERSION,
3672
+ manifest: scanOut.pluginStatus ?? [],
3479
3673
  });
3480
3674
  const mdFileName = scanOutputs.length > 1
3481
3675
  ? `scan_${safeHost(scanOut.host)}.md`
@@ -3513,6 +3707,46 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
3513
3707
  }
3514
3708
  }
3515
3709
 
3710
+ /**
3711
+ * An explicit --plugins list, resolved against the loaded plugins before anything is scanned or written: a token that names
3712
+ * no plugin is refused BY NAME, and so is a list that names none. Until 1.0.0 both were dropped without a word, and a list
3713
+ * that resolved to nothing ran ZERO plugins — exit 0, "Security findings: 0", and with --compliance every in-scope control
3714
+ * PASS at zero evidence. `all` must stand alone: inside a list it was dropped too, silently running only the rest.
3715
+ * Returns an EFLAGVALUE error (exit 2 through exitOnMainRejection), or null when the selection stands.
3716
+ * @param {string[]|string|undefined} plugins parseArgs' `plugins`: 'all', or the list's trimmed tokens
3717
+ * @param {{ findPlugin(name: string): object|null }} pm
3718
+ */
3719
+ export function pluginSelectionRefusal(plugins, pm) {
3720
+ if (!Array.isArray(plugins)) return null;
3721
+ const refuse = (msg) => Object.assign(new Error(msg), { code: 'EFLAGVALUE' });
3722
+ if (plugins.length === 0) return refuse('--plugins named no plugin — give plugin ids or names, or all');
3723
+ const quote = (t) => JSON.stringify(t);
3724
+ const withAll = plugins.some((t) => t.toLowerCase() === 'all');
3725
+ const unknown = plugins.filter((t) => t.toLowerCase() !== 'all' && !pm.findPlugin(t));
3726
+ if (unknown.length) {
3727
+ return refuse(`--plugins: no plugin matches ${unknown.map(quote).join(', ')}${withAll ? '; "all" must be given alone' : ''}`
3728
+ + ' — name each plugin by its id or name (nsauditor-ai license --plugins lists them)');
3729
+ }
3730
+ if (withAll) return refuse('--plugins: "all" must be given alone — it already selects every plugin');
3731
+ return null;
3732
+ }
3733
+
3734
+ /**
3735
+ * What a rejected main() exits with — the ONE copy of this mapping. Both entry points call it: the block below when
3736
+ * cli.mjs is run directly, and bin/nsauditor-ai.mjs, the command npm installs (which imports main(), so cli.mjs is never
3737
+ * the entry point for an installed user). A flag refusal is an operator error, not a crash: name it and exit 2, this
3738
+ * CLI's "could not do what you asked" code. Everything else keeps the stack and exit 1. `error` and `exit` are seams for
3739
+ * the suite (tests/cli_bin_entrypoint.test.mjs).
3740
+ */
3741
+ export function exitOnMainRejection(err, { error = console.error, exit = process.exit } = {}) {
3742
+ if (err?.code === 'EFLAGSHAPE' || err?.code === 'EFLAGVALUE') {
3743
+ error(`[ERROR] ${err.message}`);
3744
+ return exit(2);
3745
+ }
3746
+ error(err?.stack || err);
3747
+ return exit(1);
3748
+ }
3749
+
3516
3750
  // Only auto-run main() when this file is the process entrypoint. Importing
3517
3751
  // cli.mjs (e.g. unit tests pulling in `parseArgs`) must NOT trigger a scan.
3518
3752
  const _isEntrypoint = (() => {
@@ -3523,14 +3757,5 @@ const _isEntrypoint = (() => {
3523
3757
  }
3524
3758
  })();
3525
3759
  if (_isEntrypoint) {
3526
- main().catch((err) => {
3527
- // A flag-shape refusal is an operator error, not a crash: name it and exit 2, this CLI's
3528
- // "could not do what you asked" code. Everything else keeps the stack and exit 1.
3529
- if (err?.code === 'EFLAGSHAPE') {
3530
- console.error(`[ERROR] ${err.message}`);
3531
- process.exit(2);
3532
- }
3533
- console.error(err?.stack || err);
3534
- process.exit(1);
3535
- });
3760
+ main().catch((err) => exitOnMainRejection(err));
3536
3761
  }