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,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
+ };