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
|
@@ -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
|
+
}
|