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.
- package/CONTRIBUTING.md +24 -0
- package/LICENSE +21 -0
- package/README.md +584 -0
- package/bin/nsauditor-ai-mcp.mjs +2 -0
- package/bin/nsauditor-ai.mjs +2 -0
- package/cli.mjs +939 -0
- package/config/services.json +304 -0
- package/docs/EULA-nsauditor-ai.md +324 -0
- package/index.mjs +15 -0
- package/mcp_server.mjs +382 -0
- package/package.json +44 -0
- package/plugin_manager.mjs +829 -0
- package/plugins/arp_scanner.mjs +162 -0
- package/plugins/db_scanner.mjs +248 -0
- package/plugins/dns_scanner.mjs +369 -0
- package/plugins/dnssd-scanner.mjs +245 -0
- package/plugins/ftp_banner_check.mjs +247 -0
- package/plugins/host_up_check.mjs +337 -0
- package/plugins/http_probe.mjs +290 -0
- package/plugins/llmnr_scanner.mjs +130 -0
- package/plugins/mdns_scanner.mjs +522 -0
- package/plugins/netbios_scanner.mjs +737 -0
- package/plugins/opensearch_scanner.mjs +276 -0
- package/plugins/os_detector.mjs +436 -0
- package/plugins/ping_checker.mjs +271 -0
- package/plugins/port_scanner.mjs +250 -0
- package/plugins/result_concluder.mjs +274 -0
- package/plugins/snmp_scanner.mjs +278 -0
- package/plugins/ssh_scanner.mjs +421 -0
- package/plugins/sunrpc_scanner.mjs +339 -0
- package/plugins/syn_scanner.mjs +314 -0
- package/plugins/tls_scanner.mjs +225 -0
- package/plugins/upnp_scanner.mjs +441 -0
- package/plugins/webapp_detector.mjs +246 -0
- package/plugins/wsd_scanner.mjs +290 -0
- package/utils/attack_map.mjs +180 -0
- package/utils/capabilities.mjs +53 -0
- package/utils/conclusion_utils.mjs +70 -0
- package/utils/cpe.mjs +74 -0
- package/utils/cve_validator.mjs +64 -0
- package/utils/cvss.mjs +129 -0
- package/utils/delta_reporter.mjs +110 -0
- package/utils/export_csv.mjs +82 -0
- package/utils/finding_queue.mjs +64 -0
- package/utils/finding_schema.mjs +36 -0
- package/utils/host_iterator.mjs +166 -0
- package/utils/license.mjs +29 -0
- package/utils/net_validation.mjs +66 -0
- package/utils/nvd_cache.mjs +77 -0
- package/utils/nvd_client.mjs +130 -0
- package/utils/oui.mjs +107 -0
- package/utils/plugin_discovery.mjs +89 -0
- package/utils/prompts.mjs +143 -0
- package/utils/raw_report_html.mjs +170 -0
- package/utils/redact.mjs +79 -0
- package/utils/report_html.mjs +236 -0
- package/utils/sarif.mjs +225 -0
- package/utils/scan_history.mjs +248 -0
- package/utils/scheduler.mjs +157 -0
- 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
|
+
});
|