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,247 @@
1
+ // plugins/ftp_banner_check.mjs
2
+ // Real plugin to connect to an FTP server (port 21) and parse the banner for program name, version, and OS hints.
3
+ // Handles banners like "220 bftpd 1.6.6 at 192.168.1.1 ready." and Pure-FTPd multi-line banners.
4
+ // Returns { up: boolean, program: string, version: string, os: string|null, data: [{ probe_protocol, probe_port, probe_info, response_banner }] }.
5
+
6
+ import net from 'node:net';
7
+
8
+ const DEBUG = /^(1|true|yes|on)$/i.test(String(process.env.DEBUG_MODE || process.env.FTP_DEBUG || ''));
9
+ function dlog(...a) { if (DEBUG) console.log('[ftp-banner-check]', ...a); }
10
+
11
+ export default {
12
+ id: '004',
13
+ name: 'FTP Banner Check',
14
+ description: 'Connects to FTP server on port 21 and retrieves the program name, version, and OS from the banner.',
15
+ priority: 40,
16
+ requirements: { host: "up", tcp_open: [21] },
17
+ protocols: ['tcp'],
18
+ ports: [21],
19
+ async run(host, port = 21) {
20
+ const anonEnabled = /^(1|true|yes|on)$/i.test(String(process.env.FTP_CHECK_ANON || ''));
21
+ dlog(`Running FTP Banner Check on ${host}:${port} (anon=${anonEnabled})`);
22
+ return new Promise((resolve) => {
23
+ const socket = new net.Socket();
24
+ let banner = '';
25
+ let up = false;
26
+ let anonState = 'banner'; // banner | send-user | send-pass | done
27
+ let anonymousLogin = false;
28
+ let bannerCollected = false;
29
+ let resolved = false;
30
+ const safeResolve = (value) => { if (resolved) return; resolved = true; resolve(value); };
31
+
32
+ socket.setTimeout(5000); // 5-second timeout
33
+
34
+ socket.on('connect', () => {
35
+ up = true; // Host is up if connection succeeds
36
+ dlog(`Connected to ${host}:${port}`);
37
+ });
38
+
39
+ function finalizeBanner() {
40
+ const bannerTrimmed = banner.trim();
41
+ let program = 'Unknown';
42
+ let version = 'Unknown';
43
+ let os = null;
44
+ let probe_info = 'No recognizable FTP banner';
45
+
46
+ const singleLineMatch = bannerTrimmed.match(/220[- ]?([a-zA-Z0-9]+)\s+([0-9.]+)(?:\s+.*)?$/i) ||
47
+ bannerTrimmed.match(/220[- ]?\((.*?)\s+([0-9.]+)\)/i);
48
+ const pureFtpdMatch = bannerTrimmed.match(/220[-]+ Welcome to ([a-zA-Z0-9-]+)(?:\s+\[.*?\])?[- ]+/i);
49
+
50
+ if (singleLineMatch) {
51
+ program = singleLineMatch[1] || 'Unknown';
52
+ version = singleLineMatch[2] || 'Unknown';
53
+ probe_info = `Detected FTP server: ${program} ${version}`;
54
+ dlog(`Parsed banner: program=${program}, version=${version}`);
55
+ } else if (pureFtpdMatch) {
56
+ program = pureFtpdMatch[1] || 'Unknown';
57
+ probe_info = `Detected FTP server: ${program} (version not specified in banner)`;
58
+ dlog(`Parsed banner: program=${program}, version=Unknown`);
59
+ }
60
+
61
+ if (bannerTrimmed.toLowerCase().includes('unix') || program.toLowerCase().includes('pure-ftpd') || program.toLowerCase().includes('vsftpd') || program.toLowerCase().includes('bftpd')) {
62
+ os = 'Linux';
63
+ dlog(`OS detected: ${os} from banner or program`);
64
+ } else if (bannerTrimmed.toLowerCase().includes('windows') || program.toLowerCase().includes('filezilla')) {
65
+ os = 'Windows';
66
+ dlog(`OS detected: ${os} from banner or program`);
67
+ }
68
+
69
+ return { bannerTrimmed, program, version, os, probe_info };
70
+ }
71
+
72
+ function buildResult(parsed) {
73
+ const evidenceRows = [{
74
+ probe_protocol: 'tcp',
75
+ probe_port: port,
76
+ probe_info: parsed.probe_info,
77
+ response_banner: parsed.bannerTrimmed || null
78
+ }];
79
+
80
+ if (anonEnabled && anonymousLogin) {
81
+ evidenceRows.push({
82
+ probe_protocol: 'tcp',
83
+ probe_port: port,
84
+ probe_info: 'SECURITY FINDING: Anonymous FTP login permitted',
85
+ response_banner: parsed.bannerTrimmed || null
86
+ });
87
+ }
88
+
89
+ const result = {
90
+ up,
91
+ program: parsed.program,
92
+ version: parsed.version,
93
+ os: parsed.os,
94
+ data: evidenceRows
95
+ };
96
+
97
+ if (anonEnabled) {
98
+ result.anonymousLogin = anonymousLogin;
99
+ }
100
+
101
+ dlog(`FTP Banner Check result: up=${up}, program=${parsed.program}, version=${parsed.version}, os=${parsed.os}, anonymousLogin=${anonymousLogin}, banner=${parsed.bannerTrimmed || 'none'}`);
102
+ return result;
103
+ }
104
+
105
+ const MAX_BANNER = 65536;
106
+ socket.on('data', (data) => {
107
+ const chunk = data.toString();
108
+
109
+ if (anonState === 'banner') {
110
+ if (banner.length > MAX_BANNER) { socket.destroy(); return; }
111
+ banner += chunk;
112
+ if (bannerCollected) return; // already scheduled
113
+ bannerCollected = true;
114
+ // Collect banner for up to 1 second to handle multi-line banners like Pure-FTPd
115
+ setTimeout(() => {
116
+ if (anonEnabled && banner.trim().startsWith('220')) {
117
+ anonState = 'send-user';
118
+ dlog('Starting anonymous login check');
119
+ socket.write('USER anonymous\r\n');
120
+ // Set a separate timeout for the anonymous check
121
+ setTimeout(() => {
122
+ if (anonState !== 'done') {
123
+ dlog('Anonymous login check timed out');
124
+ anonState = 'done';
125
+ socket.end();
126
+ }
127
+ }, 3000);
128
+ } else {
129
+ socket.end();
130
+ }
131
+ }, 1000);
132
+ } else if (anonState === 'send-user') {
133
+ const response = chunk.trim();
134
+ dlog(`Anon USER response: ${response}`);
135
+ if (response.startsWith('331')) {
136
+ anonState = 'send-pass';
137
+ socket.write('PASS anonymous@audit.local\r\n');
138
+ } else if (response.startsWith('230')) {
139
+ // Logged in without password
140
+ anonymousLogin = true;
141
+ anonState = 'done';
142
+ socket.end();
143
+ } else {
144
+ // Unexpected response, treat as denied
145
+ anonState = 'done';
146
+ socket.end();
147
+ }
148
+ } else if (anonState === 'send-pass') {
149
+ const response = chunk.trim();
150
+ dlog(`Anon PASS response: ${response}`);
151
+ if (response.startsWith('230')) {
152
+ anonymousLogin = true;
153
+ }
154
+ // 530/421 or anything else = denied
155
+ anonState = 'done';
156
+ socket.end();
157
+ }
158
+ });
159
+
160
+ socket.on('end', () => {
161
+ const parsed = finalizeBanner();
162
+ safeResolve(buildResult(parsed));
163
+ });
164
+
165
+ socket.on('timeout', () => {
166
+ socket.destroy();
167
+ dlog(`FTP connection to ${host}:${port} timed out`);
168
+ safeResolve({
169
+ up: false,
170
+ program: 'Unknown',
171
+ version: 'Unknown',
172
+ os: null,
173
+ data: [{
174
+ probe_protocol: 'tcp',
175
+ probe_port: port,
176
+ probe_info: 'Connection timed out',
177
+ response_banner: null
178
+ }]
179
+ });
180
+ });
181
+
182
+ socket.on('error', (err) => {
183
+ socket.destroy();
184
+ let probe_info = `Connection error: ${err.message}`;
185
+ if (err.code === 'ECONNREFUSED') {
186
+ up = true; // Host is up but port is closed
187
+ probe_info = 'Connection refused - host up, FTP port closed';
188
+ dlog(`FTP connection to ${host}:${port} refused - host up`);
189
+ } else {
190
+ dlog(`FTP connection error to ${host}:${port}: ${err.message}`);
191
+ }
192
+ safeResolve({
193
+ up,
194
+ program: 'Unknown',
195
+ version: 'Unknown',
196
+ os: null,
197
+ data: [{
198
+ probe_protocol: 'tcp',
199
+ probe_port: port,
200
+ probe_info,
201
+ response_banner: null
202
+ }]
203
+ });
204
+ });
205
+
206
+ dlog(`Attempting TCP connection to ${host}:${port}`);
207
+ socket.connect(port, host);
208
+ });
209
+ }
210
+ };
211
+
212
+ // Plug-and-play adapter for the concluder
213
+ import { statusFrom } from '../utils/conclusion_utils.mjs';
214
+
215
+ export async function conclude({ host, result }) {
216
+ const rows = Array.isArray(result?.data) ? result.data : [];
217
+ // Prefer first meaningful row
218
+ const r = rows.find(x => x && (x.probe_port != null)) || rows[0] || {};
219
+ const info = r?.probe_info || null;
220
+ const banner = r?.response_banner || null;
221
+
222
+ // Derive status: treat ECONNREFUSED/refused as 'closed'; banner '220' as 'open'
223
+ let status = statusFrom({ info, banner });
224
+ if (status === 'unknown' && result?.up === true && banner) status = 'open';
225
+
226
+ const record = {
227
+ port: Number(r?.probe_port ?? 21),
228
+ protocol: String(r?.probe_protocol || 'tcp'),
229
+ service: 'ftp',
230
+ program: result?.program || 'Unknown',
231
+ version: result?.version || 'Unknown',
232
+ status,
233
+ info,
234
+ banner,
235
+ source: 'ftp',
236
+ evidence: rows,
237
+ authoritative: true
238
+ };
239
+
240
+ if (result?.anonymousLogin != null) {
241
+ record.anonymousLogin = result.anonymousLogin;
242
+ }
243
+
244
+ return [record];
245
+ }
246
+
247
+ export const authoritativePorts = new Set(['tcp:21']);
@@ -0,0 +1,337 @@
1
+ // plugins/host_up_check.mjs
2
+ // Real plugin to check if a host is up or down using ICMP (ping), TCP (common ports), and UDP (high closed port) probes.
3
+ // Updated to prioritize ICMP TTL-based OS detection, with TCP probes refining ambiguous TTLs (e.g., TTL 64) using banners and port heuristics.
4
+ // Extracts router name and version from port 443 banner (e.g., Netgear R8000) and includes in result.
5
+ // Returns { up: boolean, os: string|null, router_info: { name: string|null, version: string|null }|null, data: [{ probe_protocol, probe_port, probe_info, response_banner }] }.
6
+
7
+ import { promisify } from 'node:util';
8
+ import { execFile } from 'node:child_process';
9
+ import net from 'node:net';
10
+ import dgram from 'node:dgram';
11
+
12
+ const execFileP = promisify(execFile);
13
+
14
+ const DEBUG = /^(1|true|yes|on)$/i.test(String(process.env.DEBUG_MODE || process.env.HOST_UP_DEBUG || ''));
15
+ function dlog(...a) { if (DEBUG) console.log('[host-up-check]', ...a); }
16
+
17
+ /** Validate host string to prevent command injection. */
18
+ function isValidHost(h) {
19
+ if (!h || typeof h !== "string") return false;
20
+ return /^[a-zA-Z0-9.:_\-\[\]%]+$/.test(h);
21
+ }
22
+
23
+ // TTL to OS mapping based on provided data (filtered for ICMP, removed Netgear FVG318)
24
+ const TTL_OS_MAPPING = [
25
+ { Device_OS: "AIX", TTL: 255, Protocol: "ICMP" },
26
+ { Device_OS: "BSDI", TTL: 255, Protocol: "ICMP" },
27
+ { Device_OS: "Compa", TTL: 64, Protocol: "ICMP" },
28
+ { Device_OS: "Cisco", TTL: 254, Protocol: "ICMP" },
29
+ { Device_OS: "Foundry", TTL: 64, Protocol: "ICMP" },
30
+ { Device_OS: "FreeBSD", TTL: 255, Protocol: "ICMP" },
31
+ { Device_OS: "FreeBSD", TTL: 64, Protocol: "ICMP" },
32
+ { Device_OS: "HP-UX", TTL: 255, Protocol: "ICMP" },
33
+ { Device_OS: "Irix", TTL: 255, Protocol: "ICMP" },
34
+ { Device_OS: "juniper", TTL: 64, Protocol: "ICMP" },
35
+ { Device_OS: "MPE/IX (HP)", TTL: 200, Protocol: "ICMP" },
36
+ { Device_OS: "Linux", TTL: 64, Protocol: "ICMP" },
37
+ { Device_OS: "Linux", TTL: 255, Protocol: "ICMP" },
38
+ { Device_OS: "MacOS/MacTCP", TTL: 64, Protocol: "ICMP/TCP/UDP" },
39
+ { Device_OS: "NetBSD", TTL: 255, Protocol: "ICMP" },
40
+ { Device_OS: "OpenBSD", TTL: 255, Protocol: "ICMP" },
41
+ { Device_OS: "OpenVMS", TTL: 255, Protocol: "ICMP" },
42
+ { Device_OS: "Solaris", TTL: 255, Protocol: "ICMP" },
43
+ { Device_OS: "Stratus", TTL: 255, Protocol: "ICMP" },
44
+ { Device_OS: "SunOS", TTL: 255, Protocol: "ICMP" },
45
+ { Device_OS: "Ultrix", TTL: 255, Protocol: "ICMP" },
46
+ { Device_OS: "Windows", TTL: 32, Protocol: "ICMP" },
47
+ { Device_OS: "Windows", TTL: 128, Protocol: "ICMP" },
48
+ { Device_OS: "Android", TTL: 64, Protocol: "TCP and ICMP" }
49
+ ];
50
+
51
+ // Prioritize Linux for router-like devices with TTL 64
52
+ const OS_SPECIFICITY = {
53
+ "Linux": 10, // High for servers/routers with FTP/HTTP/HTTPS
54
+ "MacOS/MacTCP": 9, // High for desktops
55
+ "Android": 8,
56
+ "FreeBSD": 7,
57
+ "juniper": 6,
58
+ "Cisco": 6,
59
+ "Foundry": 5,
60
+ "Windows": 4,
61
+ "AIX": 3,
62
+ "BSDI": 3,
63
+ "Compa": 3,
64
+ "HP-UX": 3,
65
+ "Irix": 3,
66
+ "MPE/IX (HP)": 3,
67
+ "NetBSD": 3,
68
+ "OpenBSD": 3,
69
+ "OpenVMS": 3,
70
+ "Solaris": 3,
71
+ "Stratus": 3,
72
+ "SunOS": 3,
73
+ "Ultrix": 3
74
+ };
75
+
76
+ export default {
77
+ id: '005',
78
+ name: 'Host Up Check',
79
+ description: 'Checks if the host is up or down using ICMP, TCP (common ports), and UDP (high closed port) probes with enhanced OS detection.',
80
+ priority: 20,
81
+ requirements: { host: "down" }, // run when ping hasn't marked it UP (ping blocked/filtered)
82
+ protocols: ['tcp', 'udp', 'icmp'],
83
+ ports: [21, 22, 80, 443, 3389, 54321],
84
+ runStrategy: 'single',
85
+ async run(host) {
86
+ if (!isValidHost(host)) {
87
+ return { up: false, os: null, router_info: null, data: [{ probe_protocol: 'icmp', probe_port: null, probe_info: 'Invalid host', response_banner: null }] };
88
+ }
89
+ dlog(`Running Host Up Check on ${host}`);
90
+ const data = [];
91
+ let up = false;
92
+ let os = null;
93
+ let router_info = null; // { name: string|null, version: string|null }
94
+ let icmpCandidates = []; // Store ICMP candidates for TCP refinement
95
+
96
+ // ICMP Probe (using system ping)
97
+ try {
98
+ const isWindows = process.platform === 'win32';
99
+ const pingArgs = isWindows ? ['-n', '1', '-w', '1000', host] : ['-c', '1', '-W', '1', host];
100
+ dlog(`Executing ping command: ping ${pingArgs.join(' ')}`);
101
+ const { stdout } = await execFileP('ping', pingArgs, { windowsHide: true, timeout: 6000 });
102
+ const received = isWindows ? stdout.includes('Reply from') : stdout.includes('1 received');
103
+ let probe_info = received ? 'Ping successful' : 'Ping failed';
104
+ if (received) {
105
+ up = true;
106
+ // Enhanced OS detection from TTL
107
+ const ttlMatch = stdout.match(/ttl=(\d+)/i);
108
+ if (ttlMatch) {
109
+ const ttl = parseInt(ttlMatch[1], 10);
110
+ icmpCandidates = TTL_OS_MAPPING.filter(entry => entry.TTL === ttl && entry.Protocol.includes('ICMP'));
111
+ dlog(`ICMP TTL ${ttl} candidates: ${icmpCandidates.map(c => c.Device_OS).join(', ')}`);
112
+ if (icmpCandidates.length > 0) {
113
+ // Choose the most specific OS based on OS_SPECIFICITY
114
+ os = icmpCandidates.reduce((best, curr) =>
115
+ (OS_SPECIFICITY[curr.Device_OS] || 0) > (OS_SPECIFICITY[best.Device_OS] || 0) ? curr : best
116
+ ).Device_OS;
117
+ probe_info = `Ping successful (TTL: ${ttl}, OS: ${os})`;
118
+ }
119
+ }
120
+ }
121
+ data.push({
122
+ probe_protocol: 'icmp',
123
+ probe_port: null,
124
+ probe_info,
125
+ response_banner: null
126
+ });
127
+ } catch (err) {
128
+ if (DEBUG) console.error('[host-up-check]', `ICMP probe error: ${err.message}`);
129
+ data.push({
130
+ probe_protocol: 'icmp',
131
+ probe_port: null,
132
+ probe_info: `Ping error: ${err.message}`,
133
+ response_banner: null
134
+ });
135
+ }
136
+
137
+ // TCP Probes (common ports)
138
+ const tcpPorts = [21, 22, 80, 443, 3389];
139
+ for (const port of tcpPorts) {
140
+ await new Promise((resolve) => {
141
+ dlog(`Attempting TCP probe on ${host}:${port}`);
142
+ const socket = new net.Socket();
143
+ socket.setTimeout(2000);
144
+ let banner = '';
145
+
146
+ socket.on('connect', () => {
147
+ up = true;
148
+ const MAX_BANNER = 4096;
149
+ socket.on('data', (d) => { if (banner.length < MAX_BANNER) banner += d.toString(); });
150
+ setTimeout(() => {
151
+ // OS detection from banner or port, only if ICMP is ambiguous
152
+ let newOs = null;
153
+ let newRouterInfo = null;
154
+ if (banner.toLowerCase().includes('unix') || banner.toLowerCase().includes('linux') || banner.toLowerCase().includes('bftpd')) {
155
+ newOs = 'Linux';
156
+ dlog(`TCP probe port ${port}: Detected ${newOs} from banner: ${banner.trim()}`);
157
+ } else if (banner.toLowerCase().includes('windows') || banner.includes('Microsoft')) {
158
+ newOs = 'Windows';
159
+ dlog(`TCP probe port ${port}: Detected ${newOs} from banner: ${banner.trim()}`);
160
+ } else if (banner.toLowerCase().includes('netgear')) {
161
+ newOs = 'Linux'; // Netgear routers typically run Linux
162
+ // Extract router name and version (e.g., Netgear R8000)
163
+ const routerMatch = banner.match(/(netgear)\s+([^\s]+)/i);
164
+ if (routerMatch) {
165
+ newRouterInfo = { name: routerMatch[1], version: routerMatch[2] };
166
+ dlog(`TCP probe port ${port}: Detected router ${newRouterInfo.name} ${newRouterInfo.version} from banner: ${banner.trim()}`);
167
+ } else {
168
+ newRouterInfo = { name: 'Netgear', version: null };
169
+ dlog(`TCP probe port ${port}: Detected generic Netgear router from banner: ${banner.trim()}`);
170
+ }
171
+ } else if (port === 3389) {
172
+ newOs = 'Windows'; // RDP port suggests Windows
173
+ } else if (port === 22) {
174
+ newOs = 'MacOS/MacTCP'; // SSH port suggests MacOS on desktops
175
+ } else if (port === 21 || port === 80 || port === 443) {
176
+ newOs = 'Linux'; // FTP/HTTP/HTTPS ports suggest Linux on routers
177
+ dlog(`TCP probe port ${port}: Detected ${newOs} from open port (router heuristic)`);
178
+ }
179
+ // Refine os if ICMP set an ambiguous TTL (e.g., 64) or newOs is more specific
180
+ if (newOs && (!os || (icmpCandidates.length > 1 && (OS_SPECIFICITY[newOs] || 0) >= (OS_SPECIFICITY[os] || 0)))) {
181
+ dlog(`TCP probe port ${port}: Setting os=${newOs} (ICMP ambiguous or less specific, TTL ${icmpCandidates[0]?.TTL})`);
182
+ os = newOs;
183
+ if (newRouterInfo) {
184
+ router_info = newRouterInfo;
185
+ }
186
+ } else {
187
+ dlog(`TCP probe port ${port}: Keeping os=${os} (newOs=${newOs})`);
188
+ }
189
+ data.push({
190
+ probe_protocol: 'tcp',
191
+ probe_port: port,
192
+ probe_info: 'Connection successful',
193
+ response_banner: banner.trim() || null
194
+ });
195
+ socket.destroy();
196
+ resolve();
197
+ }, 1000);
198
+ });
199
+
200
+ socket.on('error', (err) => {
201
+ if (err.code === 'ECONNREFUSED') {
202
+ up = true;
203
+ let newOs = null;
204
+ if (port === 3389) {
205
+ newOs = 'Windows'; // Refused RDP port suggests Windows
206
+ } else if (port === 22) {
207
+ newOs = 'MacOS/MacTCP'; // Refused SSH port suggests MacOS
208
+ }
209
+ // Refine os if ICMP set an ambiguous TTL (e.g., 64) or newOs is more specific
210
+ if (newOs && (!os || (icmpCandidates.length > 1 && (OS_SPECIFICITY[newOs] || 0) >= (OS_SPECIFICITY[os] || 0)))) {
211
+ dlog(`TCP probe port ${port}: Setting os=${newOs} (ICMP ambiguous or less specific, TTL ${icmpCandidates[0]?.TTL})`);
212
+ os = newOs;
213
+ } else {
214
+ dlog(`TCP probe port ${port}: Keeping os=${os} (newOs=${newOs})`);
215
+ }
216
+ data.push({
217
+ probe_protocol: 'tcp',
218
+ probe_port: port,
219
+ probe_info: 'Connection refused - host up',
220
+ response_banner: null
221
+ });
222
+ } else {
223
+ data.push({
224
+ probe_protocol: 'tcp',
225
+ probe_port: port,
226
+ probe_info: `Error: ${err.code} - ${err.message}`,
227
+ response_banner: null
228
+ });
229
+ }
230
+ resolve();
231
+ });
232
+
233
+ socket.on('timeout', () => {
234
+ data.push({
235
+ probe_protocol: 'tcp',
236
+ probe_port: port,
237
+ probe_info: 'Timeout',
238
+ response_banner: null
239
+ });
240
+ socket.destroy();
241
+ resolve();
242
+ });
243
+
244
+ socket.connect(port, host);
245
+ });
246
+ }
247
+
248
+ // UDP Probe (send to likely closed high port)
249
+ const udpPort = 54321;
250
+ await new Promise((resolve) => {
251
+ dlog(`Attempting UDP probe on ${host}:${udpPort}`);
252
+ const socket = dgram.createSocket('udp4');
253
+ const timeoutId = setTimeout(() => {
254
+ data.push({
255
+ probe_protocol: 'udp',
256
+ probe_port: udpPort,
257
+ probe_info: 'Timeout - host possibly down',
258
+ response_banner: null
259
+ });
260
+ socket.close();
261
+ resolve();
262
+ }, 3000);
263
+
264
+ socket.on('error', (err) => {
265
+ clearTimeout(timeoutId);
266
+ if (err.code === 'ECONNREFUSED') {
267
+ up = true;
268
+ data.push({
269
+ probe_protocol: 'udp',
270
+ probe_port: udpPort,
271
+ probe_info: 'ICMP Port Unreachable - host up',
272
+ response_banner: null
273
+ });
274
+ } else {
275
+ data.push({
276
+ probe_protocol: 'udp',
277
+ probe_port: udpPort,
278
+ probe_info: `Error: ${err.code} - ${err.message}`,
279
+ response_banner: null
280
+ });
281
+ }
282
+ socket.close();
283
+ resolve();
284
+ });
285
+
286
+ socket.connect(udpPort, host, (err) => {
287
+ if (err) {
288
+ clearTimeout(timeoutId);
289
+ data.push({
290
+ probe_protocol: 'udp',
291
+ probe_port: udpPort,
292
+ probe_info: `Connect error: ${err.message}`,
293
+ response_banner: null
294
+ });
295
+ socket.close();
296
+ resolve();
297
+ return;
298
+ }
299
+
300
+ socket.send(Buffer.alloc(0), (err) => {
301
+ clearTimeout(timeoutId);
302
+ if (err) {
303
+ if (err.code === 'ECONNREFUSED') {
304
+ up = true;
305
+ data.push({
306
+ probe_protocol: 'udp',
307
+ probe_port: udpPort,
308
+ probe_info: 'ICMP Port Unreachable - host up',
309
+ response_banner: null
310
+ });
311
+ } else {
312
+ data.push({
313
+ probe_protocol: 'udp',
314
+ probe_port: udpPort,
315
+ probe_info: `Send error: ${err.message}`,
316
+ response_banner: null
317
+ });
318
+ }
319
+ } else {
320
+ up = true;
321
+ data.push({
322
+ probe_protocol: 'udp',
323
+ probe_port: udpPort,
324
+ probe_info: 'Send successful, no error - host up (port may be open)',
325
+ response_banner: null
326
+ });
327
+ }
328
+ socket.close();
329
+ resolve();
330
+ });
331
+ });
332
+ });
333
+
334
+ dlog(`Host Up Check result: up=${up}, os=${os}, router_info=${JSON.stringify(router_info)}, data=${JSON.stringify(data)}`);
335
+ return { up, os, router_info, data };
336
+ }
337
+ };