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,271 @@
|
|
|
1
|
+
// plugins/ping_checker.mjs
|
|
2
|
+
// Ping Checker — ICMP ping, TTL-based OS guess, ARP assist for local targets,
|
|
3
|
+
// and ICMP/TCP fallback probes when Echo Request gets no reply.
|
|
4
|
+
// Uses ONLY ctx.lookupVendor / ctx.probableOsFromVendor (if provided).
|
|
5
|
+
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { execFile } from "node:child_process";
|
|
8
|
+
import net from "node:net";
|
|
9
|
+
import { isPrivateLike } from '../utils/net_validation.mjs';
|
|
10
|
+
|
|
11
|
+
const execFileP = promisify(execFile);
|
|
12
|
+
const isWin = process.platform === "win32";
|
|
13
|
+
const DEBUG = /^(1|true|yes|on)$/i.test(String(process.env.DEBUG_MODE || process.env.PING_DEBUG || ""));
|
|
14
|
+
const FALLBACK_ENABLED = !/^(0|false|no|off)$/i.test(String(process.env.PING_FALLBACK ?? "true"));
|
|
15
|
+
const FALLBACK_TIMEOUT = Number(process.env.PING_FALLBACK_TIMEOUT) || 3000;
|
|
16
|
+
|
|
17
|
+
function dlog(...a) { if (DEBUG) console.log("[ping-checker]", ...a); }
|
|
18
|
+
|
|
19
|
+
/** Validate host string to prevent command injection. */
|
|
20
|
+
function isValidHost(h) {
|
|
21
|
+
if (!h || typeof h !== "string") return false;
|
|
22
|
+
// Allow IPv4, IPv6, and hostnames only — no shell metacharacters
|
|
23
|
+
return /^[a-zA-Z0-9.:_\-\[\]%]+$/.test(h);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Try ICMP Timestamp Request (Type 13) via nping or hping3.
|
|
28
|
+
* Returns true if the host responded, false otherwise.
|
|
29
|
+
*/
|
|
30
|
+
async function tryIcmpTimestamp(host, timeoutMs) {
|
|
31
|
+
if (!isValidHost(host)) return { alive: false, info: "Invalid host" };
|
|
32
|
+
|
|
33
|
+
// Try nping first (from nmap suite)
|
|
34
|
+
try {
|
|
35
|
+
const { stdout } = await execFileP(
|
|
36
|
+
"nping",
|
|
37
|
+
["--icmp", "--icmp-type", "timestamp", "-c", "1", "--delay", "0", host],
|
|
38
|
+
{ windowsHide: true, timeout: timeoutMs + 500 }
|
|
39
|
+
);
|
|
40
|
+
dlog("nping timestamp output:", stdout);
|
|
41
|
+
const ok = /RCVD|completed/i.test(stdout) && !/0 received/i.test(stdout);
|
|
42
|
+
if (ok) return { alive: true, info: "ICMP Timestamp reply via nping" };
|
|
43
|
+
return { alive: false, info: "ICMP Timestamp — no reply (nping)" };
|
|
44
|
+
} catch {
|
|
45
|
+
dlog("nping not available or failed");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Try hping3 as second option
|
|
49
|
+
try {
|
|
50
|
+
const { stdout } = await execFileP(
|
|
51
|
+
"hping3",
|
|
52
|
+
["-1", "--icmptype", "13", "-c", "1", host],
|
|
53
|
+
{ windowsHide: true, timeout: timeoutMs + 500 }
|
|
54
|
+
);
|
|
55
|
+
dlog("hping3 timestamp output:", stdout);
|
|
56
|
+
const ok = /flags=/.test(stdout) || /len=/.test(stdout);
|
|
57
|
+
if (ok) return { alive: true, info: "ICMP Timestamp reply via hping3" };
|
|
58
|
+
return { alive: false, info: "ICMP Timestamp — no reply (hping3)" };
|
|
59
|
+
} catch {
|
|
60
|
+
dlog("hping3 not available or failed");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { alive: false, info: "ICMP Timestamp — nping/hping3 not available" };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* TCP ACK probe on common ports (80, 443).
|
|
68
|
+
* A successful connect OR ECONNREFUSED (RST) both mean the host is up.
|
|
69
|
+
* Returns { alive, port, info }.
|
|
70
|
+
*/
|
|
71
|
+
function tryTcpAckProbe(host, port, timeoutMs) {
|
|
72
|
+
return new Promise((resolve) => {
|
|
73
|
+
const socket = new net.Socket();
|
|
74
|
+
socket.setTimeout(timeoutMs);
|
|
75
|
+
|
|
76
|
+
const cleanup = () => { try { socket.destroy(); } catch {} };
|
|
77
|
+
|
|
78
|
+
socket.on("connect", () => {
|
|
79
|
+
cleanup();
|
|
80
|
+
resolve({ alive: true, port, info: `TCP connect to port ${port} — host up` });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
socket.on("error", (err) => {
|
|
84
|
+
cleanup();
|
|
85
|
+
if (err.code === "ECONNREFUSED") {
|
|
86
|
+
// RST received — host is alive
|
|
87
|
+
resolve({ alive: true, port, info: `TCP RST on port ${port} — host up` });
|
|
88
|
+
} else {
|
|
89
|
+
resolve({ alive: false, port, info: `TCP probe port ${port} — ${err.code || err.message}` });
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
socket.on("timeout", () => {
|
|
94
|
+
cleanup();
|
|
95
|
+
resolve({ alive: false, port, info: `TCP probe port ${port} — timeout` });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
socket.connect(port, host);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function getMacViaArp(ip, iface = null) {
|
|
103
|
+
const cmd = "arp";
|
|
104
|
+
const args = isWin ? ["-a", ip] : ["-n", ip];
|
|
105
|
+
if (iface && !isWin) {
|
|
106
|
+
args.push("-i", iface);
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
const { stdout } = await execFileP(cmd, args, { windowsHide: true, timeout: 5000 });
|
|
110
|
+
// Log raw output for debugging
|
|
111
|
+
dlog("Raw ARP output:", stdout);
|
|
112
|
+
// Compact regex: catches colon or dash formats, 1 or 2 hex digits
|
|
113
|
+
const macRe = /([0-9a-f]{1,2}(?:[:-][0-9a-f]{1,2}){5})/i;
|
|
114
|
+
const line = stdout.split(/\r?\n/).find(l => l.includes(ip) || l.includes(`(${ip})`));
|
|
115
|
+
const m = (line && line.match(macRe)) || stdout.match(macRe);
|
|
116
|
+
if (m) {
|
|
117
|
+
let mac = m[1].replace(/-/g, ":").toUpperCase();
|
|
118
|
+
mac = mac.split(":").map(part => part.padStart(2, "0")).join(":");
|
|
119
|
+
dlog(`Parsed MAC for IP ${ip}: ${mac}`);
|
|
120
|
+
return mac;
|
|
121
|
+
}
|
|
122
|
+
dlog("No MAC found in output");
|
|
123
|
+
return null;
|
|
124
|
+
} catch (e) {
|
|
125
|
+
dlog("arp exec failed:", e?.message || e);
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function osFromTtl(initial) {
|
|
131
|
+
if (!initial) return null;
|
|
132
|
+
if (initial >= 61 && initial <= 64) return "Linux/Unix/macOS or RTOS";
|
|
133
|
+
if (initial >= 125 && initial <= 128) return "Windows";
|
|
134
|
+
if (initial >= 254 && initial <= 255) return "Cisco/Solaris or RTOS/IoT";
|
|
135
|
+
if (initial >= 30 && initial <= 32) return "Older system or custom embedded";
|
|
136
|
+
return `Custom/Proprietary (TTL=${initial})`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export default {
|
|
140
|
+
id: "001",
|
|
141
|
+
name: "Ping Checker",
|
|
142
|
+
description: "Checks ICMP reachability, infers OS from TTL, and leverages ARP (local only) with vendor hints from ctx.",
|
|
143
|
+
priority: 10,
|
|
144
|
+
requirements: {},
|
|
145
|
+
protocols: ["icmp"],
|
|
146
|
+
ports: [],
|
|
147
|
+
dependencies: [],
|
|
148
|
+
|
|
149
|
+
async run(host, _port, opts = {}) {
|
|
150
|
+
if (!isValidHost(host)) {
|
|
151
|
+
return { up: false, os: null, probeMethod: "none", data: [{ probe_protocol: "icmp", probe_port: 0, probe_info: "Invalid host", response_banner: null }] };
|
|
152
|
+
}
|
|
153
|
+
const timeoutMs = Number(opts.timeoutMs ?? process.env.NSA_PING_TIMEOUT_MS ?? 5000);
|
|
154
|
+
const fallbackEnabled = opts.fallback ?? FALLBACK_ENABLED;
|
|
155
|
+
const fallbackTimeout = Number(opts.fallbackTimeout ?? FALLBACK_TIMEOUT);
|
|
156
|
+
const cmd = "ping";
|
|
157
|
+
const args = isWin
|
|
158
|
+
? ["-n", "1", "-w", String(timeoutMs), host]
|
|
159
|
+
: ["-c", "1", "-W", String(Math.ceil(timeoutMs / 1000)), host];
|
|
160
|
+
|
|
161
|
+
const data = [];
|
|
162
|
+
let up = false;
|
|
163
|
+
let os = null;
|
|
164
|
+
let probeMethod = "none";
|
|
165
|
+
|
|
166
|
+
// 1) ICMP Echo Request (Type 8)
|
|
167
|
+
try {
|
|
168
|
+
const { stdout } = await execFileP(cmd, args, { windowsHide: true, timeout: timeoutMs + 1000 });
|
|
169
|
+
dlog("Raw ping output:", stdout);
|
|
170
|
+
const ok = /ttl=|TTL=|bytes from|time=|Antwort von|Reply from/i.test(stdout);
|
|
171
|
+
const ttlMatch = stdout.match(/ttl=(\d+)|TTL=(\d+)/i);
|
|
172
|
+
const ttl = ttlMatch ? parseInt(ttlMatch[1] || ttlMatch[2], 10) : null;
|
|
173
|
+
const initialTTL = ttl ? ttl + 1 : null;
|
|
174
|
+
const icmpOs = osFromTtl(initialTTL);
|
|
175
|
+
|
|
176
|
+
data.push({
|
|
177
|
+
probe_protocol: "icmp",
|
|
178
|
+
probe_port: 0,
|
|
179
|
+
probe_info: ok
|
|
180
|
+
? `Ping OK — ttl=${ttl ?? "N/A"} (inferred base ${initialTTL ?? "N/A"})`
|
|
181
|
+
: "Ping did not confirm host up",
|
|
182
|
+
response_banner: ttl ? `ttl=${ttl}` : null
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
if (ok) {
|
|
186
|
+
up = true;
|
|
187
|
+
probeMethod = "echo";
|
|
188
|
+
if (icmpOs) os = icmpOs;
|
|
189
|
+
}
|
|
190
|
+
} catch (e) {
|
|
191
|
+
dlog("ICMP probe error:", e?.message || e);
|
|
192
|
+
data.push({ probe_protocol: "icmp", probe_port: 0, probe_info: "Ping failed", response_banner: null });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 2) Fallback probes — only when echo failed and fallback is enabled
|
|
196
|
+
if (!up && fallbackEnabled) {
|
|
197
|
+
dlog("Echo failed, attempting fallback probes");
|
|
198
|
+
|
|
199
|
+
// Fallback 1: ICMP Timestamp Request (Type 13)
|
|
200
|
+
const tsResult = await tryIcmpTimestamp(host, fallbackTimeout);
|
|
201
|
+
data.push({
|
|
202
|
+
probe_protocol: "icmp-timestamp",
|
|
203
|
+
probe_port: 0,
|
|
204
|
+
probe_info: tsResult.info,
|
|
205
|
+
response_banner: null
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
if (tsResult.alive) {
|
|
209
|
+
up = true;
|
|
210
|
+
probeMethod = "timestamp";
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Fallback 2: TCP ACK probe on ports 80 and 443
|
|
214
|
+
if (!up) {
|
|
215
|
+
const tcpPorts = [80, 443];
|
|
216
|
+
for (const port of tcpPorts) {
|
|
217
|
+
const tcpResult = await tryTcpAckProbe(host, port, fallbackTimeout);
|
|
218
|
+
data.push({
|
|
219
|
+
probe_protocol: "tcp-ack",
|
|
220
|
+
probe_port: port,
|
|
221
|
+
probe_info: tcpResult.info,
|
|
222
|
+
response_banner: null
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
if (tcpResult.alive) {
|
|
226
|
+
up = true;
|
|
227
|
+
probeMethod = "tcp-ack";
|
|
228
|
+
// Propagate confirmed port to shared context so downstream
|
|
229
|
+
// plugins gated on tcp_open (HTTP Probe, Webapp Detector, etc.)
|
|
230
|
+
// can run without waiting for a full port scan.
|
|
231
|
+
const ctx = opts?.context;
|
|
232
|
+
if (ctx?.tcpOpen && typeof ctx.tcpOpen.add === "function") {
|
|
233
|
+
ctx.tcpOpen.add(port);
|
|
234
|
+
}
|
|
235
|
+
break; // No need to probe further
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// 3) ARP (local only)
|
|
242
|
+
if (isPrivateLike(host)) {
|
|
243
|
+
const interfaces = isWin ? [null] : [null, "en0"];
|
|
244
|
+
let mac = null;
|
|
245
|
+
for (const iface of interfaces) {
|
|
246
|
+
mac = await getMacViaArp(host, iface);
|
|
247
|
+
if (mac) break;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (mac) {
|
|
251
|
+
up = true; // ARP reachability implies L2 presence
|
|
252
|
+
|
|
253
|
+
// Only use ctx helpers (no OUI loading here)
|
|
254
|
+
const ctx = opts?.context || {};
|
|
255
|
+
const vendorRaw = typeof ctx.lookupVendor === "function" ? (ctx.lookupVendor(mac) || null) : null;
|
|
256
|
+
const vendor = vendorRaw ? vendorRaw.replace(/[\r\n]+/g, ' ').trim() : null;
|
|
257
|
+
const info = vendor ? `ARP entry found — vendor: ${vendor}` : "ARP entry found";
|
|
258
|
+
|
|
259
|
+
// If OS not set from TTL, try ctx vendor heuristic
|
|
260
|
+
if (!os && typeof ctx.probableOsFromVendor === "function") {
|
|
261
|
+
const guess = ctx.probableOsFromVendor(vendor);
|
|
262
|
+
if (guess && guess !== "Unknown") os = guess;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
data.push({ probe_protocol: "arp", probe_port: 0, probe_info: info, response_banner: mac });
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return { up, os, probeMethod, data };
|
|
270
|
+
}
|
|
271
|
+
};
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// plugins/port_scanner.mjs
|
|
2
|
+
// Fast TCP/UDP port sampler with banner sniffing and clear CLOSED/FILTERED mapping.
|
|
3
|
+
// Output shape matches earlier examples (tcpOpen/tcpClosed/tcpFiltered, etc.).
|
|
4
|
+
|
|
5
|
+
import net from "node:net";
|
|
6
|
+
import dgram from "node:dgram";
|
|
7
|
+
import fsp from "node:fs/promises";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
|
|
10
|
+
/* ------------------------------ helpers ------------------------------ */
|
|
11
|
+
|
|
12
|
+
const toInt = (v, d) => {
|
|
13
|
+
const n = Number(v);
|
|
14
|
+
return Number.isFinite(n) && n >= 0 ? n : d;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function uniqInts(arr = []) {
|
|
18
|
+
return [...new Set((arr || []).map((x) => Number(x)).filter(Number.isFinite))];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function loadConfigPortsFromServicesJson(cwd = process.cwd()) {
|
|
22
|
+
// Supports the "array schema" used by tests:
|
|
23
|
+
// { "services": [ { port, protocol }, ... ] }
|
|
24
|
+
// and a fallback object schema: { tcp: [...], udp: [...] }
|
|
25
|
+
const out = { tcp: [], udp: [] };
|
|
26
|
+
try {
|
|
27
|
+
const fp = path.join(cwd, "config", "services.json");
|
|
28
|
+
const raw = await fsp.readFile(fp, "utf8");
|
|
29
|
+
const cfg = JSON.parse(raw);
|
|
30
|
+
|
|
31
|
+
if (Array.isArray(cfg?.services)) {
|
|
32
|
+
for (const s of cfg.services) {
|
|
33
|
+
const p = Number(s?.port);
|
|
34
|
+
const proto = String(s?.protocol || "").toLowerCase();
|
|
35
|
+
if (!Number.isFinite(p)) continue;
|
|
36
|
+
if (proto === "udp") out.udp.push(p);
|
|
37
|
+
else out.tcp.push(p); // default to TCP when not specified
|
|
38
|
+
}
|
|
39
|
+
} else {
|
|
40
|
+
if (Array.isArray(cfg?.tcp)) out.tcp.push(...cfg.tcp);
|
|
41
|
+
if (Array.isArray(cfg?.udp)) out.udp.push(...cfg.udp);
|
|
42
|
+
}
|
|
43
|
+
} catch {
|
|
44
|
+
// no config, ignore
|
|
45
|
+
}
|
|
46
|
+
out.tcp = uniqInts(out.tcp);
|
|
47
|
+
out.udp = uniqInts(out.udp);
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function classifyTcpError(err) {
|
|
52
|
+
const code = err?.code || "";
|
|
53
|
+
const msg = err?.message || String(err) || "";
|
|
54
|
+
// Treat ANY "refused" (code OR message) as closed, and ensure info contains 'refused'
|
|
55
|
+
if (code === "ECONNREFUSED" || /ECONNREFUSED|refused/i.test(msg)) {
|
|
56
|
+
const suffix = code ? ` (${code})` : "";
|
|
57
|
+
return { status: "closed", info: `Connect refused${suffix}` };
|
|
58
|
+
}
|
|
59
|
+
if (code === "ETIMEDOUT" || /timed?\s*out/i.test(msg)) return { status: "filtered", info: "Timeout" };
|
|
60
|
+
if (code === "EHOSTUNREACH" || code === "ENETUNREACH") return { status: "filtered", info: "Unreachable" };
|
|
61
|
+
return { status: "filtered", info: code || "Socket error" };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/* ------------------------------ TCP scan ------------------------------ */
|
|
65
|
+
|
|
66
|
+
async function scanTcpPort(host, port, { timeoutMs, bannerTimeoutMs, maxBannerBytes }) {
|
|
67
|
+
return new Promise((resolve) => {
|
|
68
|
+
const started = Date.now();
|
|
69
|
+
const socket = new net.Socket();
|
|
70
|
+
let banner = Buffer.alloc(0);
|
|
71
|
+
let done = false;
|
|
72
|
+
let bannerTimer = null;
|
|
73
|
+
|
|
74
|
+
const finish = (status, info, extra = {}) => {
|
|
75
|
+
if (done) return;
|
|
76
|
+
done = true;
|
|
77
|
+
clearTimeout(bannerTimer);
|
|
78
|
+
try { socket.destroy(); } catch {}
|
|
79
|
+
resolve({
|
|
80
|
+
probe_protocol: "tcp",
|
|
81
|
+
probe_port: port,
|
|
82
|
+
status,
|
|
83
|
+
probe_info: info || null,
|
|
84
|
+
response_banner: banner.length ? banner.toString("utf8", 0, Math.min(maxBannerBytes, banner.length)).trim() : null,
|
|
85
|
+
rtt_ms: Date.now() - started,
|
|
86
|
+
error: extra.error || null,
|
|
87
|
+
});
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
socket.setTimeout(timeoutMs);
|
|
91
|
+
socket.setNoDelay?.(true);
|
|
92
|
+
|
|
93
|
+
socket.once("connect", () => {
|
|
94
|
+
// Give services a short opportunity to greet with a banner.
|
|
95
|
+
bannerTimer = setTimeout(() => finish("open", "TCP connect success (peer closed)"), bannerTimeoutMs);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
socket.on("data", (chunk) => {
|
|
99
|
+
banner = Buffer.concat([banner, chunk]);
|
|
100
|
+
if (banner.length >= maxBannerBytes) {
|
|
101
|
+
finish("open", "TCP connect success (banner captured)");
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
socket.once("timeout", () => finish("filtered", "Timeout"));
|
|
106
|
+
|
|
107
|
+
socket.once("error", (e) => {
|
|
108
|
+
const cls = classifyTcpError(e);
|
|
109
|
+
finish(cls.status, cls.info, { error: e?.code || String(e) });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
socket.connect(port, host);
|
|
114
|
+
} catch (e) {
|
|
115
|
+
const cls = classifyTcpError(e);
|
|
116
|
+
finish(cls.status, cls.info, { error: e?.code || String(e) });
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/* ------------------------------ UDP scan ------------------------------ */
|
|
122
|
+
|
|
123
|
+
async function scanUdpPort(host, port, { timeoutMs, udpPayload }) {
|
|
124
|
+
return new Promise((resolve) => {
|
|
125
|
+
const started = Date.now();
|
|
126
|
+
const sock = dgram.createSocket("udp4");
|
|
127
|
+
let done = false;
|
|
128
|
+
|
|
129
|
+
const finish = (status, info) => {
|
|
130
|
+
if (done) return;
|
|
131
|
+
done = true;
|
|
132
|
+
try { sock.close(); } catch {}
|
|
133
|
+
resolve({
|
|
134
|
+
probe_protocol: "udp",
|
|
135
|
+
probe_port: port,
|
|
136
|
+
status,
|
|
137
|
+
probe_info: info || null,
|
|
138
|
+
response_banner: null,
|
|
139
|
+
rtt_ms: Date.now() - started,
|
|
140
|
+
});
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const t = setTimeout(() => finish("no-response", "No UDP response"), timeoutMs);
|
|
144
|
+
|
|
145
|
+
sock.once("error", () => {
|
|
146
|
+
clearTimeout(t);
|
|
147
|
+
finish("no-response", "UDP error/no response"); // conservative default for generic UDP ping
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
sock.once("message", () => {
|
|
151
|
+
clearTimeout(t);
|
|
152
|
+
finish("open", "UDP response");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
sock.send(udpPayload, port, host);
|
|
157
|
+
} catch {
|
|
158
|
+
clearTimeout(t);
|
|
159
|
+
finish("no-response", "UDP send error");
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/* ------------------------------- runner ------------------------------- */
|
|
165
|
+
|
|
166
|
+
export default {
|
|
167
|
+
id: "003",
|
|
168
|
+
name: "Port Scanner",
|
|
169
|
+
description: "Lightweight TCP/UDP sampler with banner sniffing. Classifies ECONNREFUSED as closed.",
|
|
170
|
+
priority: 30,
|
|
171
|
+
protocols: ["tcp", "udp"],
|
|
172
|
+
ports: [],
|
|
173
|
+
requirements: { host: "up" },
|
|
174
|
+
|
|
175
|
+
// run(host, _portIgnored, opts)
|
|
176
|
+
async run(host, _port = 0, opts = {}) {
|
|
177
|
+
const timeoutMs = toInt(opts.timeoutMs ?? process.env.TCP_CONNECT_TIMEOUT_MS, 1200);
|
|
178
|
+
const bannerTimeoutMs = toInt(opts.bannerTimeoutMs ?? process.env.TCP_BANNER_TIMEOUT_MS, 250);
|
|
179
|
+
const maxBannerBytes = toInt(process.env.TCP_BANNER_MAX_BYTES, 350);
|
|
180
|
+
const udpPayload = Buffer.from("hi");
|
|
181
|
+
|
|
182
|
+
// Port sources: opts first, else config/services.json, else empty (tests supply what they need)
|
|
183
|
+
let tcpPorts = Array.isArray(opts.tcpPorts) ? uniqInts(opts.tcpPorts) : [];
|
|
184
|
+
let udpPorts = Array.isArray(opts.udpPorts) ? uniqInts(opts.udpPorts) : [];
|
|
185
|
+
|
|
186
|
+
if (!tcpPorts.length && !udpPorts.length) {
|
|
187
|
+
const cfg = await loadConfigPortsFromServicesJson();
|
|
188
|
+
tcpPorts = cfg.tcp;
|
|
189
|
+
udpPorts = cfg.udp;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// If still nothing, just return empty structure
|
|
193
|
+
if (!tcpPorts.length && !udpPorts.length) {
|
|
194
|
+
return {
|
|
195
|
+
up: false,
|
|
196
|
+
program: "Unknown",
|
|
197
|
+
version: "Unknown",
|
|
198
|
+
os: null,
|
|
199
|
+
type: "port-scan",
|
|
200
|
+
tcpOpen: [],
|
|
201
|
+
tcpClosed: [],
|
|
202
|
+
tcpFiltered: [],
|
|
203
|
+
udpOpen: [],
|
|
204
|
+
udpClosed: [],
|
|
205
|
+
udpNoResponse: [],
|
|
206
|
+
data: [],
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const data = [];
|
|
211
|
+
|
|
212
|
+
// TCP scans
|
|
213
|
+
for (const p of tcpPorts) {
|
|
214
|
+
data.push(await scanTcpPort(host, p, { timeoutMs, bannerTimeoutMs, maxBannerBytes }));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// UDP scans
|
|
218
|
+
for (const p of udpPorts) {
|
|
219
|
+
data.push(await scanUdpPort(host, p, { timeoutMs, udpPayload }));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Buckets
|
|
223
|
+
const tcpOpen = data.filter(d => d.probe_protocol === "tcp" && d.status === "open").map(d => d.probe_port);
|
|
224
|
+
const tcpClosed = data.filter(d => d.probe_protocol === "tcp" && d.status === "closed").map(d => d.probe_port);
|
|
225
|
+
const tcpFiltered = data.filter(d => d.probe_protocol === "tcp" && d.status === "filtered").map(d => d.probe_port);
|
|
226
|
+
|
|
227
|
+
const udpOpen = data.filter(d => d.probe_protocol === "udp" && d.status === "open").map(d => d.probe_port);
|
|
228
|
+
const udpClosed = data.filter(d => d.probe_protocol === "udp" && d.status === "closed").map(d => d.probe_port); // typically empty
|
|
229
|
+
const udpNoResponse = data.filter(d => d.probe_protocol === "udp" && d.status === "no-response").map(d => d.probe_port);
|
|
230
|
+
|
|
231
|
+
// Consider host "up" if we saw any TCP evidence (open/closed/filtered) OR any UDP open.
|
|
232
|
+
const anyTcpEvidence = tcpOpen.length > 0 || tcpClosed.length > 0 || tcpFiltered.length > 0;
|
|
233
|
+
const anyUdpOpen = udpOpen.length > 0;
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
up: anyTcpEvidence || anyUdpOpen,
|
|
237
|
+
program: "Unknown",
|
|
238
|
+
version: "Unknown",
|
|
239
|
+
os: null,
|
|
240
|
+
type: "port-scan",
|
|
241
|
+
tcpOpen,
|
|
242
|
+
tcpClosed,
|
|
243
|
+
tcpFiltered,
|
|
244
|
+
udpOpen,
|
|
245
|
+
udpClosed,
|
|
246
|
+
udpNoResponse,
|
|
247
|
+
data,
|
|
248
|
+
};
|
|
249
|
+
},
|
|
250
|
+
};
|