nsauditor-ai 0.2.57 → 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.
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
@@ -726,6 +788,49 @@ export async function parseArgs(argv) {
726
788
  // R4: by default a watch cycle alerts only hosts whose scan CHANGED; this restores an alert on every cycle.
727
789
  args.alertEveryCycle = !!(get('alert-every-cycle') || get('alert_every_cycle'));
728
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
+ }
833
+
729
834
  // Compliance: framework selector + scope file. Forwarded to EE's
730
835
  // runCompliancePhase via enrichScan(). No-op without an EE Enterprise license.
731
836
  const complianceVal = get('compliance');
@@ -865,6 +970,16 @@ export async function parseArgs(argv) {
865
970
  * @param {{ cmd?: string, host?: string|true, hostFile?: string }} args parseArgs' result
866
971
  * @returns {{ message: string, code: number }|null}
867
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
+
868
983
  export function scanTargetRefusal({ cmd, host, hostFile } = {}) {
869
984
  if (cmd !== 'scan') return { message: `Unknown command: ${cmd}`, code: 2 };
870
985
  if (!hostFile && !host) return { message: 'Fatal: --host or --host-file is required', code: 2 };
@@ -894,34 +1009,48 @@ const CLOUD_SENTINEL_HOSTS = new Set(['aws', 'gcp', 'azure']);
894
1009
  * that is not a literal is resolved with EVERY answer checked. Before 1.3.0 a digits-and-dots string
895
1010
  * skipped resolution (/^[\\d.:[\\]]+$/), so '6425673729' — which the OS reads as 127.0.0.1 — was
896
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).
897
1017
  * @param {string} host
898
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
899
1020
  */
900
1021
  export async function assertScanTargetAllowed(host, env = process.env) {
901
1022
  const isCloudSentinel = typeof host === 'string' && CLOUD_SENTINEL_HOSTS.has(host.toLowerCase());
902
- if (isCloudSentinel || allowAllHosts(env)) return;
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
+ }
903
1028
  if (isBlockedIp(host)) {
904
1029
  throw new Error(`Scanning blocked address range is not allowed: ${host}`);
905
1030
  }
906
1031
  if (!canonicalIp(host)) {
907
1032
  try {
908
- await resolveAndValidate(host);
1033
+ return await resolveAndValidateAll(host);
909
1034
  } catch (err) {
910
1035
  throw new Error(`Host rejected by SSRF guard: ${err.message}`);
911
1036
  }
912
1037
  }
1038
+ return null;
913
1039
  }
914
1040
 
915
1041
  export async function scanSingleHost(pm, host, plugins, opts, promptMode) {
916
1042
  // 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).
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).
918
1045
  // Cloud-sentinel hosts (see CLOUD_SENTINEL_HOSTS above) skip the guard.
919
- await assertScanTargetAllowed(host);
1046
+ const answers = await assertScanTargetAllowed(host);
920
1047
 
921
1048
  // `manifest` classifies every selected plugin as ran|skipped|timeout|error with a reason.
922
1049
  // Discarding it is what makes "no findings" ambiguous between a clean estate and one whose
923
1050
  // plugins never loaded — see utils/run_record.mjs.
924
- 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));
925
1054
 
926
1055
  // Enrich conclusion with MITRE ATT&CK technique mapping
927
1056
  const techniques = getAllTechniques(conclusion);
@@ -1012,6 +1141,18 @@ export async function scanSingleHost(pm, host, plugins, opts, promptMode) {
1012
1141
  // delta as findings RESOLVED — measured on a live run, three such rows.
1013
1142
  pluginStatus,
1014
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,
1015
1156
  }) : null;
1016
1157
  hostScopeScanned = eeEnrichment?.scopeScanned ?? null;
1017
1158
  // ⚠️ Read from `exploitIntel.stores`, NOT a top-level `exploit` key — measured against
@@ -1634,7 +1775,7 @@ export async function runReport(args, caps) {
1634
1775
 
1635
1776
  export async function main(testHooks = {}) {
1636
1777
  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;
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;
1638
1779
 
1639
1780
  // Version: handled before license verification so it works without a key.
1640
1781
  // CE-0.1.30.1 — closes the discovery-flag UX gap where pre-fix
@@ -1673,7 +1814,7 @@ Scan options:
1673
1814
  region-scoped checks use AWS_REGION, else one region (with an
1674
1815
  incomplete-coverage notice); CloudTrail trail discovery,
1675
1816
  GuardDuty/Inspector and EC2 instances attempt every region.
1676
- --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)
1677
1818
  --ports <list> Ports ADDED to the default set (e.g. 22,80,443 or 161/udp;
1678
1819
  a range such as 1-1000 is not parsed and adds nothing)
1679
1820
  --out <dir> Output directory for scan artifacts
@@ -1685,11 +1826,11 @@ Scan options:
1685
1826
  040 / 050 / 060 audits (060: the domain's SPF / DMARC /
1686
1827
  NS), and Dangerous HTTP methods (medium) only where an
1687
1828
  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.
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.
1693
1834
  --output-format <fmt> Additional report format: sarif | csv | md
1694
1835
  --insecure-https Skip TLS validation on probed HTTPS targets
1695
1836
  --watch CTEM continuous ALERTING mode: re-scan on --interval and
@@ -1713,6 +1854,17 @@ Scan options:
1713
1854
  --alert-every-cycle Alert every host with a finding at or above --alert-severity
1714
1855
  on every --watch cycle, the first included (default: only
1715
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
1716
1868
  --compliance <framework> Map findings to controls. 'all' = all 8 frameworks, or a CSV of
1717
1869
  soc2,hipaa,nist-csf,pci-dss,iso-27001,cis-v8,gdpr,nist-800-171
1718
1870
  (aliases nist/pci/iso/cis, and 800-171 or cmmc for nist-800-171 --
@@ -1821,7 +1973,9 @@ Environment:
1821
1973
  NSA_MCP_AUTH_KEY MCP server auth key — read by mcp_server at startup;
1822
1974
  client supplies via Claude Desktop config env block
1823
1975
  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)
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
1825
1979
  CLOUD_PROVIDER=aws|gcp|azure Required for cloud scanner plugins (020/021/022/023/030)
1826
1980
  AI_PROVIDER=openai|claude|ollama AI provider for report generation
1827
1981
  NSAUDITOR_TSA_URL RFC 3161 timestamp authority for compliance attestation.
@@ -3234,15 +3388,29 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
3234
3388
  opts.complianceTrackSla = true;
3235
3389
  }
3236
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 });
3237
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;
3238
3402
  const promptMode = String(process.env.OPENAI_PROMPT_MODE || 'basic').toLowerCase().trim();
3239
3403
 
3240
3404
  // --- CTEM: continuous watch mode ---
3241
3405
  if (watch) {
3242
3406
  const intervalMs = intervalMinutes * 60 * 1000;
3243
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;
3244
3411
 
3245
- 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)({
3246
3414
  intervalMs,
3247
3415
  hosts,
3248
3416
  parallel,
@@ -3262,8 +3430,9 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
3262
3430
  console.log(text ?? '[CTEM] First cycle complete — it establishes the baseline. Delta reporting begins on the next cycle.');
3263
3431
  if (webhookUrl) {
3264
3432
  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 });
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 });
3267
3436
  if (webhookResult.success) {
3268
3437
  console.log(`[CTEM] Webhook alert sent for ${h}`);
3269
3438
  } else {
@@ -3290,7 +3459,8 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
3290
3459
  const dropped = scheduler.duplicatesDropped
3291
3460
  ? ` (${scheduler.duplicatesDropped} duplicate host(s) dropped — watch mode scans each host once per cycle)` : '';
3292
3461
  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 `
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 `
3294
3464
  + (alertEveryCycle ? 'every host with such a finding, every cycle' : 'hosts whose scan changed (the first cycle sets the baseline)'));
3295
3465
 
3296
3466
  scheduler.start();
@@ -3537,6 +3707,46 @@ Docs: https://www.nsauditor.com/ai/ | Pricing: https://www.nsauditor.com/ai/
3537
3707
  }
3538
3708
  }
3539
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
+
3540
3750
  // Only auto-run main() when this file is the process entrypoint. Importing
3541
3751
  // cli.mjs (e.g. unit tests pulling in `parseArgs`) must NOT trigger a scan.
3542
3752
  const _isEntrypoint = (() => {
@@ -3547,14 +3757,5 @@ const _isEntrypoint = (() => {
3547
3757
  }
3548
3758
  })();
3549
3759
  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
- });
3760
+ main().catch((err) => exitOnMainRejection(err));
3560
3761
  }