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