nsauditor-ai 0.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.
Files changed (60) hide show
  1. package/CONTRIBUTING.md +24 -0
  2. package/LICENSE +21 -0
  3. package/README.md +584 -0
  4. package/bin/nsauditor-ai-mcp.mjs +2 -0
  5. package/bin/nsauditor-ai.mjs +2 -0
  6. package/cli.mjs +939 -0
  7. package/config/services.json +304 -0
  8. package/docs/EULA-nsauditor-ai.md +324 -0
  9. package/index.mjs +15 -0
  10. package/mcp_server.mjs +382 -0
  11. package/package.json +44 -0
  12. package/plugin_manager.mjs +829 -0
  13. package/plugins/arp_scanner.mjs +162 -0
  14. package/plugins/db_scanner.mjs +248 -0
  15. package/plugins/dns_scanner.mjs +369 -0
  16. package/plugins/dnssd-scanner.mjs +245 -0
  17. package/plugins/ftp_banner_check.mjs +247 -0
  18. package/plugins/host_up_check.mjs +337 -0
  19. package/plugins/http_probe.mjs +290 -0
  20. package/plugins/llmnr_scanner.mjs +130 -0
  21. package/plugins/mdns_scanner.mjs +522 -0
  22. package/plugins/netbios_scanner.mjs +737 -0
  23. package/plugins/opensearch_scanner.mjs +276 -0
  24. package/plugins/os_detector.mjs +436 -0
  25. package/plugins/ping_checker.mjs +271 -0
  26. package/plugins/port_scanner.mjs +250 -0
  27. package/plugins/result_concluder.mjs +274 -0
  28. package/plugins/snmp_scanner.mjs +278 -0
  29. package/plugins/ssh_scanner.mjs +421 -0
  30. package/plugins/sunrpc_scanner.mjs +339 -0
  31. package/plugins/syn_scanner.mjs +314 -0
  32. package/plugins/tls_scanner.mjs +225 -0
  33. package/plugins/upnp_scanner.mjs +441 -0
  34. package/plugins/webapp_detector.mjs +246 -0
  35. package/plugins/wsd_scanner.mjs +290 -0
  36. package/utils/attack_map.mjs +180 -0
  37. package/utils/capabilities.mjs +53 -0
  38. package/utils/conclusion_utils.mjs +70 -0
  39. package/utils/cpe.mjs +74 -0
  40. package/utils/cve_validator.mjs +64 -0
  41. package/utils/cvss.mjs +129 -0
  42. package/utils/delta_reporter.mjs +110 -0
  43. package/utils/export_csv.mjs +82 -0
  44. package/utils/finding_queue.mjs +64 -0
  45. package/utils/finding_schema.mjs +36 -0
  46. package/utils/host_iterator.mjs +166 -0
  47. package/utils/license.mjs +29 -0
  48. package/utils/net_validation.mjs +66 -0
  49. package/utils/nvd_cache.mjs +77 -0
  50. package/utils/nvd_client.mjs +130 -0
  51. package/utils/oui.mjs +107 -0
  52. package/utils/plugin_discovery.mjs +89 -0
  53. package/utils/prompts.mjs +143 -0
  54. package/utils/raw_report_html.mjs +170 -0
  55. package/utils/redact.mjs +79 -0
  56. package/utils/report_html.mjs +236 -0
  57. package/utils/sarif.mjs +225 -0
  58. package/utils/scan_history.mjs +248 -0
  59. package/utils/scheduler.mjs +157 -0
  60. package/utils/webhook.mjs +177 -0
package/cli.mjs ADDED
@@ -0,0 +1,939 @@
1
+ #!/usr/bin/env node
2
+ import 'dotenv/config';
3
+ import PluginManager from './plugin_manager.mjs';
4
+ import { buildHtmlReport } from './utils/report_html.mjs';
5
+ import fsp from 'node:fs/promises';
6
+ import path from 'node:path';
7
+ import { openaiSimplePrompt, openaiPrompt as openaiProPrompt, openaiPromptOptimized } from './utils/prompts.mjs';
8
+ import { parseHostArg, parseHostFile } from './utils/host_iterator.mjs';
9
+ import { buildSarifLog } from './utils/sarif.mjs';
10
+ import { buildCsv } from './utils/export_csv.mjs';
11
+ import { recordScan, getLastScan, computeDiff, formatDiffReport, pruneForCE, HISTORY_FILE } from './utils/scan_history.mjs';
12
+ import { getTierFromEnv } from './utils/license.mjs';
13
+ import { resolveCapabilities, hasCapability } from './utils/capabilities.mjs';
14
+ import { createScheduler } from './utils/scheduler.mjs';
15
+ import { buildDeltaReport, formatDeltaSummary, hasSignificantChanges } from './utils/delta_reporter.mjs';
16
+ import { sendWebhook, buildAlertPayload, isSafeWebhookUrl } from './utils/webhook.mjs';
17
+ import { scrubByKey } from './utils/redact.mjs';
18
+ import { isBlockedIp, resolveAndValidate } from './utils/net_validation.mjs';
19
+ import { getAllTechniques } from './utils/attack_map.mjs';
20
+
21
+ /* ------------------------- helpers & utilities ------------------------- */
22
+
23
+ const parseBool = (val, def = false) => {
24
+ const s = String(val ?? '').trim().replace(/^['"]+|['"]+$/g, '').toLowerCase();
25
+ if (!s && def != null) return !!def;
26
+ return ['true', '1', 'yes', 'on', 'y'].includes(s);
27
+ };
28
+ const nowStamp = () => {
29
+ const d = new Date();
30
+ const pad = (n) => String(n).padStart(2, '0');
31
+ return (
32
+ d.getFullYear().toString() +
33
+ pad(d.getMonth() + 1) +
34
+ pad(d.getDate()) + '_' +
35
+ pad(d.getHours()) +
36
+ pad(d.getMinutes()) +
37
+ pad(d.getSeconds())
38
+ );
39
+ };
40
+ const safeHost = (h) => String(h ?? 'unknown').replace(/[\/\\?%*:|"<>]/g, '_');
41
+ const toCleanPath = (s) => String(s ?? '').trim().replace(/^['"]+|['"]+$/g, '');
42
+
43
+ /** Minimal redactor used if nothing external is provided. */
44
+ function redactSensitiveForAI(input, targetHost) {
45
+ const DROP_KEYS = new Set([
46
+ 'ip6', 'deviceWebPage', 'deviceWebPageInstruction',
47
+ 'hardwareVersion', 'firmwareVersion'
48
+ ]);
49
+ const SERIAL_KEY_RE = /^(serial(number)?|sn)$/i;
50
+ const isPrivateV4 = (ip) =>
51
+ /^10\./.test(ip) ||
52
+ /^172\.(1[6-9]|2\d|3[0-1])\./.test(ip) ||
53
+ /^192\.168\./.test(ip);
54
+
55
+ const scrubString = (str) => {
56
+ let s = String(str);
57
+ s = s.replace(/\bSerial\s*[:=]\s*[A-Za-z0-9._-]+/gi, 'Serial=[REDACTED_HIDDEN]');
58
+ s = s.replace(/\b(?:[0-9a-f]{2}:){5}[0-9a-f]{2}\b/gi, '[MAC]'); // MAC
59
+ s = s.replace(/\bfe80::[0-9a-f:]+\b/gi, '[FE80::/64]'); // IPv6 link-local
60
+ s = s.replace(/\b(?:[0-9a-f]{1,4}:){2,7}[0-9a-f]{1,4}\b/gi, '[IPv6]');
61
+ s = s.replace(/\b(?:(?:\d{1,3}\.){3}\d{1,3})\b/g, (ip) => (isPrivateV4(ip) ? ip : '[IP]'));
62
+ return s;
63
+ };
64
+
65
+ const walk = (val, key = '') => {
66
+ if (Array.isArray(val)) return val.map((v) => walk(v));
67
+ if (val && typeof val === 'object') {
68
+ const out = {};
69
+ for (const [k, v] of Object.entries(val)) {
70
+ if (DROP_KEYS.has(k)) continue;
71
+ if (SERIAL_KEY_RE.test(k)) { out[k] = '[REDACTED_HIDDEN]'; continue; }
72
+ out[k] = walk(v, k);
73
+ }
74
+ return out;
75
+ }
76
+ if (typeof val === 'string') return scrubString(val);
77
+ return val;
78
+ };
79
+
80
+ return walk(input);
81
+ }
82
+
83
+ /* ------------------------- OpenAI & reporting -------------------------- */
84
+
85
+ async function maybeSendToOpenAI({ host, results, conclusion, promptMode = 'basic' }) {
86
+ // --- env & opts -----------------------------------------------------------
87
+ const sendEnabled = parseBool(process.env.AI_ENABLED);
88
+ const redactEnabled = parseBool(process.env.OPENAI_REDACT, true);
89
+ const aiProvider = (process.env.AI_PROVIDER || 'openai').toLowerCase().trim();
90
+ const model = aiProvider === 'claude'
91
+ ? toCleanPath(process.env.ANTHROPIC_MODEL || 'claude-sonnet-4-20250514')
92
+ : aiProvider === 'ollama'
93
+ ? toCleanPath(process.env.OLLAMA_MODEL || 'llama3')
94
+ : toCleanPath(process.env.OPENAI_MODEL || 'gpt-4o-mini');
95
+ const keyRaw = aiProvider === 'claude'
96
+ ? process.env.ANTHROPIC_API_KEY
97
+ : aiProvider === 'ollama'
98
+ ? 'ollama' // Ollama needs no real key; OpenAI SDK requires a non-empty string
99
+ : process.env.OPENAI_API_KEY;
100
+ const key = keyRaw ? String(keyRaw).trim() : null;
101
+
102
+ // Base output folder (directory ONLY; if a file path is given, take its dir)
103
+ const outHintRaw = toCleanPath(process.env.SCAN_OUT_PATH || process.env.OPENAI_OUT_PATH || 'out');
104
+ const parsedHint = path.parse(outHintRaw);
105
+ const baseOutDir = parsedHint.ext ? (parsedHint.dir || 'out') : (outHintRaw || 'out');
106
+
107
+ await fsp.mkdir(baseOutDir, { recursive: true });
108
+
109
+ // Per-scan folder
110
+ const ts = nowStamp();
111
+ const runDir = `${safeHost(host)}_${ts}`;
112
+ const outDir = path.join(baseOutDir, runDir);
113
+ await fsp.mkdir(outDir, { recursive: true });
114
+
115
+ // Paths (fixed names inside per-scan folder)
116
+ const adminRawPath = path.join(outDir, 'scan_conclusion_raw.json');
117
+ const adminHtmlPath = path.join(outDir, 'scan_conclusion_raw.html');
118
+ const aiPayloadPath = path.join(outDir, 'scan_response_ai_payload.json');
119
+ const aiResponsePath = path.join(outDir, 'scan_response_ai.json');
120
+ const aiTxtPath = path.join(outDir, 'scan_response_ai.txt');
121
+ const aiHtmlPath = path.join(outDir, 'scan_response_ai.html');
122
+ const aiErrPath = path.join(outDir, 'scan_response_ai_error.json');
123
+
124
+ // Ensure “Serial: …” appears in summary only if present
125
+ const ensureSerialInSummary = (srcSummary, serialText) => {
126
+ const s = String(srcSummary ?? '').trim();
127
+ if (!s) return `Serial: ${serialText}`;
128
+ if (/\bSerial\s*[:=]/i.test(s)) return s;
129
+ return `${s} Serial: ${serialText}`;
130
+ };
131
+
132
+ // Extract serial from conclusion/results/evidence
133
+ const findSerial = () => {
134
+ const direct = conclusion?.result?.serialNumber;
135
+ if (typeof direct === 'string' && direct.trim()) return direct.trim();
136
+
137
+ if (Array.isArray(results)) {
138
+ for (const r of results) {
139
+ const s = r?.result?.serialNumber;
140
+ if (typeof s === 'string' && s.trim()) return s.trim();
141
+ }
142
+ }
143
+
144
+ const scanText = (t) => {
145
+ if (!t) return null;
146
+ const m = String(t).match(/\bSerial\s*[:=]\s*([A-Za-z0-9._-]+)/i);
147
+ return m?.[1] ? m[1].trim() : null;
148
+ };
149
+ const ev = conclusion?.result?.evidence;
150
+ if (Array.isArray(ev)) {
151
+ for (const e of ev) {
152
+ const s1 = scanText(e?.banner);
153
+ if (s1) return s1;
154
+ const s2 = scanText(e?.info);
155
+ if (s2) return s2;
156
+ }
157
+ }
158
+
159
+ const svcs = conclusion?.result?.services;
160
+ if (Array.isArray(svcs)) {
161
+ for (const s of svcs) {
162
+ const m = String(s?.banner ?? '').match(/\bSerial\s*[:=]\s*([A-Za-z0-9._-]+)/i);
163
+ if (m?.[1]) return m[1].trim();
164
+ }
165
+ }
166
+ return null;
167
+ };
168
+
169
+ // Basic pieces
170
+ const baseSummary = conclusion?.result?.summary ?? conclusion?.summary ?? null;
171
+ if (!baseSummary) {
172
+ console.warn('[OpenAI] No conclusion.summary available; skipping.');
173
+ return {
174
+ file_paths: { folder: outDir, plain: null, ai_json: null, raw_json: null, html: null, admin_html: null },
175
+ ai_conclusion: null
176
+ };
177
+ }
178
+
179
+ // Host OS hint for AI (if present)
180
+ const hostOsHint = conclusion?.result?.host?.os || conclusion?.host?.os || null;
181
+
182
+ // Compose summaries
183
+ const detectedSerial = findSerial();
184
+ const summaryWithFullSerial = detectedSerial ? ensureSerialInSummary(baseSummary, detectedSerial) : baseSummary;
185
+
186
+ // --- Admin RAW (unsanitized) JSON + Admin HTML ----------------------------
187
+ try {
188
+ const adminRaw = { host, summary: summaryWithFullSerial, results, conclusion };
189
+ await fsp.writeFile(adminRawPath, JSON.stringify(adminRaw, null, 2), 'utf8');
190
+ console.log('[OpenAI] Wrote admin RAW:', adminRawPath);
191
+
192
+ try {
193
+ const { buildAdminRawHtmlReport } = await import('./utils/raw_report_html.mjs');
194
+ const adminHtml = await buildAdminRawHtmlReport({
195
+ host,
196
+ whenIso: new Date().toISOString(),
197
+ summary: (conclusion?.result?.summary ?? summaryWithFullSerial) || '',
198
+ services: Array.isArray(conclusion?.result?.services) ? conclusion.result.services : [],
199
+ evidence: Array.isArray(conclusion?.result?.evidence) ? conclusion.result.evidence : []
200
+ });
201
+ await fsp.writeFile(adminHtmlPath, adminHtml, 'utf8');
202
+ console.log('[AdminHTML] Wrote Admin RAW HTML:', adminHtmlPath);
203
+ } catch (e) {
204
+ console.warn('[AdminHTML] Failed to write Admin RAW HTML:', e?.message || e);
205
+ }
206
+ } catch (e) {
207
+ console.warn('[OpenAI] Failed to write admin RAW:', e?.message || e);
208
+ }
209
+
210
+ // --- Build sanitized payload for AI ---------------------------------------
211
+ let payloadForAI = {
212
+ host,
213
+ host_os_hint: hostOsHint,
214
+ summary: summaryWithFullSerial, // include full; redactor will mask
215
+ services: conclusion?.result?.services ?? [],
216
+ evidence: conclusion?.result?.evidence ?? [],
217
+ _meta: {
218
+ resultsCount: Array.isArray(results) ? results.length : (results ? 1 : 0),
219
+ serialFound: !!detectedSerial
220
+ }
221
+ };
222
+
223
+ if (redactEnabled) {
224
+ let used = 'fallback';
225
+ try {
226
+ // Only allow external redaction override for Pro/Enterprise tiers.
227
+ // CE always uses the built-in redact pipeline to preserve the ZDE guarantee.
228
+ const redactCaps = resolveCapabilities(getTierFromEnv());
229
+ if (hasCapability(redactCaps, 'enhancedRedaction') && typeof globalThis.redactSensitiveForAI === 'function') {
230
+ let out = globalThis.redactSensitiveForAI(payloadForAI);
231
+ if (out && typeof out.then === 'function') out = await out;
232
+ if (typeof out === 'string') out = JSON.parse(out);
233
+ if (!out || typeof out !== 'object') throw new Error('external redactor returned non-object');
234
+ payloadForAI = out;
235
+ used = 'external';
236
+ } else {
237
+ payloadForAI = redactSensitiveForAI(payloadForAI, host);
238
+ }
239
+ } catch (e) {
240
+ console.warn('[OpenAI] Redaction failed, using fallback:', e?.message || e);
241
+ payloadForAI = redactSensitiveForAI(payloadForAI, host);
242
+ used = 'fallback';
243
+ }
244
+
245
+ // additional key-based scrubbing (CONFIDENTIAL_KEYWORDS=serial,password,token)
246
+ const keywords = String(process.env.CONFIDENTIAL_KEYWORDS || '')
247
+ .split(',')
248
+ .map((s) => s.trim().toLowerCase())
249
+ .filter(Boolean);
250
+ if (keywords.length) payloadForAI = scrubByKey(payloadForAI, keywords, '[REDACTED_HIDDEN]');
251
+
252
+ // Redact top-level host field (private IPs survive scrubString)
253
+ if (typeof payloadForAI.host === 'string') {
254
+ payloadForAI.host = '[REDACTED_HOST]';
255
+ }
256
+
257
+ // Redact any remaining IP addresses in the summary field
258
+ if (typeof payloadForAI.summary === 'string') {
259
+ payloadForAI.summary = payloadForAI.summary
260
+ .replace(/\b(?:(?:\d{1,3}\.){3}\d{1,3})\b/g, '[REDACTED_HOST]');
261
+ }
262
+
263
+ // Also redact private IPs in nested service/evidence strings
264
+ 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;
265
+ function scrubPrivateIps(obj) {
266
+ if (typeof obj === 'string') return obj.replace(privateIpRe, '[REDACTED_IP]');
267
+ if (Array.isArray(obj)) return obj.map(scrubPrivateIps);
268
+ if (obj && typeof obj === 'object') {
269
+ const out = {};
270
+ for (const [k, v] of Object.entries(obj)) out[k] = scrubPrivateIps(v);
271
+ return out;
272
+ }
273
+ return obj;
274
+ }
275
+ payloadForAI.services = scrubPrivateIps(payloadForAI.services);
276
+ payloadForAI.evidence = scrubPrivateIps(payloadForAI.evidence);
277
+
278
+ payloadForAI = { ...payloadForAI, _meta: { ...(payloadForAI?._meta || {}), wasRedacted: true, redactor: used } };
279
+ } else {
280
+ payloadForAI = { ...payloadForAI, _meta: { ...(payloadForAI?._meta || {}), wasRedacted: false } };
281
+ }
282
+
283
+ // Ensure placeholder only if a serial was detected
284
+ if (payloadForAI?._meta?.serialFound && detectedSerial) {
285
+ const s = String(payloadForAI.summary ?? '').trim();
286
+ if (!/\bSerial\s*[:=]/i.test(s)) {
287
+ payloadForAI.summary = `${s} Serial: [REDACTED_HIDDEN]`;
288
+ } else {
289
+ payloadForAI.summary = s.replace(/\bSerial\s*[:=]\s*([A-Za-z0-9._-]+)/i, 'Serial: [REDACTED_HIDDEN]');
290
+ }
291
+ }
292
+
293
+ // --- Bail out early if sending disabled -----------------------------------
294
+ const providerLabel = aiProvider === 'claude' ? 'Claude' : aiProvider === 'ollama' ? 'Ollama' : 'OpenAI';
295
+ if (!sendEnabled || !key) {
296
+ console.log(`[${providerLabel}] AI_ENABLED=false; not sending. Model=${model}`);
297
+ return {
298
+ file_paths: { folder: outDir, plain: null, ai_json: null, raw_json: adminRawPath, html: null, admin_html: adminHtmlPath },
299
+ ai_conclusion: null
300
+ };
301
+ }
302
+
303
+ // --- Write the payload we plan to send ------------------------------------
304
+ try {
305
+ await fsp.writeFile(aiPayloadPath, JSON.stringify(payloadForAI, null, 2), 'utf8');
306
+ console.log(`[${providerLabel}] Wrote AI payload:`, aiPayloadPath);
307
+ } catch (e) {
308
+ console.warn(`[${providerLabel}] Failed to write AI payload:`, e?.message || e);
309
+ }
310
+
311
+ // --- Select prompt ---------------------------------------------------------
312
+ let promptText = openaiSimplePrompt;
313
+ if (String(promptMode).toLowerCase() === 'pro') {
314
+ promptText = openaiProPrompt;
315
+ } else if (String(promptMode).toLowerCase() === 'optimized') {
316
+ promptText = openaiPromptOptimized;
317
+ }
318
+
319
+ // --- Send to AI provider ---------------------------------------------------
320
+ let aiConclusionText = null;
321
+ try {
322
+ console.log(`[${providerLabel}] Sending summary, model:`, model);
323
+
324
+ let resp;
325
+ const userContent = `Scan payload:\n${JSON.stringify(payloadForAI, null, 2)}`;
326
+
327
+ // AbortController timeout — prevents the pipeline hanging on a stalled AI provider.
328
+ const AI_TIMEOUT_MS = Number(process.env.NSA_AI_TIMEOUT_MS) || 120_000; // 2 min default
329
+ const ac = new AbortController();
330
+ const aiTimer = setTimeout(() => ac.abort(), AI_TIMEOUT_MS);
331
+
332
+ try {
333
+ if (aiProvider === 'claude') {
334
+ // --- Claude (Anthropic) ---
335
+ const { default: Anthropic } = await import('@anthropic-ai/sdk');
336
+ const client = new Anthropic({ apiKey: key });
337
+
338
+ resp = await client.messages.create({
339
+ model,
340
+ max_tokens: 4096,
341
+ system: promptText,
342
+ messages: [
343
+ { role: 'user', content: userContent }
344
+ ]
345
+ }, { signal: ac.signal });
346
+
347
+ console.log(`[${providerLabel}] Response id:`, resp?.id ?? '(unknown)');
348
+
349
+ // Extract text from Claude response
350
+ aiConclusionText = (resp?.content ?? [])
351
+ .filter(b => b.type === 'text')
352
+ .map(b => b.text)
353
+ .join('\n')
354
+ .trim() || null;
355
+ } else if (aiProvider === 'ollama') {
356
+ // --- Ollama (OpenAI-compatible API) ---
357
+ const { default: OpenAI } = await import('openai');
358
+ const ollamaBase = process.env.OLLAMA_BASE_URL || 'http://localhost:11434/v1';
359
+ const client = new OpenAI({ baseURL: ollamaBase, apiKey: key });
360
+
361
+ resp = await client.chat.completions.create({
362
+ model,
363
+ messages: [
364
+ { role: 'system', content: promptText },
365
+ { role: 'user', content: userContent }
366
+ ]
367
+ }, { signal: ac.signal });
368
+
369
+ console.log(`[${providerLabel}] Response id:`, resp?.id ?? '(unknown)');
370
+
371
+ aiConclusionText = resp?.choices?.[0]?.message?.content?.trim() || null;
372
+ } else {
373
+ // --- OpenAI ---
374
+ const { default: OpenAI } = await import('openai');
375
+ const client = new OpenAI({ apiKey: key });
376
+
377
+ if (client.responses?.create) {
378
+ resp = await client.responses.create({
379
+ model,
380
+ input: [
381
+ { role: 'system', content: promptText },
382
+ { role: 'user', content: userContent }
383
+ ]
384
+ }, { signal: ac.signal });
385
+ } else if (client.chat?.completions?.create) {
386
+ resp = await client.chat.completions.create({
387
+ model,
388
+ messages: [
389
+ { role: 'system', content: promptText },
390
+ { role: 'user', content: userContent }
391
+ ]
392
+ }, { signal: ac.signal });
393
+ } else {
394
+ throw new Error('OpenAI SDK: neither responses.create nor chat.completions.create is available.');
395
+ }
396
+
397
+ console.log(`[${providerLabel}] Response id:`, resp?.id ?? resp?.choices?.[0]?.id ?? '(unknown)');
398
+
399
+ // Extract assistant text (robust)
400
+ const extractAssistantText = (r) => {
401
+ try {
402
+ if (typeof r?.output_text === 'string' && r.output_text.trim()) return r.output_text.trim();
403
+ const msg = r?.choices?.[0]?.message?.content;
404
+ if (typeof msg === 'string' && msg.trim()) return msg.trim();
405
+ const texts = [];
406
+ const walk = (v) => {
407
+ if (!v) return;
408
+ if (Array.isArray(v)) return v.forEach(walk);
409
+ if (typeof v === 'object') {
410
+ if (typeof v.text === 'string') texts.push(v.text);
411
+ if (typeof v.content === 'string') texts.push(v.content);
412
+ for (const k of Object.keys(v)) walk(v[k]);
413
+ }
414
+ };
415
+ walk(r?.output);
416
+ const combined = texts.join('\n').trim();
417
+ return combined || null;
418
+ } catch {
419
+ return null;
420
+ }
421
+ };
422
+
423
+ aiConclusionText = extractAssistantText(resp);
424
+ }
425
+ } finally {
426
+ clearTimeout(aiTimer);
427
+ }
428
+
429
+ // Write full AI response
430
+ try {
431
+ await fsp.writeFile(aiResponsePath, JSON.stringify(resp, null, 2), 'utf8');
432
+ console.log(`[${providerLabel}] Wrote AI response:`, aiResponsePath);
433
+ } catch (e) {
434
+ console.warn(`[${providerLabel}] Failed to write AI response:`, e?.message || e);
435
+ }
436
+
437
+ // Write TXT & HTML
438
+ try {
439
+ const lines = [
440
+ `Model: ${model}`,
441
+ `Provider: ${providerLabel}`,
442
+ `When: ${new Date().toISOString()}`,
443
+ `Host: ${host}`,
444
+ ``,
445
+ `Payload path: ${aiPayloadPath}`,
446
+ `Response path: ${aiResponsePath}`,
447
+ ``,
448
+ `==== ${providerLabel} Conclusion ====`,
449
+ aiConclusionText ? aiConclusionText : '(no text content returned)'
450
+ ];
451
+ await fsp.writeFile(aiTxtPath, lines.join('\n'), 'utf8');
452
+ console.log(`[${providerLabel}] Wrote AI TXT:`, aiTxtPath);
453
+
454
+ if (typeof aiConclusionText === 'string' && aiConclusionText.trim()) {
455
+ const html = await buildHtmlReport({
456
+ host,
457
+ whenIso: new Date().toISOString(),
458
+ model,
459
+ md: aiConclusionText.trim()
460
+ });
461
+ await fsp.writeFile(aiHtmlPath, html, 'utf8');
462
+ console.log(`[${providerLabel}] Wrote AI HTML:`, aiHtmlPath);
463
+ }
464
+ } catch (e) {
465
+ console.warn(`[${providerLabel}] Failed to write AI TXT/HTML:`, e?.message || e);
466
+ }
467
+
468
+ return {
469
+ file_paths: {
470
+ folder: outDir,
471
+ plain: aiTxtPath,
472
+ ai_json: aiResponsePath,
473
+ raw_json: adminRawPath,
474
+ html: aiHtmlPath,
475
+ admin_html: adminHtmlPath
476
+ },
477
+ ai_conclusion: aiConclusionText
478
+ };
479
+ } catch (err) {
480
+ console.error(`[${providerLabel}] Send failed:`, err?.stack || err?.message || String(err));
481
+ try {
482
+ await fsp.writeFile(aiErrPath, JSON.stringify({
483
+ error: String(err?.message || err),
484
+ stack: err?.stack || null,
485
+ provider: aiProvider,
486
+ model
487
+ }, null, 2), 'utf8');
488
+ console.log(`[${providerLabel}] Wrote AI error:`, aiErrPath);
489
+ } catch (e) {
490
+ console.warn(`[${providerLabel}] Also failed to write error file:`, e?.message || e);
491
+ }
492
+
493
+ return {
494
+ file_paths: {
495
+ folder: outDir,
496
+ plain: null,
497
+ ai_json: null,
498
+ raw_json: adminRawPath,
499
+ html: null,
500
+ admin_html: adminHtmlPath
501
+ },
502
+ ai_conclusion: null
503
+ };
504
+ }
505
+ }
506
+
507
+ /* ------------------------------- CLI ----------------------------------- */
508
+
509
+ async function parseArgs(argv) {
510
+ const args = { cmd: 'scan', host: undefined, plugins: 'all', insecureHttps: false };
511
+ const a = argv.slice(2);
512
+ if (a.length && !a[0].startsWith('--')) args.cmd = a[0];
513
+
514
+ const get = (name) => {
515
+ const i = a.indexOf(`--${name}`);
516
+ if (i === -1) return undefined;
517
+ const v = a[i + 1];
518
+ if (!v || v.startsWith('--')) return true;
519
+ return v;
520
+ };
521
+
522
+ args.host = get('host') || get('ip') || get('target');
523
+ const p = get('plugins');
524
+ if (p && p !== true && p.toLowerCase() !== 'all') {
525
+ args.plugins = p.split(',').map((s) => s.trim()).filter(Boolean);
526
+ } else if (p && p.toLowerCase() === 'all') {
527
+ args.plugins = 'all';
528
+ }
529
+ args.insecureHttps = !!(get('insecure-https') || get('insecure_https'));
530
+ const hostFileVal = get('host-file') || get('host_file');
531
+ args.hostFile = (hostFileVal && hostFileVal !== true) ? hostFileVal : undefined;
532
+ const outVal = get('out');
533
+ if (outVal && outVal !== true) process.env.SCAN_OUT_PATH = outVal;
534
+ const portsVal = get('ports');
535
+ args.ports = (portsVal && portsVal !== true) ? portsVal : null;
536
+ const parallelVal = get('parallel');
537
+ args.parallel = (parallelVal && parallelVal !== true) ? Math.max(1, parseInt(parallelVal, 10) || 1) : 1;
538
+ args.failOn = get('fail-on') || get('fail_on') || null;
539
+ if (args.failOn === true) args.failOn = null; // bare flag without value
540
+ const ofVal = get('output-format') || get('output_format') || null;
541
+ args.outputFormat = (ofVal && ofVal !== true) ? ofVal : null;
542
+
543
+ // CTEM: continuous watch mode flags
544
+ args.watch = !!(get('watch'));
545
+ const intervalVal = get('interval');
546
+ args.intervalMinutes = (intervalVal && intervalVal !== true) ? Math.max(1, parseInt(intervalVal, 10) || 60) : 60;
547
+ const whUrl = get('webhook-url') || get('webhook_url') || null;
548
+ if (whUrl && whUrl !== true) {
549
+ if (!(await isSafeWebhookUrl(whUrl))) {
550
+ console.error(`[ERROR] Webhook URL rejected: private/loopback/metadata addresses are not allowed.`);
551
+ process.exit(2);
552
+ }
553
+ args.webhookUrl = whUrl;
554
+ } else {
555
+ args.webhookUrl = null;
556
+ }
557
+ const alertSev = get('alert-severity') || get('alert_severity') || null;
558
+ args.alertSeverity = (alertSev && alertSev !== true) ? alertSev.toLowerCase() : 'high';
559
+
560
+ return args;
561
+ }
562
+
563
+ async function scanSingleHost(pm, host, plugins, opts, promptMode) {
564
+ // SSRF guard — block loopback, private ranges, cloud metadata endpoints.
565
+ // Set NSA_ALLOW_ALL_HOSTS=1 to scan RFC 1918 / private ranges (local network auditing).
566
+ if (!process.env.NSA_ALLOW_ALL_HOSTS) {
567
+ if (isBlockedIp(host)) {
568
+ throw new Error(`Scanning blocked address range is not allowed: ${host}`);
569
+ }
570
+ // Hostname (not literal IP) — resolve and validate the resolved address
571
+ if (!/^[\d.:[\]]+$/.test(host)) {
572
+ try {
573
+ await resolveAndValidate(host);
574
+ } catch (err) {
575
+ throw new Error(`Host rejected by SSRF guard: ${err.message}`);
576
+ }
577
+ }
578
+ }
579
+
580
+ const { results, conclusion } = await pm.run(host, plugins || 'all', opts);
581
+
582
+ // Enrich conclusion with MITRE ATT&CK technique mapping
583
+ const techniques = getAllTechniques(conclusion);
584
+ if (techniques.length > 0) {
585
+ conclusion.result = conclusion.result || {};
586
+ conclusion.result.techniques = techniques;
587
+ }
588
+
589
+ const { file_paths: ai_file_paths, ai_conclusion } = await maybeSendToOpenAI({ host, results, conclusion, promptMode });
590
+
591
+ // --- Scan history: record & compare ---
592
+ let scanDiff = null;
593
+ try {
594
+ const outRoot = toCleanPath(process.env.SCAN_OUT_PATH || process.env.OPENAI_OUT_PATH || 'out').replace(/\.[^/.]+$/, '') || 'out';
595
+ const services = conclusion?.result?.services ?? [];
596
+ const findingsCount = services.reduce((n, svc) => {
597
+ if (svc.anonymousLogin === true) n++;
598
+ if (svc.axfrAllowed === true) n++;
599
+ if (Array.isArray(svc.weakAlgorithms)) n += svc.weakAlgorithms.length;
600
+ if (Array.isArray(svc.dangerousMethods)) n += svc.dangerousMethods.length;
601
+ const cves = svc.cves || svc.cve || [];
602
+ if (Array.isArray(cves)) n += cves.length;
603
+ return n;
604
+ }, 0);
605
+
606
+ const scanSummary = {
607
+ timestamp: new Date().toISOString(),
608
+ host,
609
+ servicesCount: services.length,
610
+ openPorts: services.filter((s) => s.status === 'open').map((s) => s.port),
611
+ os: conclusion?.result?.host?.os ?? null,
612
+ findingsCount,
613
+ services: services.map((s) => ({
614
+ port: s.port, protocol: s.protocol ?? 'tcp',
615
+ service: s.service ?? null, version: s.version ?? null,
616
+ })),
617
+ };
618
+
619
+ // Retrieve previous scan for this host before recording the new one
620
+ const previous = await getLastScan(outRoot, host);
621
+ await recordScan(outRoot, scanSummary);
622
+ // CE: enforce 7-day JSONL retention (Pro/Enterprise: unlimited).
623
+ // Note: concurrent parallel scans on the same outRoot can race here (TOCTOU);
624
+ // acceptable for CE — production deployments should use a single scan process per directory.
625
+ if (getTierFromEnv() === 'ce') {
626
+ await pruneForCE(path.join(outRoot, HISTORY_FILE));
627
+ }
628
+
629
+ scanDiff = computeDiff(scanSummary, previous);
630
+ if (previous) {
631
+ console.log(`[ScanHistory] ${host}: ${scanDiff.summary}`);
632
+ } else {
633
+ console.log(`[ScanHistory] ${host}: First scan recorded.`);
634
+ }
635
+ } catch (err) {
636
+ console.warn('[ScanHistory] Failed to record/compare scan:', err?.message || err);
637
+ }
638
+
639
+ return { host, results, conclusion, ai_file_paths, ai_conclusion, scanDiff };
640
+ }
641
+
642
+ /* -------------------- CI/CD severity threshold helpers ------------------- */
643
+
644
+ const SEVERITY_RANK = { critical: 4, high: 3, medium: 2, low: 1, info: 0 };
645
+
646
+ /**
647
+ * Determine the maximum severity level present in a conclusion's services.
648
+ * Checks security findings (anonymousLogin, axfrAllowed, weakAlgorithms,
649
+ * dangerousMethods, CVEs) as well as open service status.
650
+ * @param {object} conclusion
651
+ * @returns {number} highest severity rank found (0-4)
652
+ */
653
+ function maxSeverityInConclusion(conclusion) {
654
+ const services = conclusion?.result?.services || [];
655
+ let max = 0;
656
+
657
+ for (const svc of services) {
658
+ // anonymousLogin or axfrAllowed → Critical
659
+ if (svc.anonymousLogin === true) max = Math.max(max, SEVERITY_RANK.critical);
660
+ if (svc.axfrAllowed === true) max = Math.max(max, SEVERITY_RANK.critical);
661
+
662
+ // weakAlgorithms or dangerousMethods → Medium
663
+ if (Array.isArray(svc.weakAlgorithms) && svc.weakAlgorithms.length > 0) max = Math.max(max, SEVERITY_RANK.medium);
664
+ if (Array.isArray(svc.dangerousMethods) && svc.dangerousMethods.length > 0) max = Math.max(max, SEVERITY_RANK.medium);
665
+
666
+ // CVEs
667
+ const cves = svc.cves || svc.cve || [];
668
+ if (Array.isArray(cves)) {
669
+ for (const cve of cves) {
670
+ const sev = typeof cve === 'string' ? 'high' : String(cve?.severity || 'high').toLowerCase();
671
+ max = Math.max(max, SEVERITY_RANK[sev] ?? SEVERITY_RANK.high);
672
+ }
673
+ }
674
+
675
+ // Open service → Info (baseline)
676
+ if (svc.status === 'open') max = Math.max(max, SEVERITY_RANK.info);
677
+ }
678
+
679
+ return max;
680
+ }
681
+
682
+ async function main() {
683
+ const { cmd, host, plugins, insecureHttps, hostFile, parallel, failOn, outputFormat, watch, intervalMinutes, webhookUrl, alertSeverity, ports } = await parseArgs(process.argv);
684
+
685
+ if (cmd === 'license') {
686
+ const { getTierFromEnv } = await import('./utils/license.mjs');
687
+ const { resolveCapabilities } = await import('./utils/capabilities.mjs');
688
+ // TODO (Phase 2): replace getTierFromEnv() with loadLicense() for full JWT verification
689
+ const tier = getTierFromEnv();
690
+ const caps = resolveCapabilities(tier);
691
+ const key = process.env.NSAUDITOR_LICENSE_KEY;
692
+ const rawArgs = process.argv.slice(2);
693
+
694
+ if (rawArgs.includes('--status')) {
695
+ const tierLabel = { ce: 'Community Edition (CE)', pro: 'Pro', enterprise: 'Enterprise' };
696
+ console.log(`License status: ${tierLabel[tier] ?? tier}`);
697
+ console.log(`License key: ${key ? `set (${key.slice(0, 8)}...)` : 'not set — running CE'}`);
698
+ if (!key) {
699
+ console.log('\n→ Start a free 14-day Pro trial: https://www.nsauditor.com/ai/trial');
700
+ }
701
+ } else if (rawArgs.includes('--capabilities')) {
702
+ console.log(`Active capabilities for tier: ${tier}\n`);
703
+ for (const [name, enabled] of Object.entries(caps)) {
704
+ console.log(` ${enabled ? '✓' : '✗'} ${name}`);
705
+ }
706
+ } else {
707
+ console.log('Usage: nsauditor-ai license --status | --capabilities');
708
+ }
709
+ process.exit(0);
710
+ }
711
+
712
+ if (cmd !== 'scan') {
713
+ console.error(`Unknown command: ${cmd}`);
714
+ process.exit(2);
715
+ }
716
+
717
+ // Resolve host list
718
+ let hosts;
719
+ if (hostFile) {
720
+ hosts = await parseHostFile(hostFile);
721
+ } else if (host) {
722
+ hosts = await parseHostArg(host);
723
+ } else {
724
+ console.error('Fatal: --host or --host-file is required');
725
+ process.exit(2);
726
+ }
727
+
728
+ if (!hosts || hosts.length === 0) {
729
+ console.error('Fatal: no hosts resolved');
730
+ process.exit(2);
731
+ }
732
+
733
+ const opts = { insecureHttps };
734
+ if (ports) opts.ports = ports;
735
+ const pm = await PluginManager.create('./plugins');
736
+ const promptMode = String(process.env.OPENAI_PROMPT_MODE || 'basic').toLowerCase().trim();
737
+
738
+ // --- CTEM: continuous watch mode ---
739
+ if (watch) {
740
+ const intervalMs = intervalMinutes * 60 * 1000;
741
+ console.log(`[CTEM] Watch mode enabled. Interval: ${intervalMinutes}m, Concurrency: ${parallel}, Hosts: ${hosts.length}`);
742
+ if (webhookUrl) console.log(`[CTEM] Webhook URL: ${webhookUrl}, Alert severity: ${alertSeverity}`);
743
+
744
+ let previousCycleResults = null;
745
+
746
+ const scheduler = createScheduler({
747
+ intervalMs,
748
+ hosts,
749
+ parallel,
750
+ scanFn: async (h) => {
751
+ const out = await scanSingleHost(pm, h, plugins, opts, promptMode);
752
+ return out;
753
+ },
754
+ onScanComplete: (h, result) => {
755
+ console.log(`[CTEM] Scan complete: ${h}`);
756
+ },
757
+ onCycleComplete: async (results) => {
758
+ console.log(`[CTEM] Cycle complete. Scanned ${results.size} host(s).`);
759
+
760
+ // Build delta report
761
+ if (previousCycleResults) {
762
+ const delta = buildDeltaReport(results, previousCycleResults);
763
+ console.log(formatDeltaSummary(delta));
764
+
765
+ // Send webhook alerts for significant changes
766
+ if (webhookUrl && hasSignificantChanges(delta)) {
767
+ const sevRank = SEVERITY_RANK[alertSeverity] ?? SEVERITY_RANK.high;
768
+
769
+ for (const [h, scanOut] of results) {
770
+ if (!scanOut?.conclusion) continue;
771
+ const hostSev = maxSeverityInConclusion(scanOut.conclusion);
772
+ if (hostSev >= sevRank) {
773
+ const services = scanOut.conclusion?.result?.services || [];
774
+ const findings = services.filter((svc) => {
775
+ let svcSev = 0;
776
+ if (svc.anonymousLogin === true || svc.axfrAllowed === true) svcSev = SEVERITY_RANK.critical;
777
+ if (Array.isArray(svc.weakAlgorithms) && svc.weakAlgorithms.length) svcSev = Math.max(svcSev, SEVERITY_RANK.medium);
778
+ if (Array.isArray(svc.dangerousMethods) && svc.dangerousMethods.length) svcSev = Math.max(svcSev, SEVERITY_RANK.medium);
779
+ const cves = svc.cves || svc.cve || [];
780
+ if (Array.isArray(cves) && cves.length) svcSev = Math.max(svcSev, SEVERITY_RANK.high);
781
+ return svcSev >= sevRank;
782
+ });
783
+
784
+ if (findings.length > 0) {
785
+ const payload = buildAlertPayload(h, findings, alertSeverity);
786
+ const webhookResult = await sendWebhook(webhookUrl, payload, { retries: 2, retryDelayMs: 1000 });
787
+ if (webhookResult.success) {
788
+ console.log(`[CTEM] Webhook alert sent for ${h}`);
789
+ } else {
790
+ console.warn(`[CTEM] Webhook alert failed for ${h}: ${webhookResult.error}`);
791
+ }
792
+ }
793
+ }
794
+ }
795
+ }
796
+ } else {
797
+ console.log('[CTEM] First cycle complete. Delta reporting will begin on next cycle.');
798
+ }
799
+
800
+ previousCycleResults = results;
801
+ },
802
+ });
803
+
804
+ // Graceful shutdown on SIGINT/SIGTERM
805
+ const shutdown = async () => {
806
+ console.log('\n[CTEM] Shutting down...');
807
+ await scheduler.stop();
808
+ console.log('[CTEM] Stopped.');
809
+ process.exit(0);
810
+ };
811
+ process.on('SIGINT', shutdown);
812
+ process.on('SIGTERM', shutdown);
813
+
814
+ scheduler.start();
815
+ return; // keep process alive via setInterval
816
+ }
817
+
818
+ // Collect all scan outputs for post-processing
819
+ const scanOutputs = [];
820
+
821
+ // Single host — preserve original behaviour (flat output)
822
+ if (hosts.length === 1) {
823
+ const out = await scanSingleHost(pm, hosts[0], plugins, opts, promptMode);
824
+ scanOutputs.push(out);
825
+ console.log(JSON.stringify(out, null, 2));
826
+ } else {
827
+ // Multi-host with concurrency semaphore
828
+ const concurrency = parallel;
829
+ const allResults = [];
830
+ let running = 0;
831
+ let idx = 0;
832
+
833
+ await new Promise((resolve, reject) => {
834
+ const tryNext = () => {
835
+ while (running < concurrency && idx < hosts.length) {
836
+ const h = hosts[idx++];
837
+ running++;
838
+ scanSingleHost(pm, h, plugins, opts, promptMode)
839
+ .then((result) => {
840
+ allResults.push(result);
841
+ running--;
842
+ if (allResults.length === hosts.length) return resolve();
843
+ tryNext();
844
+ })
845
+ .catch((err) => {
846
+ allResults.push({ host: h, error: err?.message || String(err) });
847
+ running--;
848
+ if (allResults.length === hosts.length) return resolve();
849
+ tryNext();
850
+ });
851
+ }
852
+ };
853
+ tryNext();
854
+ });
855
+
856
+ scanOutputs.push(...allResults);
857
+
858
+ const out = {
859
+ totalHosts: hosts.length,
860
+ concurrency,
861
+ results: allResults
862
+ };
863
+ console.log(JSON.stringify(out, null, 2));
864
+ }
865
+
866
+ // --- SARIF output ---
867
+ const wantSarif = outputFormat && String(outputFormat).toLowerCase().includes('sarif');
868
+ if (wantSarif) {
869
+ const outDir = 'out';
870
+ await fsp.mkdir(outDir, { recursive: true });
871
+
872
+ for (const scanOut of scanOutputs) {
873
+ if (!scanOut?.conclusion) continue;
874
+ const sarif = buildSarifLog({
875
+ host: scanOut.host,
876
+ conclusion: scanOut.conclusion,
877
+ results: scanOut.results
878
+ });
879
+ const sarifFileName = scanOutputs.length > 1
880
+ ? `scan_${safeHost(scanOut.host)}.sarif.json`
881
+ : 'scan_results.sarif.json';
882
+ const sarifPath = path.join(outDir, sarifFileName);
883
+ await fsp.writeFile(sarifPath, JSON.stringify(sarif, null, 2), 'utf8');
884
+ console.log(`[SARIF] Wrote SARIF output: ${sarifPath}`);
885
+ }
886
+ }
887
+
888
+ // --- CSV output ---
889
+ const wantCsv = outputFormat && String(outputFormat).toLowerCase().includes('csv');
890
+ if (wantCsv) {
891
+ const outDir = 'out';
892
+ await fsp.mkdir(outDir, { recursive: true });
893
+
894
+ for (const scanOut of scanOutputs) {
895
+ if (!scanOut?.conclusion) continue;
896
+ const csv = buildCsv({
897
+ host: scanOut.host,
898
+ conclusion: scanOut.conclusion
899
+ });
900
+ const csvFileName = scanOutputs.length > 1
901
+ ? `scan_${safeHost(scanOut.host)}.csv`
902
+ : 'scan_results.csv';
903
+ const csvPath = path.join(outDir, csvFileName);
904
+ await fsp.writeFile(csvPath, csv, 'utf8');
905
+ console.log(`[CSV] Wrote CSV output: ${csvPath}`);
906
+ }
907
+ }
908
+
909
+ // --- Fail-on severity threshold ---
910
+ if (failOn) {
911
+ const threshold = SEVERITY_RANK[String(failOn).toLowerCase()];
912
+ if (threshold == null) {
913
+ console.error(`[fail-on] Unknown severity level: ${failOn}. Valid: critical, high, medium, low, info`);
914
+ process.exit(2);
915
+ }
916
+
917
+ let highestFound = -1;
918
+ for (const scanOut of scanOutputs) {
919
+ if (!scanOut?.conclusion) continue;
920
+ highestFound = Math.max(highestFound, maxSeverityInConclusion(scanOut.conclusion));
921
+ }
922
+
923
+ if (highestFound === -1) {
924
+ console.error('[nsauditor] --fail-on set but no scan produced conclusions — exiting with code 2');
925
+ process.exit(2);
926
+ } else if (highestFound >= threshold) {
927
+ console.error(`[fail-on] Findings at or above "${failOn}" threshold detected (max severity rank: ${highestFound}). Exiting with code 1.`);
928
+ process.exit(1);
929
+ } else {
930
+ console.log(`[fail-on] No findings at or above "${failOn}" threshold. Exiting with code 0.`);
931
+ process.exit(0);
932
+ }
933
+ }
934
+ }
935
+
936
+ main().catch((err) => {
937
+ console.error(err?.stack || err);
938
+ process.exit(1);
939
+ });