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/README.md +54 -35
- package/bin/nsauditor-ai.mjs +4 -2
- package/cli.mjs +410 -131
- package/mcp_server.mjs +286 -40
- package/package.json +7 -3
- package/plugin_manager.mjs +92 -7
- package/plugins/040_tls_cert_auditor.mjs +77 -12
- package/plugins/060_dns_sec_auditor.mjs +382 -117
- package/plugins/arp_scanner.mjs +6 -3
- package/plugins/host_up_check.mjs +5 -1
- package/plugins/http_probe.mjs +20 -2
- package/plugins/mcp_scanner.mjs +10 -1
- package/plugins/mdns_scanner.mjs +11 -5
- package/plugins/ping_checker.mjs +14 -7
- package/plugins/result_concluder.mjs +61 -5
- package/plugins/syn_scanner.mjs +4 -1
- package/plugins/tls_scanner.mjs +42 -2
- package/plugins/upnp_scanner.mjs +8 -2
- package/plugins/webapp_detector.mjs +22 -10
- package/plugins/wsd_scanner.mjs +8 -2
- package/utils/capabilities.mjs +2 -2
- package/utils/cloud_finding_summary.mjs +56 -6
- package/utils/conclusion_utils.mjs +26 -0
- package/utils/delta_reporter.mjs +1 -1
- package/utils/mcp_call_digest.mjs +6 -0
- package/utils/net_validation.mjs +26 -8
- package/utils/plugin_discovery.mjs +6 -2
- package/utils/report_inputs.mjs +8 -0
- package/utils/report_md.mjs +18 -0
- package/utils/scan_delta.mjs +72 -10
- package/utils/scan_history.mjs +24 -9
- package/utils/scan_pin.mjs +161 -0
- package/utils/service_flags.mjs +41 -6
- package/utils/validate.mjs +1 -1
- package/utils/webhook.mjs +126 -21
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
|
|
@@ -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
|
-
|
|
718
|
-
|
|
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
|
-
|
|
725
|
-
|
|
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
|
|
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
|
|
1068
|
+
throw refuse(`Scanning blocked address range is not allowed: ${host}`);
|
|
905
1069
|
}
|
|
906
1070
|
if (!canonicalIp(host)) {
|
|
907
1071
|
try {
|
|
908
|
-
await
|
|
1072
|
+
return await resolveAndValidateAll(host);
|
|
909
1073
|
} catch (err) {
|
|
910
|
-
throw
|
|
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
|
-
//
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
1690
|
-
|
|
1691
|
-
exit 0 is not a clean host.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|