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/README.md +39 -32
- package/bin/nsauditor-ai.mjs +4 -2
- package/cli.mjs +320 -119
- package/mcp_server.mjs +37 -16
- package/package.json +2 -2
- package/plugin_manager.mjs +42 -2
- package/plugins/040_tls_cert_auditor.mjs +77 -12
- package/plugins/060_dns_sec_auditor.mjs +6 -2
- package/plugins/host_up_check.mjs +5 -1
- package/plugins/http_probe.mjs +20 -2
- package/plugins/ping_checker.mjs +10 -4
- package/plugins/result_concluder.mjs +46 -4
- package/plugins/syn_scanner.mjs +4 -1
- package/plugins/tls_scanner.mjs +29 -2
- package/plugins/webapp_detector.mjs +14 -8
- package/utils/capabilities.mjs +2 -2
- package/utils/conclusion_utils.mjs +26 -0
- package/utils/net_validation.mjs +25 -8
- package/utils/plugin_discovery.mjs +6 -2
- package/utils/scan_pin.mjs +143 -0
- package/utils/service_flags.mjs +1 -1
- package/utils/validate.mjs +1 -1
- package/utils/webhook.mjs +117 -17
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,
|
|
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 =
|
|
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
|
-
|
|
264
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
1690
|
-
|
|
1691
|
-
exit 0 is not a clean host.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|