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,339 @@
|
|
|
1
|
+
// plugins/sunrpc_scanner.mjs
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
import dgram from 'node:dgram';
|
|
4
|
+
|
|
5
|
+
/* ----------------------------- Helpers ----------------------------- */
|
|
6
|
+
|
|
7
|
+
function xdrEncodeInt(buffer, value, offset) {
|
|
8
|
+
buffer.writeUInt32BE(value >>> 0, offset);
|
|
9
|
+
return offset + 4;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function xdrDecodeInt(buffer, offset) {
|
|
13
|
+
const value = buffer.readInt32BE(offset);
|
|
14
|
+
return { value, newOffset: offset + 4 };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Robust parser that handles both simplified replies (no accept_stat) and proper RPC replies (with accept_stat).
|
|
18
|
+
function parseRpcPortFromReply(msg) {
|
|
19
|
+
// msg is expected to start at XID (no TCP record marker)
|
|
20
|
+
// Minimum valid RPC reply is 24 bytes
|
|
21
|
+
if (msg.length < 24) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
// Layout:
|
|
27
|
+
// 0: xid
|
|
28
|
+
// 4: msg_type (should be 1 for REPLY)
|
|
29
|
+
// 8: reply_stat (should be 0 for ACCEPTED)
|
|
30
|
+
// 12: verifier_flavor
|
|
31
|
+
// 16: verifier_length
|
|
32
|
+
// 20: verifier_body (padded to 4) -> accept_stat -> result (port)
|
|
33
|
+
const msgType = msg.readUInt32BE(4);
|
|
34
|
+
const replyState = msg.readUInt32BE(8);
|
|
35
|
+
if (msgType !== 1 || replyState !== 0) return null;
|
|
36
|
+
|
|
37
|
+
// Compute offset after opaque_auth verifier (flavor,len,body[padded])
|
|
38
|
+
if (msg.length >= 20) {
|
|
39
|
+
const verflen = msg.readUInt32BE(16) >>> 0;
|
|
40
|
+
const pad = (4 - (verflen % 4)) % 4;
|
|
41
|
+
const acceptOff = 20 + verflen + pad;
|
|
42
|
+
|
|
43
|
+
// Proper RPC reply with accept_stat followed by result (port) on success
|
|
44
|
+
if (msg.length >= acceptOff + 8) {
|
|
45
|
+
const acceptStat = msg.readUInt32BE(acceptOff);
|
|
46
|
+
if (acceptStat === 0 /* SUCCESS */) {
|
|
47
|
+
const port = msg.readUInt32BE(acceptOff + 4);
|
|
48
|
+
return port > 0 && port <= 65535 ? port : null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Fallback: treat offset 20 as port directly (simplified responders)
|
|
54
|
+
if (msg.length >= 24) {
|
|
55
|
+
const port = msg.readUInt32BE(20);
|
|
56
|
+
return port > 0 && port <= 65535 ? port : null;
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function getRpcPortTcp(host, program, version, timeout = 1000, rpcPort = 111) {
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
const socket = new net.Socket();
|
|
67
|
+
socket.setTimeout(timeout);
|
|
68
|
+
let receivedData = Buffer.alloc(0);
|
|
69
|
+
let expectedLength = null;
|
|
70
|
+
let done = false;
|
|
71
|
+
|
|
72
|
+
const finalize = (val) => {
|
|
73
|
+
if (done) return;
|
|
74
|
+
done = true;
|
|
75
|
+
clearTimeout(timeoutId);
|
|
76
|
+
socket.removeAllListeners();
|
|
77
|
+
try {
|
|
78
|
+
socket.end();
|
|
79
|
+
socket.destroy();
|
|
80
|
+
} catch {}
|
|
81
|
+
resolve(val);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
let timeoutId = setTimeout(() => {
|
|
85
|
+
finalize(null);
|
|
86
|
+
}, timeout);
|
|
87
|
+
|
|
88
|
+
socket.on('error', () => {
|
|
89
|
+
finalize(null);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
socket.on('timeout', () => {
|
|
93
|
+
finalize(null);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
socket.on('close', () => {
|
|
97
|
+
// Connection closed. Try to parse any data we might have received.
|
|
98
|
+
if (expectedLength !== null && receivedData.length >= expectedLength + 4) {
|
|
99
|
+
const rpcMessage = receivedData.slice(4, expectedLength + 4);
|
|
100
|
+
const port = parseRpcPortFromReply(rpcMessage);
|
|
101
|
+
return finalize(port);
|
|
102
|
+
}
|
|
103
|
+
finalize(null);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
socket.on('data', (data) => {
|
|
107
|
+
if (done) return;
|
|
108
|
+
receivedData = Buffer.concat([receivedData, data]);
|
|
109
|
+
|
|
110
|
+
// Parse TCP record marker
|
|
111
|
+
if (expectedLength === null && receivedData.length >= 4) {
|
|
112
|
+
const header = receivedData.readUInt32BE(0);
|
|
113
|
+
const lastFragment = (header & 0x80000000) !== 0;
|
|
114
|
+
expectedLength = header & 0x7FFFFFFF;
|
|
115
|
+
|
|
116
|
+
// Sanity checks
|
|
117
|
+
if (!lastFragment || expectedLength === 0 || expectedLength > 8192) {
|
|
118
|
+
return finalize(null);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Check if we have complete message
|
|
123
|
+
if (expectedLength !== null && receivedData.length >= expectedLength + 4) {
|
|
124
|
+
const rpcMessage = receivedData.slice(4, expectedLength + 4);
|
|
125
|
+
const port = parseRpcPortFromReply(rpcMessage);
|
|
126
|
+
return finalize(port);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
socket.connect(rpcPort, host, () => {
|
|
131
|
+
if (done) return;
|
|
132
|
+
|
|
133
|
+
const transactionId = Math.floor(Math.random() * 0xFFFFFFFF);
|
|
134
|
+
// Proper RPC CALL with cred/verifier (AUTH_NULL) + PMAP GETPORT args
|
|
135
|
+
const rpcMessage = Buffer.alloc(56); // 14 * 4-byte integers
|
|
136
|
+
let offset = 0;
|
|
137
|
+
|
|
138
|
+
// RPC call header
|
|
139
|
+
offset = xdrEncodeInt(rpcMessage, transactionId, offset); // xid
|
|
140
|
+
offset = xdrEncodeInt(rpcMessage, 0, offset); // CALL
|
|
141
|
+
offset = xdrEncodeInt(rpcMessage, 2, offset); // RPC version
|
|
142
|
+
|
|
143
|
+
const rpcbindProgram = 100000;
|
|
144
|
+
const rpcbindVersion = 2;
|
|
145
|
+
const getPortProcedure = 3;
|
|
146
|
+
|
|
147
|
+
offset = xdrEncodeInt(rpcMessage, rpcbindProgram, offset);
|
|
148
|
+
offset = xdrEncodeInt(rpcMessage, rpcbindVersion, offset);
|
|
149
|
+
offset = xdrEncodeInt(rpcMessage, getPortProcedure, offset);
|
|
150
|
+
|
|
151
|
+
// AUTH credentials (AUTH_NULL)
|
|
152
|
+
offset = xdrEncodeInt(rpcMessage, 0, offset); // cred flavor
|
|
153
|
+
offset = xdrEncodeInt(rpcMessage, 0, offset); // cred length
|
|
154
|
+
|
|
155
|
+
// AUTH verifier (AUTH_NULL)
|
|
156
|
+
offset = xdrEncodeInt(rpcMessage, 0, offset); // verf flavor
|
|
157
|
+
offset = xdrEncodeInt(rpcMessage, 0, offset); // verf length
|
|
158
|
+
|
|
159
|
+
// PMAP_GETPORT args: program, version, protocol, port(0)
|
|
160
|
+
offset = xdrEncodeInt(rpcMessage, program, offset);
|
|
161
|
+
offset = xdrEncodeInt(rpcMessage, version, offset);
|
|
162
|
+
offset = xdrEncodeInt(rpcMessage, 6, offset); // TCP
|
|
163
|
+
offset = xdrEncodeInt(rpcMessage, 0, offset);
|
|
164
|
+
|
|
165
|
+
const frameHeader = Buffer.alloc(4);
|
|
166
|
+
frameHeader.writeUInt32BE((rpcMessage.length | 0x80000000) >>> 0, 0);
|
|
167
|
+
const packet = Buffer.concat([frameHeader, rpcMessage]);
|
|
168
|
+
|
|
169
|
+
socket.write(packet, (err) => {
|
|
170
|
+
if (err && !done) {
|
|
171
|
+
finalize(null);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function getRpcPortUdp(host, program, version, timeout = 1000, rpcPort = 111) {
|
|
179
|
+
return new Promise((resolve, reject) => {
|
|
180
|
+
const client = dgram.createSocket('udp4');
|
|
181
|
+
let done = false;
|
|
182
|
+
const finalize = (val) => {
|
|
183
|
+
if (done) return;
|
|
184
|
+
done = true;
|
|
185
|
+
clearTimeout(timeoutId);
|
|
186
|
+
client.removeAllListeners();
|
|
187
|
+
try { client.close(); } catch {}
|
|
188
|
+
resolve(val);
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
let timeoutId = setTimeout(() => {
|
|
192
|
+
finalize(null); // Timeout treated as normal failure case
|
|
193
|
+
}, timeout);
|
|
194
|
+
|
|
195
|
+
client.on('error', () => {
|
|
196
|
+
finalize(null); // Network errors treated as normal failure case
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
client.on('message', (msg) => {
|
|
200
|
+
if (done) return;
|
|
201
|
+
// UDP reply starts at XID (no record marker)
|
|
202
|
+
const port = parseRpcPortFromReply(msg);
|
|
203
|
+
finalize(port);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// Construct the rpcbind GETPORT packet with AUTH_NULL cred/verifier
|
|
207
|
+
const transactionId = Math.floor(Math.random() * 0xFFFFFFFF);
|
|
208
|
+
const packet = Buffer.alloc(56); // 14 * 4-byte integers
|
|
209
|
+
let offset = 0;
|
|
210
|
+
|
|
211
|
+
// RPC call header
|
|
212
|
+
offset = xdrEncodeInt(packet, transactionId, offset); // xid
|
|
213
|
+
offset = xdrEncodeInt(packet, 0, offset); // CALL
|
|
214
|
+
offset = xdrEncodeInt(packet, 2, offset); // RPC version
|
|
215
|
+
|
|
216
|
+
const rpcbindProgram = 100000;
|
|
217
|
+
const rpcbindVersion = 2;
|
|
218
|
+
const getPortProcedure = 3;
|
|
219
|
+
|
|
220
|
+
offset = xdrEncodeInt(packet, rpcbindProgram, offset);
|
|
221
|
+
offset = xdrEncodeInt(packet, rpcbindVersion, offset);
|
|
222
|
+
offset = xdrEncodeInt(packet, getPortProcedure, offset);
|
|
223
|
+
|
|
224
|
+
// AUTH credentials (AUTH_NULL)
|
|
225
|
+
offset = xdrEncodeInt(packet, 0, offset); // cred flavor
|
|
226
|
+
offset = xdrEncodeInt(packet, 0, offset); // cred length
|
|
227
|
+
|
|
228
|
+
// AUTH verifier (AUTH_NULL)
|
|
229
|
+
offset = xdrEncodeInt(packet, 0, offset); // verf flavor
|
|
230
|
+
offset = xdrEncodeInt(packet, 0, offset); // verf length
|
|
231
|
+
|
|
232
|
+
// PMAP_GETPORT args: program, version, protocol, port(0)
|
|
233
|
+
offset = xdrEncodeInt(packet, program, offset);
|
|
234
|
+
offset = xdrEncodeInt(packet, version, offset);
|
|
235
|
+
offset = xdrEncodeInt(packet, 17, offset); // UDP
|
|
236
|
+
offset = xdrEncodeInt(packet, 0, offset);
|
|
237
|
+
|
|
238
|
+
client.send(packet, rpcPort, host, (err) => {
|
|
239
|
+
if (err) {
|
|
240
|
+
finalize(null);
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/* ----------------------------- Scanner Plugin ----------------------------- */
|
|
247
|
+
|
|
248
|
+
// Reduced set for faster scanning
|
|
249
|
+
const RPC_PROGRAMS = [
|
|
250
|
+
{ num: 100000, name: 'PORTMAPPER', versions: [2] },
|
|
251
|
+
{ num: 100003, name: 'NFS', versions: [3] },
|
|
252
|
+
{ num: 100005, name: 'MOUNTD', versions: [1] }
|
|
253
|
+
];
|
|
254
|
+
|
|
255
|
+
export default {
|
|
256
|
+
id: "015",
|
|
257
|
+
name: "SUN RPC Scanner",
|
|
258
|
+
description: "Scans for RPC services via portmapper on TCP/UDP 111 including NFS, mountd and others",
|
|
259
|
+
priority: 350,
|
|
260
|
+
requirements: {},
|
|
261
|
+
protocols: ["tcp", "udp"],
|
|
262
|
+
ports: [111],
|
|
263
|
+
|
|
264
|
+
async run(host, port=111, opts={}) {
|
|
265
|
+
const data = [];
|
|
266
|
+
let up = false;
|
|
267
|
+
let program = "Unknown";
|
|
268
|
+
let version = "Unknown";
|
|
269
|
+
|
|
270
|
+
// Determine which protocols to scan based on opts or default to both
|
|
271
|
+
const protocols = opts.protocol ? [opts.protocol] : ['tcp', 'udp'];
|
|
272
|
+
const timeout = opts.timeout || 1000;
|
|
273
|
+
|
|
274
|
+
// Scan each RPC program using provided port and protocols
|
|
275
|
+
for (const protocol of protocols) {
|
|
276
|
+
for (const prog of RPC_PROGRAMS) {
|
|
277
|
+
for (const ver of prog.versions) {
|
|
278
|
+
try {
|
|
279
|
+
const getRpcPort = protocol === 'tcp' ? getRpcPortTcp : getRpcPortUdp;
|
|
280
|
+
const detectedPort = await getRpcPort(host, prog.num, ver, timeout, port);
|
|
281
|
+
|
|
282
|
+
if (detectedPort) {
|
|
283
|
+
up = true;
|
|
284
|
+
program = "SUN RPC";
|
|
285
|
+
|
|
286
|
+
data.push({
|
|
287
|
+
// normalized evidence fields (preferred by reports)
|
|
288
|
+
from: 'sunrpc',
|
|
289
|
+
protocol,
|
|
290
|
+
port: detectedPort,
|
|
291
|
+
info: `RPC ${prog.name} v${ver} (${protocol.toUpperCase()})`,
|
|
292
|
+
banner: `Program ${prog.num} Version ${ver} on port ${detectedPort} via ${protocol.toUpperCase()}`,
|
|
293
|
+
status: 'open',
|
|
294
|
+
service: prog.name.toLowerCase(),
|
|
295
|
+
|
|
296
|
+
// keep legacy probe_* fields for compatibility
|
|
297
|
+
probe_protocol: protocol,
|
|
298
|
+
probe_port: detectedPort,
|
|
299
|
+
probe_info: `RPC ${prog.name} v${ver} (${protocol.toUpperCase()})`,
|
|
300
|
+
response_banner: `Program ${prog.num} Version ${ver} on port ${detectedPort} via ${protocol.toUpperCase()}`
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// Exit early after finding first service to speed up testing
|
|
304
|
+
if (opts.protocol) return { up, program, version, type: 'sunrpc', data };
|
|
305
|
+
}
|
|
306
|
+
} catch {
|
|
307
|
+
// Individual program/version failures are ignored
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (!up) {
|
|
314
|
+
data.push({
|
|
315
|
+
from: 'sunrpc',
|
|
316
|
+
protocol: protocols[0],
|
|
317
|
+
port,
|
|
318
|
+
info: 'No RPC services found',
|
|
319
|
+
banner: null,
|
|
320
|
+
status: 'filtered',
|
|
321
|
+
service: 'sunrpc',
|
|
322
|
+
|
|
323
|
+
// legacy fields
|
|
324
|
+
probe_protocol: protocols[0],
|
|
325
|
+
probe_port: port,
|
|
326
|
+
probe_info: 'No RPC services found',
|
|
327
|
+
response_banner: null
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return {
|
|
332
|
+
up,
|
|
333
|
+
program,
|
|
334
|
+
version,
|
|
335
|
+
type: 'sunrpc',
|
|
336
|
+
data
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
};
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
// plugins/syn_scanner.mjs
|
|
2
|
+
// TCP SYN Scanner — optional Nmap wrapper for SYN (-sS) scanning.
|
|
3
|
+
// Gated by ENABLE_SYN_SCAN env var (default: false).
|
|
4
|
+
// Requires nmap to be installed; falls back gracefully when missing.
|
|
5
|
+
|
|
6
|
+
import { execFile } from "node:child_process";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
|
|
9
|
+
const execFileP = promisify(execFile);
|
|
10
|
+
|
|
11
|
+
/* ------------------------------ helpers ------------------------------ */
|
|
12
|
+
|
|
13
|
+
const HOST_RE = /^[a-zA-Z0-9._:-]+$/;
|
|
14
|
+
|
|
15
|
+
function isValidHost(host) {
|
|
16
|
+
if (!host || typeof host !== "string") return false;
|
|
17
|
+
if (host.length > 253) return false;
|
|
18
|
+
return HOST_RE.test(host);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Parse Nmap XML output (from -oX -) using regex.
|
|
23
|
+
* Returns { hosts: [{ ip, status, ports: [{ port, protocol, state, service, version }], os }] }
|
|
24
|
+
*/
|
|
25
|
+
export function parseNmapXml(xml) {
|
|
26
|
+
const result = { hosts: [] };
|
|
27
|
+
if (!xml || typeof xml !== "string") return result;
|
|
28
|
+
|
|
29
|
+
// Match each <host>...</host> block
|
|
30
|
+
const hostBlocks = xml.match(/<host[\s>][\s\S]*?<\/host>/gi);
|
|
31
|
+
if (!hostBlocks) return result;
|
|
32
|
+
|
|
33
|
+
for (const block of hostBlocks) {
|
|
34
|
+
const host = { ip: null, status: null, ports: [], os: null };
|
|
35
|
+
|
|
36
|
+
// Extract address
|
|
37
|
+
const addrMatch = block.match(/<address\s+addr="([^"]+)"/i);
|
|
38
|
+
if (addrMatch) host.ip = addrMatch[1];
|
|
39
|
+
|
|
40
|
+
// Extract status
|
|
41
|
+
const statusMatch = block.match(/<status\s+state="([^"]+)"/i);
|
|
42
|
+
if (statusMatch) host.status = statusMatch[1];
|
|
43
|
+
|
|
44
|
+
// Extract ports
|
|
45
|
+
const portMatches = block.match(/<port[\s>][\s\S]*?<\/port>/gi);
|
|
46
|
+
if (portMatches) {
|
|
47
|
+
for (const portBlock of portMatches) {
|
|
48
|
+
const portInfo = {};
|
|
49
|
+
|
|
50
|
+
const protoMatch = portBlock.match(/protocol="([^"]+)"/i);
|
|
51
|
+
portInfo.protocol = protoMatch ? protoMatch[1] : "tcp";
|
|
52
|
+
|
|
53
|
+
const portIdMatch = portBlock.match(/portid="([^"]+)"/i);
|
|
54
|
+
portInfo.port = portIdMatch ? Number(portIdMatch[1]) : 0;
|
|
55
|
+
|
|
56
|
+
const stateMatch = portBlock.match(/<state\s+state="([^"]+)"/i);
|
|
57
|
+
portInfo.state = stateMatch ? stateMatch[1] : "unknown";
|
|
58
|
+
|
|
59
|
+
const svcNameMatch = portBlock.match(/<service\s+name="([^"]+)"/i);
|
|
60
|
+
portInfo.service = svcNameMatch ? svcNameMatch[1] : null;
|
|
61
|
+
|
|
62
|
+
// Extract product and version from service tag attributes
|
|
63
|
+
const productMatch = portBlock.match(/product="([^"]+)"/i);
|
|
64
|
+
const versionMatch = portBlock.match(/version="([^"]+)"/i);
|
|
65
|
+
portInfo.version = null;
|
|
66
|
+
if (productMatch && versionMatch) {
|
|
67
|
+
portInfo.version = `${productMatch[1]} ${versionMatch[1]}`;
|
|
68
|
+
} else if (productMatch) {
|
|
69
|
+
portInfo.version = productMatch[1];
|
|
70
|
+
} else if (versionMatch) {
|
|
71
|
+
portInfo.version = versionMatch[1];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
host.ports.push(portInfo);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Extract OS match
|
|
79
|
+
const osMatch = block.match(/<osmatch\s+name="([^"]+)"/i);
|
|
80
|
+
if (osMatch) host.os = osMatch[1];
|
|
81
|
+
|
|
82
|
+
result.hosts.push(host);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Build the nmap argument list.
|
|
90
|
+
*/
|
|
91
|
+
function buildNmapArgs(host) {
|
|
92
|
+
const args = ["-sS", "-Pn", "-T4", "--min-rate", "100", "-oX", "-"];
|
|
93
|
+
|
|
94
|
+
const portRange = process.env.SYN_SCAN_PORTS;
|
|
95
|
+
const PORT_RANGE_RE = /^[\dT:U:,\-\s]+$/;
|
|
96
|
+
if (portRange && typeof portRange === "string" && portRange.trim() && PORT_RANGE_RE.test(portRange.trim())) {
|
|
97
|
+
args.push("-p", portRange.trim());
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
args.push(host);
|
|
101
|
+
return args;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Check if nmap is available on the system.
|
|
106
|
+
*/
|
|
107
|
+
async function isNmapAvailable() {
|
|
108
|
+
try {
|
|
109
|
+
// Use 'command -v' on Unix-like, 'where' on Windows
|
|
110
|
+
const cmd = process.platform === "win32" ? "where" : "command";
|
|
111
|
+
const cmdArgs = process.platform === "win32" ? ["nmap"] : ["-v", "nmap"];
|
|
112
|
+
await execFileP(cmd, cmdArgs, { timeout: 5000 });
|
|
113
|
+
return true;
|
|
114
|
+
} catch {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Check if SYN scanning is enabled via env var.
|
|
121
|
+
*/
|
|
122
|
+
function isSynScanEnabled() {
|
|
123
|
+
const v = String(process.env.ENABLE_SYN_SCAN || "").toLowerCase();
|
|
124
|
+
return v === "1" || v === "true" || v === "yes" || v === "on";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/* ------------------------------ plugin ------------------------------ */
|
|
128
|
+
|
|
129
|
+
export default {
|
|
130
|
+
id: "024",
|
|
131
|
+
name: "TCP SYN Scanner (Nmap)",
|
|
132
|
+
description:
|
|
133
|
+
"SYN scan via Nmap wrapper. Requires nmap installed and ENABLE_SYN_SCAN=1. Falls back gracefully when unavailable.",
|
|
134
|
+
priority: 12,
|
|
135
|
+
protocols: ["tcp"],
|
|
136
|
+
ports: [],
|
|
137
|
+
requirements: {},
|
|
138
|
+
|
|
139
|
+
async run(host, _port = 0, opts = {}) {
|
|
140
|
+
// Gate: must be explicitly enabled
|
|
141
|
+
if (!isSynScanEnabled()) {
|
|
142
|
+
return {
|
|
143
|
+
up: false,
|
|
144
|
+
program: "Unknown",
|
|
145
|
+
version: "Unknown",
|
|
146
|
+
os: null,
|
|
147
|
+
type: "syn-scan",
|
|
148
|
+
tcpOpen: [],
|
|
149
|
+
data: [{ probe_info: "SYN scan disabled (set ENABLE_SYN_SCAN=1 to enable)" }],
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Validate host to prevent command injection
|
|
154
|
+
if (!isValidHost(host)) {
|
|
155
|
+
return {
|
|
156
|
+
up: false,
|
|
157
|
+
program: "Unknown",
|
|
158
|
+
version: "Unknown",
|
|
159
|
+
os: null,
|
|
160
|
+
type: "syn-scan",
|
|
161
|
+
tcpOpen: [],
|
|
162
|
+
data: [{ probe_info: `Invalid host: ${String(host).slice(0, 50)}` }],
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Check nmap availability
|
|
167
|
+
const nmapFound = await isNmapAvailable();
|
|
168
|
+
if (!nmapFound) {
|
|
169
|
+
return {
|
|
170
|
+
up: false,
|
|
171
|
+
program: "Unknown",
|
|
172
|
+
version: "Unknown",
|
|
173
|
+
os: null,
|
|
174
|
+
type: "syn-scan",
|
|
175
|
+
tcpOpen: [],
|
|
176
|
+
data: [{ probe_info: "nmap not found, falling back to TCP connect scan" }],
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Run nmap SYN scan
|
|
181
|
+
const timeoutMs = Number(process.env.SYN_SCAN_TIMEOUT) || 30000;
|
|
182
|
+
const args = buildNmapArgs(host);
|
|
183
|
+
|
|
184
|
+
let stdout, stderr;
|
|
185
|
+
try {
|
|
186
|
+
const result = await execFileP("nmap", args, {
|
|
187
|
+
timeout: timeoutMs,
|
|
188
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
189
|
+
});
|
|
190
|
+
stdout = result.stdout || "";
|
|
191
|
+
stderr = result.stderr || "";
|
|
192
|
+
} catch (err) {
|
|
193
|
+
const msg = String(err?.stderr || err?.message || err);
|
|
194
|
+
|
|
195
|
+
// Detect permission issues (SYN scan requires root/sudo)
|
|
196
|
+
if (/permission|operation not permitted|requires root|raw socket|not authorized/i.test(msg)) {
|
|
197
|
+
return {
|
|
198
|
+
up: false,
|
|
199
|
+
program: "Unknown",
|
|
200
|
+
version: "Unknown",
|
|
201
|
+
os: null,
|
|
202
|
+
type: "syn-scan",
|
|
203
|
+
tcpOpen: [],
|
|
204
|
+
data: [
|
|
205
|
+
{
|
|
206
|
+
probe_info:
|
|
207
|
+
"SYN scan requires root/sudo privileges. Run with sudo or use the TCP connect scanner instead.",
|
|
208
|
+
},
|
|
209
|
+
],
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
up: false,
|
|
215
|
+
program: "Unknown",
|
|
216
|
+
version: "Unknown",
|
|
217
|
+
os: null,
|
|
218
|
+
type: "syn-scan",
|
|
219
|
+
tcpOpen: [],
|
|
220
|
+
data: [{ probe_info: `nmap error: ${msg.slice(0, 200)}` }],
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Parse XML output
|
|
225
|
+
const parsed = parseNmapXml(stdout);
|
|
226
|
+
|
|
227
|
+
const data = [];
|
|
228
|
+
const tcpOpen = [];
|
|
229
|
+
let hostUp = false;
|
|
230
|
+
let detectedOs = null;
|
|
231
|
+
|
|
232
|
+
for (const h of parsed.hosts) {
|
|
233
|
+
if (h.status === "up") hostUp = true;
|
|
234
|
+
if (h.os) detectedOs = h.os;
|
|
235
|
+
|
|
236
|
+
for (const p of h.ports) {
|
|
237
|
+
const row = {
|
|
238
|
+
probe_protocol: p.protocol || "tcp",
|
|
239
|
+
probe_port: p.port,
|
|
240
|
+
status: p.state,
|
|
241
|
+
probe_info: `SYN scan: ${p.state}${p.service ? ` (${p.service})` : ""}`,
|
|
242
|
+
response_banner: p.version || null,
|
|
243
|
+
service: p.service || null,
|
|
244
|
+
};
|
|
245
|
+
data.push(row);
|
|
246
|
+
|
|
247
|
+
if (p.state === "open" && (p.protocol || "tcp") === "tcp") {
|
|
248
|
+
tcpOpen.push(p.port);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Update context if provided
|
|
254
|
+
const ctx = opts?.context;
|
|
255
|
+
if (ctx) {
|
|
256
|
+
if (hostUp) ctx.hostUp = true;
|
|
257
|
+
if (ctx.tcpOpen && typeof ctx.tcpOpen.add === "function") {
|
|
258
|
+
for (const p of tcpOpen) ctx.tcpOpen.add(p);
|
|
259
|
+
}
|
|
260
|
+
if (detectedOs && !ctx.os) {
|
|
261
|
+
ctx.os = detectedOs;
|
|
262
|
+
ctx.guessedOs = detectedOs;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
up: hostUp || tcpOpen.length > 0,
|
|
268
|
+
program: "nmap",
|
|
269
|
+
version: "Unknown",
|
|
270
|
+
os: detectedOs,
|
|
271
|
+
type: "syn-scan",
|
|
272
|
+
tcpOpen,
|
|
273
|
+
data,
|
|
274
|
+
};
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
/* ------------------------------ conclude adapter ------------------------------ */
|
|
279
|
+
|
|
280
|
+
import { statusFrom } from "../utils/conclusion_utils.mjs";
|
|
281
|
+
|
|
282
|
+
export async function conclude({ host, result }) {
|
|
283
|
+
const rows = Array.isArray(result?.data) ? result.data : [];
|
|
284
|
+
const items = [];
|
|
285
|
+
|
|
286
|
+
for (const r of rows) {
|
|
287
|
+
const proto = r?.probe_protocol || "tcp";
|
|
288
|
+
const port = Number(r?.probe_port ?? 0);
|
|
289
|
+
if (!port) continue;
|
|
290
|
+
|
|
291
|
+
const state = r?.status || "unknown";
|
|
292
|
+
let status;
|
|
293
|
+
if (state === "open") status = "open";
|
|
294
|
+
else if (state === "closed") status = "closed";
|
|
295
|
+
else if (state === "filtered") status = "filtered";
|
|
296
|
+
else status = statusFrom({ info: r?.probe_info, banner: r?.response_banner, fallbackUp: result?.up });
|
|
297
|
+
|
|
298
|
+
items.push({
|
|
299
|
+
port,
|
|
300
|
+
protocol: proto,
|
|
301
|
+
service: r?.service || "unknown",
|
|
302
|
+
program: result?.program || "nmap",
|
|
303
|
+
version: r?.response_banner || "Unknown",
|
|
304
|
+
status,
|
|
305
|
+
info: r?.probe_info || null,
|
|
306
|
+
banner: r?.response_banner || null,
|
|
307
|
+
source: "syn_scanner",
|
|
308
|
+
evidence: rows,
|
|
309
|
+
authoritative: false,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return items;
|
|
314
|
+
}
|