nsauditor-ai 0.2.57 → 1.1.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.
package/cli.mjs CHANGED
@@ -25,9 +25,10 @@ import { resolveCapabilities, hasCapability, inferRequiredTier, CAPABILITIES } f
25
25
  import { createScheduler } from './utils/scheduler.mjs';
26
26
  import { watchCycle } from './utils/watch_cycle.mjs';
27
27
  import { redactSetCookieLines } from './utils/cookie_redaction.mjs';
28
- import { sendWebhook, buildAlertPayload, isSafeWebhookUrl } from './utils/webhook.mjs';
28
+ import { sendWebhook, buildAlertPayload, isSafeWebhookUrl, ALERT_PAYLOADS, NOTIFY_FORMATS, NOTIFY_PAYLOADS, NOTIFY_SEVERITIES } from './utils/webhook.mjs';
29
29
  import { scrubByKey } from './utils/redact.mjs';
30
- import { isBlockedIp, resolveAndValidate, allowAllHosts, canonicalIp } from './utils/net_validation.mjs';
30
+ import { isBlockedIp, resolveAndValidateAll, allowAllHosts, canonicalIp } from './utils/net_validation.mjs';
31
+ import { runPinned } from './utils/scan_pin.mjs';
31
32
  import { getAllTechniques } from './utils/attack_map.mjs';
32
33
  import { TOOL_VERSION } from './utils/tool_version.mjs';
33
34
  import { resolveBaseOutDir, createHostOutDir, safeHost } from './utils/output_dir.mjs';
@@ -88,6 +89,34 @@ export function scrubIPv4KeepingProductIds(str, replaceAddress) {
88
89
  * to compare a raw against its payload, which needs the real function rather than a re-spelling
89
90
  * of its rules. `tests/redact_product_ids.test.mjs` is that census.
90
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
+
91
120
  export function redactSensitiveForAI(input, targetHost) {
92
121
  const DROP_KEYS = new Set([
93
122
  'ip6', 'deviceWebPage', 'deviceWebPageInstruction',
@@ -99,8 +128,11 @@ export function redactSensitiveForAI(input, targetHost) {
99
128
  /^172\.(1[6-9]|2\d|3[0-1])\./.test(ip) ||
100
129
  /^192\.168\./.test(ip);
101
130
 
131
+ const accountIds = [...awsAccountIdsIn(input)];
132
+
102
133
  const scrubString = (str) => {
103
134
  let s = String(str);
135
+ for (const id of accountIds) s = s.split(id).join(ACCOUNT_ID_MASK);
104
136
  s = s.replace(/\bSerial\s*[:=]\s*[A-Za-z0-9._-]+/gi, 'Serial=[REDACTED_HIDDEN]');
105
137
  s = s.replace(/\b(?:[0-9a-f]{2}:){5}[0-9a-f]{2}\b/gi, '[MAC]'); // MAC
106
138
  s = s.replace(/\bfe80::[0-9a-f:]+\b/gi, '[FE80::/64]'); // IPv6 link-local
@@ -110,6 +142,8 @@ export function redactSensitiveForAI(input, targetHost) {
110
142
  // A scanned service's Set-Cookie VALUE is its session token: redacted here whatever a producer kept (1.3.0, both
111
143
  // seams ruled — the webapp detector still reflects its headers into the result).
112
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);
113
147
  return s;
114
148
  };
115
149
 
@@ -131,18 +165,117 @@ export function redactSensitiveForAI(input, targetHost) {
131
165
  return walk(input);
132
166
  }
133
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
+
134
252
  /* ------------------------- OpenAI & reporting -------------------------- */
135
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
+
136
273
  async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basic', outDir: presetOutDir = null, runId = null, pluginStatus = [] }) {
137
274
  // --- env & opts -----------------------------------------------------------
138
275
  const sendEnabled = parseBool(process.env.AI_ENABLED);
139
276
  const redactEnabled = parseBool(process.env.OPENAI_REDACT, true);
140
277
  const aiProvider = (process.env.AI_PROVIDER || 'openai').toLowerCase().trim();
141
- const model = aiProvider === 'claude'
142
- ? toCleanPath(process.env.ANTHROPIC_MODEL || 'claude-sonnet-4-6')
143
- : aiProvider === 'ollama'
144
- ? toCleanPath(process.env.OLLAMA_MODEL || 'llama3')
145
- : toCleanPath(process.env.OPENAI_MODEL || 'gpt-4o-mini');
278
+ const model = aiModelFor(process.env);
146
279
  const { resolveSecret } = await import('./utils/keychain.mjs');
147
280
  const keyRaw = aiProvider === 'claude'
148
281
  ? await resolveSecret(process.env.ANTHROPIC_API_KEY)
@@ -163,6 +296,7 @@ async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basi
163
296
  const adminRawPath = path.join(outDir, 'scan_conclusion_raw.json');
164
297
  const adminHtmlPath = path.join(outDir, 'scan_conclusion_raw.html');
165
298
  const aiPayloadPath = path.join(outDir, 'scan_response_ai_payload.json');
299
+ const aiSystemPromptPath = path.join(outDir, 'scan_response_ai_system_prompt.txt');
166
300
  const aiResponsePath = path.join(outDir, 'scan_response_ai.json');
167
301
  const aiTxtPath = path.join(outDir, 'scan_response_ai.txt');
168
302
  const aiHtmlPath = path.join(outDir, 'scan_response_ai.html');
@@ -260,91 +394,8 @@ async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basi
260
394
  }
261
395
 
262
396
  // --- Build sanitized payload for AI ---------------------------------------
263
- let payloadForAI = {
264
- host,
265
- host_os_hint: hostOsHint,
266
- summary: summaryWithFullSerial, // include full; redactor will mask
267
- services: conclusion?.result?.services ?? [],
268
- evidence: conclusion?.result?.evidence ?? [],
269
- _meta: {
270
- resultsCount: Array.isArray(results) ? results.length : (results ? 1 : 0),
271
- serialFound: !!detectedSerial
272
- }
273
- };
274
-
275
- if (redactEnabled) {
276
- let used = 'fallback';
277
- try {
278
- // Only allow external redaction override for Pro/Enterprise tiers.
279
- // CE always uses the built-in redact pipeline to preserve the ZDE guarantee.
280
- const redactCaps = resolveCapabilities(getTierFromEnv());
281
- if (hasCapability(redactCaps, 'enhancedRedaction') && typeof globalThis.redactSensitiveForAI === 'function') {
282
- let out = globalThis.redactSensitiveForAI(payloadForAI);
283
- if (out && typeof out.then === 'function') out = await out;
284
- if (typeof out === 'string') out = JSON.parse(out);
285
- if (!out || typeof out !== 'object') throw new Error('external redactor returned non-object');
286
- payloadForAI = out;
287
- used = 'external';
288
- } else {
289
- payloadForAI = redactSensitiveForAI(payloadForAI, host);
290
- }
291
- } catch (e) {
292
- console.warn('[OpenAI] Redaction failed, using fallback:', e?.message || e);
293
- payloadForAI = redactSensitiveForAI(payloadForAI, host);
294
- used = 'fallback';
295
- }
296
-
297
- // additional key-based scrubbing (CONFIDENTIAL_KEYWORDS=serial,password,token)
298
- const keywords = String(process.env.CONFIDENTIAL_KEYWORDS || '')
299
- .split(',')
300
- .map((s) => s.trim().toLowerCase())
301
- .filter(Boolean);
302
- if (keywords.length) payloadForAI = scrubByKey(payloadForAI, keywords, '[REDACTED_HIDDEN]');
303
-
304
- // Redact top-level host field (private IPs survive scrubString)
305
- if (typeof payloadForAI.host === 'string') {
306
- payloadForAI.host = '[REDACTED_HOST]';
307
- }
308
-
309
- // Redact any remaining IP addresses in the summary field
310
- if (typeof payloadForAI.summary === 'string') {
311
- // ⚠️ THE SAME RULE, A DIFFERENT POLICY: every address goes, private ones included. It
312
- // carried its own copy of the address regex — the copy that was NOT fixed when the
313
- // identifier defect was found in the redactor, which is exactly how the second instance
314
- // of a class arrives. Measured at the time: neither live payload's summary carries an
315
- // `EE-` token, so this was a latent duplicate rather than a live defect.
316
- payloadForAI.summary = scrubIPv4KeepingProductIds(payloadForAI.summary, () => '[REDACTED_HOST]');
317
- }
318
-
319
- // Also redact private IPs in nested service/evidence strings
320
- 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;
321
- function scrubPrivateIps(obj) {
322
- if (typeof obj === 'string') return obj.replace(privateIpRe, '[REDACTED_IP]');
323
- if (Array.isArray(obj)) return obj.map(scrubPrivateIps);
324
- if (obj && typeof obj === 'object') {
325
- const out = {};
326
- for (const [k, v] of Object.entries(obj)) out[k] = scrubPrivateIps(v);
327
- return out;
328
- }
329
- return obj;
330
- }
331
- payloadForAI.services = scrubPrivateIps(payloadForAI.services);
332
- payloadForAI.evidence = scrubPrivateIps(payloadForAI.evidence);
333
-
334
- payloadForAI = { ...payloadForAI, _meta: { ...(payloadForAI?._meta || {}), wasRedacted: true, redactor: used } };
335
- } else {
336
- payloadForAI = { ...payloadForAI, _meta: { ...(payloadForAI?._meta || {}), wasRedacted: false } };
337
- }
338
-
339
- // Ensure placeholder only if a serial was detected
340
- if (payloadForAI?._meta?.serialFound && detectedSerial) {
341
- const s = String(payloadForAI.summary ?? '').trim();
342
- if (!/\bSerial\s*[:=]/i.test(s)) {
343
- payloadForAI.summary = `${s} Serial: [REDACTED_HIDDEN]`;
344
- } else {
345
- payloadForAI.summary = s.replace(/\bSerial\s*[:=]\s*([A-Za-z0-9._-]+)/i, 'Serial: [REDACTED_HIDDEN]');
346
- }
347
- }
397
+ const payloadForAI = buildAIPayload({ host, hostOsHint, summary: summaryWithFullSerial, results, conclusion, detectedSerial,
398
+ redact: redactEnabled });
348
399
 
349
400
  // --- Bail out early if sending disabled -----------------------------------
350
401
  const providerLabel = aiProvider === 'claude' ? 'Claude' : aiProvider === 'ollama' ? 'Ollama' : 'OpenAI';
@@ -379,6 +430,17 @@ async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basi
379
430
  const eeBlock = conclusion?.result?.eeEnrichment?.enrichedPrompt;
380
431
  if (eeBlock) promptText = eeBlock + '\n\n---\n\n' + promptText;
381
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
+
382
444
  // --- Send to AI provider ---------------------------------------------------
383
445
  let aiConclusionText = null;
384
446
  // review fold 8: contain the serialization — hoisting userContent above the
@@ -615,6 +677,18 @@ async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basi
615
677
 
616
678
  /* ------------------------------- CLI ----------------------------------- */
617
679
 
680
+ /**
681
+ * Why a webhook URL flag's value is refused before any address is looked at, or null: it is not a URL, or its scheme is
682
+ * not one the flag takes. The value itself is never repeated — a Slack or Teams webhook URL carries its own credential.
683
+ * @param {string} flag @param {unknown} value @param {string[]} schemes e.g. ['https:'] or ['http:', 'https:']
684
+ */
685
+ function webhookSchemeRefusal(flag, value, schemes) {
686
+ const wanted = schemes.length === 1 ? `an ${schemes[0].replace(':', '')} URL` : `an ${schemes.map((x) => x.replace(':', '')).join(' or ')} URL`;
687
+ let protocol;
688
+ try { protocol = new URL(String(value)).protocol; } catch { return `${flag} must be ${wanted}; the value given is not a URL`; }
689
+ return schemes.includes(protocol) ? null : `${flag} must be ${wanted}; got ${protocol}`;
690
+ }
691
+
618
692
  export async function parseArgs(argv) {
619
693
  const args = { cmd: 'scan', host: undefined, plugins: 'all', insecureHttps: false };
620
694
  const a = argv.slice(2);
@@ -711,21 +785,81 @@ export async function parseArgs(argv) {
711
785
  args.watch = !!(get('watch'));
712
786
  const intervalVal = get('interval');
713
787
  args.intervalMinutes = (intervalVal && intervalVal !== true) ? Math.max(1, parseInt(intervalVal, 10) || 60) : 60;
788
+ // Every refusal from here on THROWS with code EFLAGVALUE (mapped to exit 2 at the entry point), never `process.exit`:
789
+ // parseArgs is exported and driven in-process by the suite, and an exit here kills the runner mid-file (the EFLAGSHAPE
790
+ // lesson — `--webhook-url` exited here until the next Community release after 1.0.0).
791
+ const flagValue = (msg) => Object.assign(new Error(msg), { code: 'EFLAGVALUE' });
714
792
  const whUrl = get('webhook-url') || get('webhook_url') || null;
715
793
  if (whUrl && whUrl !== true) {
794
+ // Not a URL, or a scheme other than http(s), is named as that — never as an internal address.
795
+ const schemeRefusal = webhookSchemeRefusal('--webhook-url', whUrl, ['http:', 'https:']);
796
+ if (schemeRefusal) throw flagValue(schemeRefusal);
716
797
  if (!(await isSafeWebhookUrl(whUrl))) {
717
- console.error(`[ERROR] Webhook URL rejected: private/loopback/metadata addresses are not allowed.`);
718
- process.exit(2);
798
+ throw flagValue('Webhook URL rejected: private/loopback/metadata addresses are not allowed.');
799
+ }
800
+ // A plain-http alert URL is accepted (documented; whether to refuse it is a MAJOR-shaped decision, boarded), and the
801
+ // operator is told once what it costs. The URL itself is never printed — it carries its own credential.
802
+ if (new URL(whUrl).protocol === 'http:') {
803
+ console.warn('[WARN] --webhook-url is a plain http URL: findings leave in the clear over plaintext HTTP on every '
804
+ + 'watch alert sent to it — use an https URL');
719
805
  }
720
806
  args.webhookUrl = whUrl;
721
807
  } else {
722
808
  args.webhookUrl = null;
723
809
  }
724
- const alertSev = get('alert-severity') || get('alert_severity') || null;
725
- args.alertSeverity = (alertSev && alertSev !== true) ? alertSev.toLowerCase() : 'high';
810
+ // The accepted values are the alert path's own rank table (SEVERITY_RANK), in any case. An unrecognised one used to
811
+ // become `high` in silence — the rank fallback — while the alert echoed the typo; it is refused like every value flag.
812
+ const alertSev = get('alert-severity') ?? get('alert_severity');
813
+ const ALERT_SEVERITIES = Object.keys(SEVERITY_RANK);
814
+ if (alertSev === undefined || alertSev === null) args.alertSeverity = 'high';
815
+ else if (alertSev === true || !ALERT_SEVERITIES.includes(String(alertSev).toLowerCase())) {
816
+ throw flagValue(`--alert-severity must be one of ${ALERT_SEVERITIES.join(' | ')}`);
817
+ } else args.alertSeverity = String(alertSev).toLowerCase();
726
818
  // R4: by default a watch cycle alerts only hosts whose scan CHANGED; this restores an alert on every cycle.
727
819
  args.alertEveryCycle = !!(get('alert-every-cycle') || get('alert_every_cycle'));
728
820
 
821
+ // ── EE 2.0.0 (cargo 3, D3/D5): the drift notification and the watch alert's payload mode ─────────────────────────
822
+ const oneOf = (flag, allowed, dflt) => {
823
+ const v = get(flag);
824
+ if (v === undefined) return dflt;
825
+ if (v === true || !allowed.includes(String(v).toLowerCase())) throw flagValue(`--${flag} must be one of ${allowed.join(' | ')}`);
826
+ return String(v).toLowerCase();
827
+ };
828
+ const notifyUrl = get('notify-webhook');
829
+ if (notifyUrl === true) throw flagValue('--notify-webhook requires a URL');
830
+ args.notifyFormat = oneOf('notify-format', NOTIFY_FORMATS, null);
831
+ args.notifySeverity = oneOf('notify-severity', NOTIFY_SEVERITIES, null);
832
+ args.notifyPayload = oneOf('notify-payload', NOTIFY_PAYLOADS, null);
833
+ if (notifyUrl === undefined) {
834
+ const stray = [['notify-format', args.notifyFormat], ['notify-severity', args.notifySeverity], ['notify-payload', args.notifyPayload]]
835
+ .filter(([, v]) => v !== null).map(([f]) => `--${f}`);
836
+ if (stray.length) throw flagValue(`${stray.join(', ')} configure the drift notification and need --notify-webhook <url>`);
837
+ args.notifyWebhook = null;
838
+ } else {
839
+ if (args.watch) {
840
+ throw flagValue('--notify-webhook sends the drift file of a ONE-SHOT scan, and --watch writes none; '
841
+ + 'watch-mode alerts use --webhook-url');
842
+ }
843
+ // https ONLY (the architect seat's read, F3): the URL is a credential and the body is scan content, so neither
844
+ // crosses the network in the clear. --webhook-url is unchanged.
845
+ let notifyProtocol = null;
846
+ try { notifyProtocol = new URL(notifyUrl).protocol; } catch { /* not a URL — named just below */ }
847
+ if (notifyProtocol === 'http:') {
848
+ throw flagValue('--notify-webhook must be an https URL: it carries its own credential, and the drift it sends is scan content');
849
+ }
850
+ const notifySchemeRefusal = webhookSchemeRefusal('--notify-webhook', notifyUrl, ['https:']);
851
+ if (notifySchemeRefusal) throw flagValue(notifySchemeRefusal);
852
+ // The SAME boundary check --webhook-url gets: public destinations only, DNS-resolved.
853
+ if (!(await isSafeWebhookUrl(notifyUrl))) {
854
+ throw flagValue('--notify-webhook rejected: private/loopback/metadata addresses are not allowed');
855
+ }
856
+ args.notifyWebhook = notifyUrl;
857
+ }
858
+ args.alertPayload = oneOf('alert-payload', ALERT_PAYLOADS, 'full');
859
+ if (get('alert-payload') !== undefined && !args.webhookUrl) {
860
+ throw flagValue('--alert-payload shapes the --watch webhook alert and needs --webhook-url <url>');
861
+ }
862
+
729
863
  // Compliance: framework selector + scope file. Forwarded to EE's
730
864
  // runCompliancePhase via enrichScan(). No-op without an EE Enterprise license.
731
865
  const complianceVal = get('compliance');
@@ -865,6 +999,16 @@ export async function parseArgs(argv) {
865
999
  * @param {{ cmd?: string, host?: string|true, hostFile?: string }} args parseArgs' result
866
1000
  * @returns {{ message: string, code: number }|null}
867
1001
  */
1002
+ /**
1003
+ * EE 2.0.0: --notify-webhook sends the drift of a COMPLIANCE scan, so a run with no framework — from --compliance or
1004
+ * from COMPLIANCE_FRAMEWORKS, which --env may have loaded — would accept the flag and send nothing, ever. Refused.
1005
+ */
1006
+ export function notifyRefusal({ notifyWebhook, compliance, envCompliance } = {}) {
1007
+ if (!notifyWebhook || compliance || envCompliance) return null;
1008
+ return { code: 2, message: 'Fatal: --notify-webhook sends the drift file of a compliance scan; name a framework '
1009
+ + 'with --compliance (or COMPLIANCE_FRAMEWORKS)' };
1010
+ }
1011
+
868
1012
  export function scanTargetRefusal({ cmd, host, hostFile } = {}) {
869
1013
  if (cmd !== 'scan') return { message: `Unknown command: ${cmd}`, code: 2 };
870
1014
  if (!hostFile && !host) return { message: 'Fatal: --host or --host-file is required', code: 2 };
@@ -894,34 +1038,59 @@ const CLOUD_SENTINEL_HOSTS = new Set(['aws', 'gcp', 'azure']);
894
1038
  * that is not a literal is resolved with EVERY answer checked. Before 1.3.0 a digits-and-dots string
895
1039
  * skipped resolution (/^[\\d.:[\\]]+$/), so '6425673729' — which the OS reads as 127.0.0.1 — was
896
1040
  * scanned unchecked.
1041
+ *
1042
+ * PINNED SINCE EE 2.0.0 (R1b — the operator's ruling "CLI pins too"): it RETURNS the answer set it resolved, and
1043
+ * scanSingleHost runs the plugins inside `runPinned` with it, so the scan connects to the addresses this check looked at.
1044
+ * NSA_ALLOW_ALL_HOSTS relaxes WHICH answers are admitted, never whether one may change between the check and the connect:
1045
+ * under it the name is resolved WITHOUT a refusal and still pinned; a name that does not resolve is refused
1046
+ * (ETARGETUNRESOLVED) under it as without it — until the Community release after 1.0.0 it scanned unpinned.
897
1047
  * @param {string} host
898
1048
  * @param {Record<string,string|undefined>} [env]
1049
+ * @returns {Promise<Array<{ address: string, family: 4|6 }> | null>} the answers to pin; null for a literal or a sentinel
899
1050
  */
900
1051
  export async function assertScanTargetAllowed(host, env = process.env) {
1052
+ // A refusal is an operator error, coded so the entry point names it and exits 2 rather than printing a stack.
1053
+ const refuse = (msg) => Object.assign(new Error(msg), { code: 'ETARGETREFUSED' });
1054
+ // A name that does not resolve was not refused by the guard — it cannot be scanned at all, because a scan connects only
1055
+ // to the addresses its check resolved. Its own code and its own sentence, with or without NSA_ALLOW_ALL_HOSTS (which
1056
+ // admits every address range, never an unresolved name). MCP refuses it the same way.
1057
+ const unresolved = (err) => Object.assign(new Error(`Host could not be resolved: ${host} (${err?.code ?? 'no answer'}) — `
1058
+ + 'a scan connects only to the addresses its host check resolved, so a target that does not resolve is not scanned'),
1059
+ { code: 'ETARGETUNRESOLVED' });
1060
+ const BLOCKED_ANSWER = 'Host resolves to blocked IP range';
901
1061
  const isCloudSentinel = typeof host === 'string' && CLOUD_SENTINEL_HOSTS.has(host.toLowerCase());
902
- if (isCloudSentinel || allowAllHosts(env)) return;
1062
+ if (isCloudSentinel) return null;
1063
+ if (allowAllHosts(env)) {
1064
+ if (canonicalIp(host)) return null;
1065
+ try { return await resolveAndValidateAll(host, { checkAnswers: false }); } catch (err) { throw unresolved(err); }
1066
+ }
903
1067
  if (isBlockedIp(host)) {
904
- throw new Error(`Scanning blocked address range is not allowed: ${host}`);
1068
+ throw refuse(`Scanning blocked address range is not allowed: ${host}`);
905
1069
  }
906
1070
  if (!canonicalIp(host)) {
907
1071
  try {
908
- await resolveAndValidate(host);
1072
+ return await resolveAndValidateAll(host);
909
1073
  } catch (err) {
910
- throw new Error(`Host rejected by SSRF guard: ${err.message}`);
1074
+ if (err?.message === BLOCKED_ANSWER) throw refuse(`Host rejected by SSRF guard: ${err.message}`);
1075
+ throw unresolved(err);
911
1076
  }
912
1077
  }
1078
+ return null;
913
1079
  }
914
1080
 
915
1081
  export async function scanSingleHost(pm, host, plugins, opts, promptMode) {
916
1082
  // 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).
1083
+ // NSA_ALLOW_ALL_HOSTS=1 lifts this whole guard for local network auditing — loopback and metadata included (the MCP
1084
+ // server's guard admits private ranges only).
918
1085
  // Cloud-sentinel hosts (see CLOUD_SENTINEL_HOSTS above) skip the guard.
919
- await assertScanTargetAllowed(host);
1086
+ const answers = await assertScanTargetAllowed(host);
920
1087
 
921
1088
  // `manifest` classifies every selected plugin as ran|skipped|timeout|error with a reason.
922
1089
  // Discarding it is what makes "no findings" ambiguous between a clean estate and one whose
923
1090
  // plugins never loaded — see utils/run_record.mjs.
924
- const { results, conclusion, manifest: pluginStatus = [] } = await pm.run(host, plugins || 'all', opts);
1091
+ // R1b: the plugins connect to the addresses the check resolved (utils/scan_pin.mjs), never to a second answer.
1092
+ const { results, conclusion, manifest: pluginStatus = [] } = await runPinned(answers ? { [host]: answers } : null,
1093
+ () => pm.run(host, plugins || 'all', opts));
925
1094
 
926
1095
  // Enrich conclusion with MITRE ATT&CK technique mapping
927
1096
  const techniques = getAllTechniques(conclusion);
@@ -1012,6 +1181,18 @@ export async function scanSingleHost(pm, host, plugins, opts, promptMode) {
1012
1181
  // delta as findings RESOLVED — measured on a live run, three such rows.
1013
1182
  pluginStatus,
1014
1183
  onWarn: (msg) => console.warn(`[EE] ${msg}`),
1184
+ // EE 2.0.0 (the drift file). The run id and the root the run records are written under (the SAME resolver
1185
+ // `appendHostWritten` below uses), so EE compares with the run `report --since prior` names; and `watch`, true
1186
+ // only from the watch loop, where EE writes no drift file. Pinned by tests/ee_drift_forwarding.test.mjs.
1187
+ runId: opts?.runId ?? null,
1188
+ runRecordRoot: resolveBaseOutDir(),
1189
+ watch: opts?.watch === true,
1190
+ // EE 2.0.0 (cargo 3, D3/D4): the opt-in drift notification, forwarded verbatim; EE validates every value again
1191
+ // and sends only on a compared drift that crossed a trigger. Pinned by tests/cli_notify_flags.test.mjs.
1192
+ notifyWebhook: opts?.notifyWebhook ?? null,
1193
+ notifyFormat: opts?.notifyFormat ?? null,
1194
+ notifySeverity: opts?.notifySeverity ?? null,
1195
+ notifyPayload: opts?.notifyPayload ?? null,
1015
1196
  }) : null;
1016
1197
  hostScopeScanned = eeEnrichment?.scopeScanned ?? null;
1017
1198
  // ⚠️ Read from `exploitIntel.stores`, NOT a top-level `exploit` key — measured against
@@ -1634,7 +1815,7 @@ export async function runReport(args, caps) {
1634
1815
 
1635
1816
  export async function main(testHooks = {}) {
1636
1817
  const args = await parseArgs(process.argv);
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;
1818
+ 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;
1638
1819
 
1639
1820
  // Version: handled before license verification so it works without a key.
1640
1821
  // CE-0.1.30.1 — closes the discovery-flag UX gap where pre-fix
@@ -1673,7 +1854,7 @@ Scan options:
1673
1854
  region-scoped checks use AWS_REGION, else one region (with an
1674
1855
  incomplete-coverage notice); CloudTrail trail discovery,
1675
1856
  GuardDuty/Inspector and EC2 instances attempt every region.
1676
- --plugins <list|all> Plugins to run (e.g. 001,003,020 or "all"; default: all)
1857
+ --plugins <list|all> Plugins to run (e.g. 001,003,011 or "all"; default: all)
1677
1858
  --ports <list> Ports ADDED to the default set (e.g. 22,80,443 or 161/udp;
1678
1859
  a range such as 1-1000 is not parsed and adds nothing)
1679
1860
  --out <dir> Output directory for scan artifacts
@@ -1685,11 +1866,11 @@ Scan options:
1685
1866
  040 / 050 / 060 audits (060: the domain's SPF / DMARC /
1686
1867
  NS), and Dangerous HTTP methods (medium) only where an
1687
1868
  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.
1869
+ null session only with FTP_CHECK_ANON / DNS_CHECK_AXFR
1870
+ or DNS_AUDIT_AXFR_CHECK (060) / SMB_NULL_SESSION set;
1871
+ not tested never trips it; any concluded scan is info.
1872
+ Not CVEs or agent findings — exit 0 is not a clean host.
1873
+ Exit 2: unknown severity or no conclusion.
1693
1874
  --output-format <fmt> Additional report format: sarif | csv | md
1694
1875
  --insecure-https Skip TLS validation on probed HTTPS targets
1695
1876
  --watch CTEM continuous ALERTING mode: re-scan on --interval and
@@ -1709,10 +1890,21 @@ Scan options:
1709
1890
  one-shot --host X,X scans X twice.
1710
1891
  --interval <minutes> Watch interval (default 60)
1711
1892
  --webhook-url <url> Send --watch alerts (must be public; private/loopback blocked)
1712
- --alert-severity <sev> Min severity to alert on (default: high)
1893
+ --alert-severity <sev> Min severity to alert on: critical|high|medium|low|info (default: high)
1713
1894
  --alert-every-cycle Alert every host with a finding at or above --alert-severity
1714
1895
  on every --watch cycle, the first included (default: only
1715
1896
  hosts whose scan changed)
1897
+ --alert-payload <mode> full (default) | minimal — minimal drops the host, service and
1898
+ description from each --watch alert
1899
+ --notify-webhook <url> Enterprise, one-shot scans with --compliance only (never --watch):
1900
+ POST the scan's drift (scan_drift_<fw>.json) when a control goes
1901
+ to fail, a control loses measurement, or a new violation is at or
1902
+ above --notify-severity. Public https URLs only; off by default
1903
+ --notify-format <fmt> generic (default) | slack | teams (a Teams Workflows webhook)
1904
+ --notify-severity <sev> critical | high (default) | medium | low | info — for new
1905
+ violations only
1906
+ --notify-payload <mode> minimal (default: no hostnames, resources, account ids or
1907
+ finding text) | full
1716
1908
  --compliance <framework> Map findings to controls. 'all' = all 8 frameworks, or a CSV of
1717
1909
  soc2,hipaa,nist-csf,pci-dss,iso-27001,cis-v8,gdpr,nist-800-171
1718
1910
  (aliases nist/pci/iso/cis, and 800-171 or cmmc for nist-800-171 --
@@ -1821,7 +2013,9 @@ Environment:
1821
2013
  NSA_MCP_AUTH_KEY MCP server auth key — read by mcp_server at startup;
1822
2014
  client supplies via Claude Desktop config env block
1823
2015
  NSA_MCP_AUTH_DISABLE=1 Skip MCP auth check (CI/dev escape hatch — emits warn)
1824
- NSA_ALLOW_ALL_HOSTS=1 Permit RFC1918 / loopback (local-network auditing)
2016
+ NSA_ALLOW_ALL_HOSTS=1 Local-network auditing. CLI: lifts the whole scan-entry
2017
+ guard, loopback and metadata included. MCP server:
2018
+ admits RFC 1918 / CGNAT / fc00::/7 only
1825
2019
  CLOUD_PROVIDER=aws|gcp|azure Required for cloud scanner plugins (020/021/022/023/030)
1826
2020
  AI_PROVIDER=openai|claude|ollama AI provider for report generation
1827
2021
  NSAUDITOR_TSA_URL RFC 3161 timestamp authority for compliance attestation.
@@ -2709,11 +2903,19 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
2709
2903
  const { readFile, stat } = await import('node:fs/promises');
2710
2904
  const { join: _join } = await import('node:path');
2711
2905
  const { homedir: _homedir } = await import('node:os');
2712
- const { responseDigest, bodyOf } = await import('./utils/mcp_call_digest.mjs');
2906
+ const { responseDigest, requestDigest, bodyOf } = await import('./utils/mcp_call_digest.mjs');
2713
2907
  const logPath = _join(_homedir(), '.nsauditor', 'mcp-calls.log');
2714
2908
  const uuid = rawArgs[2];
2715
2909
  const respAt = rawArgs.indexOf('--response');
2716
2910
  const respPath = respAt > 0 ? rawArgs[respAt + 1] : null;
2911
+ // B63 (2.1.0): the request saved beside the response (<call_id>.request.json), checked against the digest the
2912
+ // server logged of that file's bytes. Optional; absent, every output below is what it was.
2913
+ const reqAt = rawArgs.indexOf('--request');
2914
+ const reqPath = reqAt > 0 ? rawArgs[reqAt + 1] : null;
2915
+ if (reqAt > 0 && !reqPath) {
2916
+ console.error('Usage: nsauditor-ai mcp verify-call <uuid> --response <file|-> --request <file>');
2917
+ process.exit(2);
2918
+ }
2717
2919
  if (!uuid || (respAt > 0 && !respPath)) {
2718
2920
  console.error('Usage: nsauditor-ai mcp verify-call <uuid> --response <file|->');
2719
2921
  console.error(' Save the whole tool response (footer included) to a file, or pipe it in with --response -.');
@@ -2740,6 +2942,13 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
2740
2942
  process.exit(2);
2741
2943
  }
2742
2944
  }
2945
+ let reqBytes = null;
2946
+ if (reqPath) {
2947
+ try { reqBytes = await readFile(reqPath); } catch (err) {
2948
+ console.error(`✗ Could not read the request from ${reqPath}: ${err.message}`);
2949
+ process.exit(2);
2950
+ }
2951
+ }
2743
2952
  let logExists = false;
2744
2953
  try { await stat(logPath); logExists = true; } catch { /* missing */ }
2745
2954
  if (!logExists) {
@@ -2774,28 +2983,48 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
2774
2983
  })();
2775
2984
  const issuedLine = `${issued.tool ?? 'an unrecorded tool'}, issued ${ago} (${issued.ts ?? 'no timestamp'})`;
2776
2985
  const digests = entries.map((e) => e.digest).filter((d) => typeof d === 'string');
2986
+ // With --request, the request is checked after the response and the exit is the worse of the two: any refutation
2987
+ // is 1, otherwise anything indeterminate is 3. Without it, each exit below is exactly what it was.
2988
+ const finish = (code) => {
2989
+ if (reqBytes == null) process.exit(code);
2990
+ const recorded = entries.map((e) => e.request_digest).filter((d) => typeof d === 'string');
2991
+ let reqCode;
2992
+ if (recorded.length === 0) {
2993
+ console.log(`? The request is not bound: this server recorded no request digest for call_id ${uuid}.`);
2994
+ console.log(' The call was not saved with NSA_MCP_RESPONSE_DIR set, or it predates request binding.');
2995
+ reqCode = 3;
2996
+ } else if (recorded.includes(requestDigest(reqBytes))) {
2997
+ console.log(`✓ This request is the one the server recorded for that call — ${issuedLine}.`);
2998
+ reqCode = 0;
2999
+ } else {
3000
+ console.log(`✗ The request does not match what this server recorded for that call (${issuedLine}).`);
3001
+ console.log(' It was changed after the server wrote it, or it belongs to another call.');
3002
+ reqCode = 1;
3003
+ }
3004
+ process.exit(code === 1 || reqCode === 1 ? 1 : (code === 3 || reqCode === 3 ? 3 : 0));
3005
+ };
2777
3006
  if (digests.length === 0) {
2778
3007
  console.log(`? call_id ${uuid} was issued by this server for ${issued.tool ?? 'an unrecorded tool'} ${ago} (${issued.ts ?? 'no timestamp'}).`);
2779
3008
  console.log(' This call predates response binding: no digest of its response was recorded, so the text');
2780
3009
  console.log(' cannot be bound to it. The id alone does not verify a response.');
2781
- process.exit(3);
3010
+ finish(3);
2782
3011
  }
2783
3012
  if (responseText == null) {
2784
3013
  console.log(`? call_id ${uuid} was issued by this server for ${issued.tool ?? 'an unrecorded tool'} ${ago} (${issued.ts ?? 'no timestamp'}).`);
2785
3014
  console.log(' That alone does not verify the text you are reading: an id from an earlier response can be');
2786
3015
  console.log(' pasted under a fabricated one. Save the raw tool response and run:');
2787
3016
  console.log(` nsauditor-ai mcp verify-call ${uuid} --response <file>`);
2788
- process.exit(3);
3017
+ finish(3);
2789
3018
  }
2790
3019
  if (digests.includes(responseDigest(bodyOf(responseText)))) {
2791
3020
  console.log(`✓ This response text was produced by this server for that call — ${issuedLine}.`);
2792
3021
  console.log(` log: ${logPath}`);
2793
- process.exit(0);
3022
+ finish(0);
2794
3023
  }
2795
3024
  console.log(`✗ The text does not match what this server sent for that call (${issuedLine}).`);
2796
3025
  console.log(' Either it was altered after copying (rendering, re-wrapping) or it was not produced by this');
2797
3026
  console.log(' call — re-copy the RAW tool output and verify again.');
2798
- process.exit(1);
3027
+ finish(1);
2799
3028
  } else {
2800
3029
  console.log('Usage:');
2801
3030
  console.log(' nsauditor-ai mcp install-key Generate a new key, persist, print Claude config');
@@ -2805,6 +3034,7 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
2805
3034
  console.log(' nsauditor-ai mcp status Show storage source without revealing the key');
2806
3035
  console.log(' nsauditor-ai mcp tier Print actual MCP server tier (ground truth, bypasses Claude AI synthesis)');
2807
3036
  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');
3037
+ console.log(' [--request <file>] …and that a saved request (<call_id>.request.json) is the one it recorded');
2808
3038
  console.log('');
2809
3039
  console.log('Environment variables:');
2810
3040
  console.log(` ${MCP_AUTH_ENV_VAR} Read by mcp_server.mjs at startup; client supplies via Claude config`);
@@ -3234,15 +3464,29 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
3234
3464
  opts.complianceTrackSla = true;
3235
3465
  }
3236
3466
  if (awsRegionIntent) opts.awsRegionIntent = awsRegionIntent;
3467
+ // EE 2.0.0: the drift notification needs a framework to drift — refused here, where --env has already loaded.
3468
+ const notifyRefused = notifyRefusal({ notifyWebhook, compliance, envCompliance: process.env.COMPLIANCE_FRAMEWORKS });
3469
+ if (notifyRefused) {
3470
+ console.error(notifyRefused.message);
3471
+ process.exit(notifyRefused.code);
3472
+ }
3473
+ if (notifyWebhook) Object.assign(opts, { notifyWebhook, notifyFormat, notifySeverity, notifyPayload });
3237
3474
  const pm = await PluginManager.create(`${__dirname}/plugins`);
3475
+ // Before the watch loop, the run record or any host: a --plugins list that names no plugin is refused by name.
3476
+ const selectionRefused = pluginSelectionRefusal(plugins, pm);
3477
+ if (selectionRefused) throw selectionRefused;
3238
3478
  const promptMode = String(process.env.OPENAI_PROMPT_MODE || 'basic').toLowerCase().trim();
3239
3479
 
3240
3480
  // --- CTEM: continuous watch mode ---
3241
3481
  if (watch) {
3242
3482
  const intervalMs = intervalMinutes * 60 * 1000;
3243
3483
  let previousCycleResults = null;
3484
+ // 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
3485
+ // drift file is the one-shot path's; the operator's ruling). `opts.runId` is already unset here.
3486
+ opts.watch = true;
3244
3487
 
3245
- const scheduler = createScheduler({
3488
+ // `testHooks._createScheduler`: a test seam, absent in every real invocation — it lets a leg run one watch cycle.
3489
+ const scheduler = (testHooks?._createScheduler ?? createScheduler)({
3246
3490
  intervalMs,
3247
3491
  hosts,
3248
3492
  parallel,
@@ -3262,8 +3506,9 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
3262
3506
  console.log(text ?? '[CTEM] First cycle complete — it establishes the baseline. Delta reporting begins on the next cycle.');
3263
3507
  if (webhookUrl) {
3264
3508
  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 });
3509
+ const payload = buildAlertPayload(h, findings, alertSeverity, { payload: alertPayload });
3510
+ // `testHooks._sendWebhook`: a test seam, absent in every real invocation.
3511
+ const webhookResult = await (testHooks?._sendWebhook ?? sendWebhook)(webhookUrl, payload, { retries: 2, retryDelayMs: 1000 });
3267
3512
  if (webhookResult.success) {
3268
3513
  console.log(`[CTEM] Webhook alert sent for ${h}`);
3269
3514
  } else {
@@ -3290,7 +3535,8 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
3290
3535
  const dropped = scheduler.duplicatesDropped
3291
3536
  ? ` (${scheduler.duplicatesDropped} duplicate host(s) dropped — watch mode scans each host once per cycle)` : '';
3292
3537
  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 `
3538
+ // The HOST only: a Slack or Teams webhook URL carries its own credential in the path or the query (EE 2.0.0).
3539
+ if (webhookUrl) console.log(`[CTEM] Webhook: ${new URL(webhookUrl).host}, Alert severity: ${alertSeverity}, alerting `
3294
3540
  + (alertEveryCycle ? 'every host with such a finding, every cycle' : 'hosts whose scan changed (the first cycle sets the baseline)'));
3295
3541
 
3296
3542
  scheduler.start();
@@ -3537,6 +3783,48 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
3537
3783
  }
3538
3784
  }
3539
3785
 
3786
+ /**
3787
+ * An explicit --plugins list, resolved against the loaded plugins before anything is scanned or written: a token that names
3788
+ * 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
3789
+ * that resolved to nothing ran ZERO plugins — exit 0, "Security findings: 0", and with --compliance every in-scope control
3790
+ * PASS at zero evidence. `all` must stand alone: inside a list it was dropped too, silently running only the rest.
3791
+ * Returns an EFLAGVALUE error (exit 2 through exitOnMainRejection), or null when the selection stands.
3792
+ * @param {string[]|string|undefined} plugins parseArgs' `plugins`: 'all', or the list's trimmed tokens
3793
+ * @param {{ findPlugin(name: string): object|null }} pm
3794
+ */
3795
+ export function pluginSelectionRefusal(plugins, pm) {
3796
+ if (!Array.isArray(plugins)) return null;
3797
+ const refuse = (msg) => Object.assign(new Error(msg), { code: 'EFLAGVALUE' });
3798
+ if (plugins.length === 0) return refuse('--plugins named no plugin — give plugin ids or names, or all');
3799
+ const quote = (t) => JSON.stringify(t);
3800
+ const withAll = plugins.some((t) => t.toLowerCase() === 'all');
3801
+ const unknown = plugins.filter((t) => t.toLowerCase() !== 'all' && !pm.findPlugin(t));
3802
+ if (unknown.length) {
3803
+ return refuse(`--plugins: no plugin matches ${unknown.map(quote).join(', ')}${withAll ? '; "all" must be given alone' : ''}`
3804
+ + ' — name each plugin by its id or name (nsauditor-ai license --plugins lists them)');
3805
+ }
3806
+ if (withAll) return refuse('--plugins: "all" must be given alone — it already selects every plugin');
3807
+ return null;
3808
+ }
3809
+
3810
+ /**
3811
+ * What a rejected main() exits with — the ONE copy of this mapping. Both entry points call it: the block below when
3812
+ * cli.mjs is run directly, and bin/nsauditor-ai.mjs, the command npm installs (which imports main(), so cli.mjs is never
3813
+ * the entry point for an installed user). A flag refusal is an operator error, not a crash: name it and exit 2, this
3814
+ * CLI's "could not do what you asked" code. Everything else keeps the stack and exit 1. `error` and `exit` are seams for
3815
+ * the suite (tests/cli_bin_entrypoint.test.mjs).
3816
+ */
3817
+ export function exitOnMainRejection(err, { error = console.error, exit = process.exit } = {}) {
3818
+ // A refused scan target (ETARGETREFUSED) is the same class: the operator asked for something the guard does not allow —
3819
+ // and so is one that does not resolve (ETARGETUNRESOLVED): nothing can be scanned at a name with no address.
3820
+ if (err?.code === 'EFLAGSHAPE' || err?.code === 'EFLAGVALUE' || err?.code === 'ETARGETREFUSED' || err?.code === 'ETARGETUNRESOLVED') {
3821
+ error(`[ERROR] ${err.message}`);
3822
+ return exit(2);
3823
+ }
3824
+ error(err?.stack || err);
3825
+ return exit(1);
3826
+ }
3827
+
3540
3828
  // Only auto-run main() when this file is the process entrypoint. Importing
3541
3829
  // cli.mjs (e.g. unit tests pulling in `parseArgs`) must NOT trigger a scan.
3542
3830
  const _isEntrypoint = (() => {
@@ -3547,14 +3835,5 @@ const _isEntrypoint = (() => {
3547
3835
  }
3548
3836
  })();
3549
3837
  if (_isEntrypoint) {
3550
- main().catch((err) => {
3551
- // A flag-shape refusal is an operator error, not a crash: name it and exit 2, this CLI's
3552
- // "could not do what you asked" code. Everything else keeps the stack and exit 1.
3553
- if (err?.code === 'EFLAGSHAPE') {
3554
- console.error(`[ERROR] ${err.message}`);
3555
- process.exit(2);
3556
- }
3557
- console.error(err?.stack || err);
3558
- process.exit(1);
3559
- });
3838
+ main().catch((err) => exitOnMainRejection(err));
3560
3839
  }