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
@@ -0,0 +1,248 @@
1
+ // utils/scan_history.mjs
2
+ // Scan history persistence and comparison utilities.
3
+ // Uses JSONL (one JSON object per line) for append-friendly storage.
4
+
5
+ import fsp from 'node:fs/promises';
6
+ import path from 'node:path';
7
+
8
+ export const HISTORY_FILE = 'scan_history.jsonl';
9
+
10
+ /**
11
+ * Build a service key for comparison (port + protocol).
12
+ * @param {object} svc
13
+ * @returns {string}
14
+ */
15
+ function serviceKey(svc) {
16
+ return `${svc.port ?? ''}/${svc.protocol ?? 'tcp'}`;
17
+ }
18
+
19
+ /**
20
+ * Append a scan summary as a single JSON line to scan_history.jsonl.
21
+ * @param {string} outputDir - root output directory
22
+ * @param {object} summary - scan summary object
23
+ * @returns {Promise<string>} path to the history file
24
+ */
25
+ export async function recordScan(outputDir, summary) {
26
+ const filePath = path.join(outputDir, HISTORY_FILE);
27
+ const entry = {
28
+ timestamp: summary.timestamp ?? new Date().toISOString(),
29
+ host: summary.host ?? null,
30
+ servicesCount: summary.servicesCount ?? 0,
31
+ openPorts: Array.isArray(summary.openPorts) ? summary.openPorts : [],
32
+ os: summary.os ?? null,
33
+ findingsCount: summary.findingsCount ?? 0,
34
+ services: Array.isArray(summary.services) ? summary.services.map((s) => ({
35
+ port: s.port ?? null,
36
+ protocol: s.protocol ?? 'tcp',
37
+ service: s.service ?? null,
38
+ version: s.version ?? null,
39
+ })) : [],
40
+ };
41
+ const line = JSON.stringify(entry) + '\n';
42
+ await fsp.mkdir(outputDir, { recursive: true });
43
+ await fsp.appendFile(filePath, line, 'utf8');
44
+ return filePath;
45
+ }
46
+
47
+ /**
48
+ * Read scan_history.jsonl and return the most recent entry for the given host.
49
+ * @param {string} outputDir
50
+ * @param {string} host
51
+ * @returns {Promise<object|null>}
52
+ */
53
+ export async function getLastScan(outputDir, host) {
54
+ const filePath = path.join(outputDir, HISTORY_FILE);
55
+ let content;
56
+ try {
57
+ content = await fsp.readFile(filePath, 'utf8');
58
+ } catch (err) {
59
+ if (err.code === 'ENOENT') return null;
60
+ throw err;
61
+ }
62
+
63
+ const lines = content.trim().split('\n').filter(Boolean);
64
+ let latest = null;
65
+
66
+ for (const line of lines) {
67
+ try {
68
+ const entry = JSON.parse(line);
69
+ if (entry.host === host) {
70
+ if (!latest || entry.timestamp > latest.timestamp) {
71
+ latest = entry;
72
+ }
73
+ }
74
+ } catch {
75
+ // skip malformed lines
76
+ }
77
+ }
78
+
79
+ return latest;
80
+ }
81
+
82
+ /**
83
+ * Compare two scan summaries and return a structured diff.
84
+ * @param {object} current - current scan summary
85
+ * @param {object|null} previous - previous scan summary (null for first scan)
86
+ * @returns {object} diff object
87
+ */
88
+ export function computeDiff(current, previous) {
89
+ if (!previous) {
90
+ return {
91
+ newServices: [],
92
+ removedServices: [],
93
+ changedServices: [],
94
+ newFindings: current?.findingsCount ?? 0,
95
+ summary: 'No previous scan for comparison.',
96
+ };
97
+ }
98
+
99
+ const currentServices = Array.isArray(current?.services) ? current.services : [];
100
+ const previousServices = Array.isArray(previous?.services) ? previous.services : [];
101
+
102
+ const prevMap = new Map();
103
+ for (const svc of previousServices) {
104
+ prevMap.set(serviceKey(svc), svc);
105
+ }
106
+
107
+ const currMap = new Map();
108
+ for (const svc of currentServices) {
109
+ currMap.set(serviceKey(svc), svc);
110
+ }
111
+
112
+ const newServices = [];
113
+ const changedServices = [];
114
+
115
+ for (const [key, svc] of currMap) {
116
+ const prev = prevMap.get(key);
117
+ if (!prev) {
118
+ newServices.push(svc);
119
+ } else if (prev.service !== svc.service || prev.version !== svc.version) {
120
+ changedServices.push({
121
+ port: svc.port,
122
+ protocol: svc.protocol,
123
+ previousService: prev.service,
124
+ previousVersion: prev.version,
125
+ currentService: svc.service,
126
+ currentVersion: svc.version,
127
+ });
128
+ }
129
+ }
130
+
131
+ const removedServices = [];
132
+ for (const [key, svc] of prevMap) {
133
+ if (!currMap.has(key)) {
134
+ removedServices.push(svc);
135
+ }
136
+ }
137
+
138
+ const findingsDelta = (current?.findingsCount ?? 0) - (previous?.findingsCount ?? 0);
139
+
140
+ // Build human-readable summary
141
+ const parts = [];
142
+ if (newServices.length) {
143
+ parts.push(`${newServices.length} new service(s) detected`);
144
+ }
145
+ if (removedServices.length) {
146
+ parts.push(`${removedServices.length} service(s) removed`);
147
+ }
148
+ if (changedServices.length) {
149
+ parts.push(`${changedServices.length} service(s) changed`);
150
+ }
151
+ if (findingsDelta !== 0) {
152
+ const sign = findingsDelta > 0 ? '+' : '';
153
+ parts.push(`findings delta: ${sign}${findingsDelta}`);
154
+ }
155
+
156
+ const summary = parts.length > 0
157
+ ? parts.join(', ') + '.'
158
+ : 'No changes detected since last scan.';
159
+
160
+ return {
161
+ newServices,
162
+ removedServices,
163
+ changedServices,
164
+ newFindings: findingsDelta,
165
+ summary,
166
+ };
167
+ }
168
+
169
+ const CE_RETENTION_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
170
+
171
+ /**
172
+ * Remove JSONL entries older than 7 days from the given history file.
173
+ * CE-only — call after each scan for CE tier. Pro/Enterprise: unlimited retention.
174
+ * Unparseable lines are preserved to avoid data loss.
175
+ *
176
+ * @param {string} filePath - absolute path to the JSONL history file
177
+ * @returns {Promise<void>}
178
+ */
179
+ export async function pruneForCE(filePath) {
180
+ let raw;
181
+ try {
182
+ raw = await fsp.readFile(filePath, 'utf8');
183
+ } catch {
184
+ return; // file doesn't exist yet — nothing to prune
185
+ }
186
+ const cutoff = Date.now() - CE_RETENTION_MS;
187
+ const kept = raw.split('\n').filter(line => {
188
+ if (!line.trim()) return false;
189
+ try {
190
+ const entry = JSON.parse(line);
191
+ const rawTs = entry.timestamp;
192
+ if (rawTs == null || rawTs === '') return true; // missing/null timestamp — preserve
193
+ const ts = new Date(rawTs).getTime();
194
+ if (isNaN(ts)) return true; // non-parseable string — preserve to avoid data loss
195
+ return ts >= cutoff;
196
+ } catch {
197
+ return true; // keep unparseable lines rather than lose data
198
+ }
199
+ });
200
+ await fsp.writeFile(filePath, kept.join('\n') + (kept.length ? '\n' : ''));
201
+ }
202
+
203
+ /**
204
+ * Format a diff object into markdown-like text lines.
205
+ * @param {object} diff - output from computeDiff()
206
+ * @returns {string}
207
+ */
208
+ export function formatDiffReport(diff) {
209
+ if (!diff) return '';
210
+
211
+ const lines = [];
212
+ lines.push('## Scan Comparison');
213
+ lines.push('');
214
+ lines.push(diff.summary);
215
+ lines.push('');
216
+
217
+ if (diff.newServices.length) {
218
+ lines.push('### New Services');
219
+ for (const svc of diff.newServices) {
220
+ lines.push(`- ${svc.port}/${svc.protocol}: ${svc.service ?? 'unknown'} (${svc.version ?? 'unknown'})`);
221
+ }
222
+ lines.push('');
223
+ }
224
+
225
+ if (diff.removedServices.length) {
226
+ lines.push('### Removed Services');
227
+ for (const svc of diff.removedServices) {
228
+ lines.push(`- ${svc.port}/${svc.protocol}: ${svc.service ?? 'unknown'} (${svc.version ?? 'unknown'})`);
229
+ }
230
+ lines.push('');
231
+ }
232
+
233
+ if (diff.changedServices.length) {
234
+ lines.push('### Changed Services');
235
+ for (const ch of diff.changedServices) {
236
+ lines.push(`- ${ch.port}/${ch.protocol}: ${ch.previousService ?? 'unknown'} ${ch.previousVersion ?? ''} -> ${ch.currentService ?? 'unknown'} ${ch.currentVersion ?? ''}`);
237
+ }
238
+ lines.push('');
239
+ }
240
+
241
+ if (diff.newFindings !== 0) {
242
+ const sign = diff.newFindings > 0 ? '+' : '';
243
+ lines.push(`**Findings delta:** ${sign}${diff.newFindings}`);
244
+ lines.push('');
245
+ }
246
+
247
+ return lines.join('\n');
248
+ }
@@ -0,0 +1,157 @@
1
+ // utils/scheduler.mjs
2
+ // Continuous scan scheduler with concurrency control.
3
+
4
+ /**
5
+ * Create a scheduler that runs periodic scan cycles across a set of hosts.
6
+ * @param {object} opts
7
+ * @param {number} opts.intervalMs - interval between scan cycles in ms
8
+ * @param {string[]} opts.hosts - hosts to scan each cycle
9
+ * @param {number} [opts.parallel=1] - max concurrent scans
10
+ * @param {function} opts.scanFn - async (host) => result — performs the scan
11
+ * @param {function} [opts.onScanComplete] - (host, result, diff) => void
12
+ * @param {function} [opts.onCycleComplete] - (results) => void
13
+ * @returns {object} scheduler instance
14
+ */
15
+ export function createScheduler(opts) {
16
+ const {
17
+ intervalMs,
18
+ hosts,
19
+ parallel = 1,
20
+ scanFn,
21
+ onScanComplete,
22
+ onCycleComplete,
23
+ } = opts;
24
+
25
+ if (!intervalMs || intervalMs <= 0) throw new Error('intervalMs must be a positive number');
26
+ if (!Array.isArray(hosts) || hosts.length === 0) throw new Error('hosts must be a non-empty array');
27
+ if (typeof scanFn !== 'function') throw new Error('scanFn must be a function');
28
+
29
+ let _running = false;
30
+ let _timer = null;
31
+ let _cycleInProgress = false;
32
+ let _stopRequested = false;
33
+ let _cyclePromise = null;
34
+
35
+ /**
36
+ * Run scans for all hosts with concurrency control (semaphore pattern).
37
+ * @returns {Promise<Map<string, object>>} host → result map
38
+ */
39
+ async function runCycle() {
40
+ _cycleInProgress = true;
41
+ const results = new Map();
42
+ const concurrency = Math.max(1, parallel);
43
+ let running = 0;
44
+ let idx = 0;
45
+
46
+ await new Promise((resolve) => {
47
+ if (hosts.length === 0) return resolve();
48
+
49
+ const tryNext = () => {
50
+ while (running < concurrency && idx < hosts.length) {
51
+ if (_stopRequested) {
52
+ // Don't start new scans, but let in-progress ones finish
53
+ if (running === 0) return resolve();
54
+ return;
55
+ }
56
+ const h = hosts[idx++];
57
+ running++;
58
+ scanFn(h)
59
+ .then((result) => {
60
+ results.set(h, result);
61
+ if (typeof onScanComplete === 'function') {
62
+ try { onScanComplete(h, result, null); } catch { /* swallow callback errors */ }
63
+ }
64
+ })
65
+ .catch((err) => {
66
+ const errResult = { error: err?.message || String(err) };
67
+ results.set(h, errResult);
68
+ if (typeof onScanComplete === 'function') {
69
+ try { onScanComplete(h, errResult, null); } catch { /* swallow */ }
70
+ }
71
+ })
72
+ .finally(() => {
73
+ running--;
74
+ if (results.size === hosts.length) return resolve();
75
+ tryNext();
76
+ });
77
+ }
78
+ // If stop was requested and nothing is running, resolve
79
+ if (_stopRequested && running === 0) return resolve();
80
+ };
81
+ tryNext();
82
+ });
83
+
84
+ if (typeof onCycleComplete === 'function') {
85
+ try { await onCycleComplete(results); } catch (err) { console.error('[scheduler] onCycleComplete error:', err?.message || err); }
86
+ }
87
+
88
+ _cycleInProgress = false;
89
+ return results;
90
+ }
91
+
92
+ const scheduler = {
93
+ /**
94
+ * Begin periodic scanning.
95
+ */
96
+ start() {
97
+ if (_running) return;
98
+ _running = true;
99
+ _stopRequested = false;
100
+
101
+ // Run first cycle immediately, then schedule subsequent ones
102
+ const kick = async () => {
103
+ if (_stopRequested) return;
104
+ _cyclePromise = runCycle();
105
+ await _cyclePromise;
106
+ _cyclePromise = null;
107
+ };
108
+
109
+ kick(); // fire-and-forget first cycle
110
+ _timer = setInterval(() => {
111
+ if (!_cycleInProgress && !_stopRequested) {
112
+ kick();
113
+ }
114
+ }, intervalMs);
115
+ },
116
+
117
+ /**
118
+ * Stop scheduling. Waits for any in-progress cycle to finish.
119
+ * @returns {Promise<void>}
120
+ */
121
+ async stop() {
122
+ if (!_running) return;
123
+ _stopRequested = true;
124
+
125
+ if (_timer !== null) {
126
+ clearInterval(_timer);
127
+ _timer = null;
128
+ }
129
+
130
+ // Wait for in-progress cycle to complete
131
+ if (_cyclePromise) {
132
+ await _cyclePromise;
133
+ }
134
+
135
+ _running = false;
136
+ _stopRequested = false;
137
+ _cycleInProgress = false;
138
+ },
139
+
140
+ /**
141
+ * @returns {boolean} whether the scheduler is running
142
+ */
143
+ isRunning() {
144
+ return _running;
145
+ },
146
+
147
+ /**
148
+ * Run a single scan cycle immediately (independent of the interval timer).
149
+ * @returns {Promise<Map<string, object>>}
150
+ */
151
+ async runOnce() {
152
+ return runCycle();
153
+ },
154
+ };
155
+
156
+ return scheduler;
157
+ }
@@ -0,0 +1,177 @@
1
+ // utils/webhook.mjs
2
+ // Webhook notification utilities — pure Node.js, no external deps.
3
+
4
+ import http from 'node:http';
5
+ import https from 'node:https';
6
+ import { isBlockedIp, resolveAndValidate } from './net_validation.mjs';
7
+
8
+ /**
9
+ * Validate that a URL is http or https.
10
+ * @param {string} url
11
+ * @returns {boolean}
12
+ */
13
+ function isValidWebhookUrl(url) {
14
+ try {
15
+ const parsed = new URL(url);
16
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:';
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Validate that a webhook URL is safe for external use (blocks SSRF targets).
24
+ * Apply this at the CLI/user-input boundary, not inside sendWebhook itself.
25
+ * Performs DNS resolution to defeat rebinding / encoded-IP bypasses.
26
+ * @param {string} url
27
+ * @returns {Promise<boolean>}
28
+ */
29
+ export async function isSafeWebhookUrl(url) {
30
+ if (!isValidWebhookUrl(url)) return false;
31
+ const host = new URL(url).hostname.toLowerCase();
32
+ // Fast-path: block obvious loopback, link-local, cloud metadata, and private ranges
33
+ if (/^(127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[0-1])\.|169\.254\.|0\.|localhost$|metadata\.google)/i.test(host)) {
34
+ return false;
35
+ }
36
+ if (host === '::1' || host === '[::1]' || /^fe80:/i.test(host)) return false;
37
+
38
+ // DNS resolution check — catches rebinding, decimal/octal IPs, IPv6-mapped addrs
39
+ try {
40
+ await resolveAndValidate(host);
41
+ } catch {
42
+ return false;
43
+ }
44
+ return true;
45
+ }
46
+
47
+ // Injectable SSRF checker — overridable in tests only (see _setWebhookSafeChecker).
48
+ let _safeChecker = isSafeWebhookUrl;
49
+
50
+ /**
51
+ * Override the SSRF checker used by sendWebhook. Test-only — throws in production.
52
+ * @internal
53
+ */
54
+ export function _setWebhookSafeChecker(fn) {
55
+ if (process.env.NODE_ENV === 'production') throw new Error('_setWebhookSafeChecker is not available in production');
56
+ _safeChecker = fn ?? isSafeWebhookUrl;
57
+ }
58
+
59
+ /**
60
+ * Send a JSON payload to a webhook URL via HTTP POST.
61
+ * @param {string} url - webhook endpoint (http or https)
62
+ * @param {object} payload - JSON-serializable data
63
+ * @param {object} [opts]
64
+ * @param {number} [opts.timeout=10000] - request timeout in ms
65
+ * @param {number} [opts.retries=2] - retry count on failure
66
+ * @param {number} [opts.retryDelayMs=1000] - delay between retries in ms
67
+ * @returns {Promise<{ success: boolean, statusCode: number, error?: string }>}
68
+ */
69
+ export async function sendWebhook(url, payload, opts = {}) {
70
+ // Enforce SSRF safety at the function level — covers scheduler, programmatic,
71
+ // and any other callers that bypass CLI parseArgs validation.
72
+ const safe = await _safeChecker(url);
73
+ if (!safe) {
74
+ return { success: false, statusCode: 0, error: 'URL rejected by SSRF guard' };
75
+ }
76
+
77
+ const timeout = opts.timeout ?? 10000;
78
+ const retries = opts.retries ?? 2;
79
+ const retryDelayMs = opts.retryDelayMs ?? 1000;
80
+
81
+ const body = JSON.stringify(payload);
82
+ const parsed = new URL(url);
83
+ const transport = parsed.protocol === 'https:' ? https : http;
84
+
85
+ const requestOptions = {
86
+ method: 'POST',
87
+ hostname: parsed.hostname,
88
+ port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
89
+ path: parsed.pathname + parsed.search,
90
+ headers: {
91
+ 'Content-Type': 'application/json',
92
+ 'Content-Length': Buffer.byteLength(body),
93
+ },
94
+ timeout,
95
+ };
96
+
97
+ let lastError = null;
98
+ const maxAttempts = 1 + retries;
99
+
100
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
101
+ if (attempt > 0 && retryDelayMs > 0) {
102
+ await new Promise((r) => setTimeout(r, retryDelayMs));
103
+ }
104
+
105
+ try {
106
+ const result = await new Promise((resolve, reject) => {
107
+ const req = transport.request(requestOptions, (res) => {
108
+ // Consume response body to free socket
109
+ const chunks = [];
110
+ res.on('data', (c) => chunks.push(c));
111
+ res.on('end', () => {
112
+ const statusCode = res.statusCode;
113
+ const success = statusCode >= 200 && statusCode < 300;
114
+ resolve({ success, statusCode });
115
+ });
116
+ });
117
+
118
+ req.on('timeout', () => {
119
+ req.destroy();
120
+ reject(new Error('Request timed out'));
121
+ });
122
+
123
+ req.on('error', (err) => {
124
+ reject(err);
125
+ });
126
+
127
+ req.write(body);
128
+ req.end();
129
+ });
130
+
131
+ if (result.success) return result;
132
+ // 4xx = client error, won't succeed on retry
133
+ if (result.statusCode >= 400 && result.statusCode < 500) {
134
+ return { success: false, statusCode: result.statusCode, error: `HTTP ${result.statusCode}` };
135
+ }
136
+ // 5xx or other: retry
137
+ lastError = `HTTP ${result.statusCode}`;
138
+ if (attempt === maxAttempts - 1) {
139
+ return { success: false, statusCode: result.statusCode, error: lastError };
140
+ }
141
+ } catch (err) {
142
+ lastError = err?.message || String(err);
143
+ if (attempt === maxAttempts - 1) {
144
+ return { success: false, statusCode: 0, error: lastError };
145
+ }
146
+ }
147
+ }
148
+
149
+ // Should not reach here, but safety net
150
+ return { success: false, statusCode: 0, error: lastError || 'Unknown error' };
151
+ }
152
+
153
+ /**
154
+ * Build a standardised alert payload for webhook delivery.
155
+ * @param {string} host - scanned host
156
+ * @param {object[]} findings - array of finding objects
157
+ * @param {string} [severity='high'] - alert severity level
158
+ * @returns {object} alert payload
159
+ */
160
+ export function buildAlertPayload(host, findings, severity = 'high') {
161
+ const items = Array.isArray(findings) ? findings : [];
162
+
163
+ return {
164
+ timestamp: new Date().toISOString(),
165
+ host,
166
+ severity,
167
+ findingsCount: items.length,
168
+ summary: `${items.length} finding(s) detected on ${host} at severity ${severity} or above`,
169
+ details: items.map((f) => ({
170
+ port: f.port ?? null,
171
+ protocol: f.protocol ?? 'tcp',
172
+ service: f.service ?? null,
173
+ description: f.description ?? f.summary ?? null,
174
+ severity: f.severity ?? severity,
175
+ })),
176
+ };
177
+ }