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,225 @@
|
|
|
1
|
+
// plugins/tls_scanner.mjs
|
|
2
|
+
// TLS Scanner — detects which TLS protocol versions a host supports on common TLS ports.
|
|
3
|
+
// Plug-and-play: includes conclude() so the Result Concluder auto-consumes it.
|
|
4
|
+
//
|
|
5
|
+
// Env vars:
|
|
6
|
+
// TLS_SCANNER_TIMEOUT_MS default 8000
|
|
7
|
+
// TLS_SCANNER_VERSIONS CSV (e.g., "TLSv1,TLSv1.1,TLSv1.2,TLSv1.3")
|
|
8
|
+
// TLS_SCANNER_PORTS CSV of ports to scan (defaults to common TLS ports below)
|
|
9
|
+
// TLS_SCANNER_DEBUG "1"/"true" to include per-version errors in data rows
|
|
10
|
+
// TLS_SCANNER_SNI optional explicit SNI/hostname for handshake
|
|
11
|
+
// TLS_SCANNER_TLS_MODULE module id/url for TLS API (for tests), default 'node:tls'
|
|
12
|
+
//
|
|
13
|
+
// Notes:
|
|
14
|
+
// - We do not rejectUnauthorized to allow protocol negotiation without CA trust.
|
|
15
|
+
// - We set minVersion==maxVersion to force a specific handshake version.
|
|
16
|
+
// - We capture the agreed protocol (e.g., "TLSv1.2") and a representative cipher name.
|
|
17
|
+
|
|
18
|
+
import dns from 'node:dns/promises';
|
|
19
|
+
|
|
20
|
+
// Lazy TLS import — test injection allowed in non-production environments only.
|
|
21
|
+
// In production (NODE_ENV=production) only 'node:tls' is accepted.
|
|
22
|
+
const _rawTlsEnv = process.env.TLS_SCANNER_TLS_MODULE;
|
|
23
|
+
const TLS_MODULE_ID = (() => {
|
|
24
|
+
if (!_rawTlsEnv) return 'node:tls';
|
|
25
|
+
if (process.env.NODE_ENV === 'production') {
|
|
26
|
+
console.warn('[tls_scanner] TLS_SCANNER_TLS_MODULE is ignored in production');
|
|
27
|
+
return 'node:tls';
|
|
28
|
+
}
|
|
29
|
+
// In non-production (test/dev): allow any value for stub injection
|
|
30
|
+
return _rawTlsEnv;
|
|
31
|
+
})();
|
|
32
|
+
let __tlsMod;
|
|
33
|
+
async function loadTls() {
|
|
34
|
+
if (!__tlsMod) {
|
|
35
|
+
const m = await import(TLS_MODULE_ID);
|
|
36
|
+
__tlsMod = m.default ?? m; // support default/named exports
|
|
37
|
+
}
|
|
38
|
+
return __tlsMod;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const DEFAULT_PORTS = {
|
|
42
|
+
443: 'https',
|
|
43
|
+
465: 'smtps',
|
|
44
|
+
563: 'nntps',
|
|
45
|
+
993: 'imaps',
|
|
46
|
+
995: 'pop3s'
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
function parseCsvEnv(name, fallback) {
|
|
50
|
+
const v = process.env[name];
|
|
51
|
+
if (!v) return fallback;
|
|
52
|
+
const arr = String(v).split(',').map(s => s.trim()).filter(Boolean);
|
|
53
|
+
return arr.length ? arr : fallback;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function parsePortsEnv(name, fallback) {
|
|
57
|
+
const v = process.env[name];
|
|
58
|
+
if (!v) return fallback;
|
|
59
|
+
const out = {};
|
|
60
|
+
for (const tok of String(v).split(',').map(s => s.trim()).filter(Boolean)) {
|
|
61
|
+
const [p, svc] = tok.split(':').map(s => s.trim());
|
|
62
|
+
const n = Number(p);
|
|
63
|
+
if (Number.isFinite(n)) out[n] = svc || (DEFAULT_PORTS[n] || `tcp-${n}`);
|
|
64
|
+
}
|
|
65
|
+
return Object.keys(out).length ? out : fallback;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function reverseHostname(ip) {
|
|
69
|
+
try {
|
|
70
|
+
const names = await dns.reverse(ip);
|
|
71
|
+
return Array.isArray(names) && names.length ? names[0] : null;
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export default {
|
|
78
|
+
id: "011",
|
|
79
|
+
name: "TLS Scanner",
|
|
80
|
+
description: "Detects supported TLS protocol versions and ciphers on common TLS ports.",
|
|
81
|
+
priority: 350,
|
|
82
|
+
requirements: {},
|
|
83
|
+
|
|
84
|
+
async run(host, _port, opts = {}) {
|
|
85
|
+
const timeoutMs = Number(process.env.TLS_SCANNER_TIMEOUT_MS || 8000);
|
|
86
|
+
const versions = parseCsvEnv('TLS_SCANNER_VERSIONS', ['TLSv1', 'TLSv1.1', 'TLSv1.2', 'TLSv1.3']);
|
|
87
|
+
const portsMap = parsePortsEnv('TLS_SCANNER_PORTS', { ...DEFAULT_PORTS });
|
|
88
|
+
const debug = /^(1|true|yes|on)$/i.test(String(process.env.TLS_SCANNER_DEBUG || ''));
|
|
89
|
+
const sni = process.env.TLS_SCANNER_SNI || null;
|
|
90
|
+
const hostname = sni || (await reverseHostname(host)) || host;
|
|
91
|
+
const tlsApi = await loadTls();
|
|
92
|
+
|
|
93
|
+
async function checkOnePort(port, service) {
|
|
94
|
+
const result = {
|
|
95
|
+
ip: host,
|
|
96
|
+
port,
|
|
97
|
+
service,
|
|
98
|
+
supportedVersions: [],
|
|
99
|
+
ciphers: {},
|
|
100
|
+
errors: [],
|
|
101
|
+
isTLSService: false,
|
|
102
|
+
supportsOld: false,
|
|
103
|
+
hostname: hostname || null
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const check = (version) => new Promise((resolve) => {
|
|
107
|
+
let settled = false;
|
|
108
|
+
const options = {
|
|
109
|
+
host,
|
|
110
|
+
port,
|
|
111
|
+
servername: hostname,
|
|
112
|
+
rejectUnauthorized: false,
|
|
113
|
+
minVersion: version,
|
|
114
|
+
maxVersion: version
|
|
115
|
+
};
|
|
116
|
+
const socket = tlsApi.connect(options, () => {
|
|
117
|
+
if (settled) return;
|
|
118
|
+
settled = true;
|
|
119
|
+
const protocol = socket.getProtocol?.();
|
|
120
|
+
const cipher = socket.getCipher?.();
|
|
121
|
+
try { socket.end?.(); } catch {}
|
|
122
|
+
resolve({ success: true, protocol, cipher: cipher ? cipher.name : 'Unknown' });
|
|
123
|
+
});
|
|
124
|
+
socket.setTimeout?.(timeoutMs);
|
|
125
|
+
socket.on?.('timeout', () => {
|
|
126
|
+
if (settled) return;
|
|
127
|
+
settled = true;
|
|
128
|
+
try { socket.destroy?.(); } catch {}
|
|
129
|
+
resolve({ success: false, error: 'timeout' });
|
|
130
|
+
});
|
|
131
|
+
socket.on?.('error', (err) => {
|
|
132
|
+
if (settled) return;
|
|
133
|
+
settled = true;
|
|
134
|
+
resolve({ success: false, error: err && err.message ? err.message : 'error' });
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
for (const v of versions) {
|
|
139
|
+
const res = await check(v);
|
|
140
|
+
if (res.success) {
|
|
141
|
+
const proto = res.protocol || v;
|
|
142
|
+
result.supportedVersions.push(proto);
|
|
143
|
+
result.ciphers[proto] = res.cipher;
|
|
144
|
+
}
|
|
145
|
+
if (debug) {
|
|
146
|
+
result.errors.push({ version: v, success: !!res.success, error: res.success ? 'none' : res.error });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
result.isTLSService = result.supportedVersions.length > 0;
|
|
151
|
+
result.supportsOld = result.supportedVersions.some(v => v === 'TLSv1' || v === 'TLSv1.1');
|
|
152
|
+
|
|
153
|
+
return result;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const perPort = [];
|
|
157
|
+
for (const [pStr, svc] of Object.entries(portsMap)) {
|
|
158
|
+
const p = Number(pStr);
|
|
159
|
+
try {
|
|
160
|
+
const r = await checkOnePort(p, svc);
|
|
161
|
+
perPort.push(r);
|
|
162
|
+
} catch (e) {
|
|
163
|
+
if (debug) perPort.push({ ip: host, port: p, service: svc, supportedVersions: [], ciphers: {}, errors: [{ error: String(e.message || e) }] });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Build raw result.data rows for Evidence + Concluder
|
|
168
|
+
const data = perPort.map(r => {
|
|
169
|
+
const infoBits = [];
|
|
170
|
+
if (r.supportedVersions.length) infoBits.push(`TLS: ${r.supportedVersions.join(', ')}`);
|
|
171
|
+
if (r.supportsOld) infoBits.push('OLD: yes');
|
|
172
|
+
if (r.hostname && r.hostname !== r.ip) infoBits.push(`SNI: ${r.hostname}`);
|
|
173
|
+
const probe_info = infoBits.join(' | ') || 'No TLS supported';
|
|
174
|
+
const bannerObj = { ciphers: r.ciphers, debug: debug ? r.errors : undefined };
|
|
175
|
+
return {
|
|
176
|
+
probe_protocol: 'tcp',
|
|
177
|
+
probe_port: r.port,
|
|
178
|
+
probe_service: r.service,
|
|
179
|
+
probe_info,
|
|
180
|
+
response_banner: JSON.stringify(bannerObj)
|
|
181
|
+
};
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const up = perPort.some(r => r.isTLSService);
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
up,
|
|
188
|
+
program: 'TLS',
|
|
189
|
+
version: null,
|
|
190
|
+
data
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// ---------------- Plug-and-Play concluder adapter ----------------
|
|
196
|
+
import { statusFrom } from '../utils/conclusion_utils.mjs';
|
|
197
|
+
|
|
198
|
+
export async function conclude({ host, result }) {
|
|
199
|
+
const rows = Array.isArray(result?.data) ? result.data : [];
|
|
200
|
+
const items = [];
|
|
201
|
+
for (const r of rows) {
|
|
202
|
+
const port = Number(r?.probe_port);
|
|
203
|
+
if (!Number.isFinite(port)) continue;
|
|
204
|
+
const svc = r?.probe_service || ({ 443:'https', 465:'smtps', 563:'nntps', 993:'imaps', 995:'pop3s' }[port]) || 'tls';
|
|
205
|
+
const info = r?.probe_info || null;
|
|
206
|
+
const banner = r?.response_banner || null;
|
|
207
|
+
const status = /TLS: /.test(String(info||'')) ? 'open' : 'closed';
|
|
208
|
+
items.push({
|
|
209
|
+
port,
|
|
210
|
+
protocol: 'tcp',
|
|
211
|
+
service: svc,
|
|
212
|
+
program: 'TLS',
|
|
213
|
+
version: null,
|
|
214
|
+
status,
|
|
215
|
+
info,
|
|
216
|
+
banner,
|
|
217
|
+
source: 'tls-scanner',
|
|
218
|
+
evidence: [r],
|
|
219
|
+
authoritative: true
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
return items;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export const authoritativePorts = new Set(['tcp:443','tcp:465','tcp:563','tcp:993','tcp:995']);
|
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
// plugins/upnp_scanner.mjs
|
|
2
|
+
// Enhanced UPnP Scanner — discovers UPnP devices/services in the subnet with comprehensive SSDP analysis
|
|
3
|
+
// Performs active M-SEARCH queries, detailed header analysis, and improved error handling
|
|
4
|
+
// Filters results to include only devices matching the target host IP by default.
|
|
5
|
+
// Set UPNP_INCLUDE_NON_MATCHED=1 to keep all discovered devices.
|
|
6
|
+
|
|
7
|
+
import upnp from 'node-upnp-utils';
|
|
8
|
+
import { isPrivateLike } from '../utils/net_validation.mjs';
|
|
9
|
+
|
|
10
|
+
const DEBUG = /^(1|true|yes|on)$/i.test(String(process.env.DEBUG_MODE || process.env.UPNP_DEBUG || ""));
|
|
11
|
+
function dlog(...a) { if (DEBUG) console.log("[upnp-scanner]", ...a); }
|
|
12
|
+
|
|
13
|
+
// Active M-SEARCH targets for comprehensive discovery
|
|
14
|
+
const SEARCH_TARGETS = [
|
|
15
|
+
'ssdp:all',
|
|
16
|
+
'upnp:rootdevice',
|
|
17
|
+
'urn:schemas-upnp-org:device:MediaRenderer:1',
|
|
18
|
+
'urn:schemas-upnp-org:device:MediaServer:1',
|
|
19
|
+
'urn:schemas-upnp-org:service:ContentDirectory:1',
|
|
20
|
+
'urn:schemas-upnp-org:service:ConnectionManager:1',
|
|
21
|
+
'urn:schemas-wifialliance-org:device:WFADevice:1'
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
function ipMatches(target, address) {
|
|
25
|
+
const t = String(target || "").trim();
|
|
26
|
+
const a = String(address || "").trim();
|
|
27
|
+
return t === a;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function extractOsFromServer(server) {
|
|
31
|
+
if (!server) return { os: null, version: null };
|
|
32
|
+
const s = String(server).toLowerCase();
|
|
33
|
+
|
|
34
|
+
// Check for POSIX
|
|
35
|
+
if (/posix/i.test(s)) {
|
|
36
|
+
return { os: "POSIX", version: null };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Check for Linux with version
|
|
40
|
+
const linuxMatch = s.match(/linux\s*\/?\s*([\d.]+)\b/i);
|
|
41
|
+
if (linuxMatch && linuxMatch[1]) {
|
|
42
|
+
return { os: "Linux", version: linuxMatch[1] };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Check for Windows
|
|
46
|
+
const windowsMatch = s.match(/windows\s*\/?\s*([\d.]+)\b/i);
|
|
47
|
+
if (windowsMatch && windowsMatch[1]) {
|
|
48
|
+
return { os: "Windows", version: windowsMatch[1] };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Check for other OS patterns
|
|
52
|
+
if (/android/i.test(s)) {
|
|
53
|
+
const androidMatch = s.match(/android\s*([\d.]+)/i);
|
|
54
|
+
return { os: "Android", version: androidMatch?.[1] || null };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (/darwin|macos|mac\s*os/i.test(s)) {
|
|
58
|
+
const macMatch = s.match(/darwin\s*([\d.]+)|mac\s*os\s*([\d.]+)/i);
|
|
59
|
+
return { os: "macOS", version: macMatch?.[1] || macMatch?.[2] || null };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { os: null, version: null };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function analyzeSsdpHeaders(headers, rinfo) {
|
|
66
|
+
const analysis = {
|
|
67
|
+
timestamp: new Date().toISOString(),
|
|
68
|
+
sourceIP: rinfo?.address,
|
|
69
|
+
sourcePort: rinfo?.port,
|
|
70
|
+
searchTarget: headers.ST,
|
|
71
|
+
notificationType: headers.NT,
|
|
72
|
+
uniqueServiceName: headers.USN,
|
|
73
|
+
server: headers.SERVER,
|
|
74
|
+
location: headers.LOCATION,
|
|
75
|
+
cacheControl: headers['CACHE-CONTROL'],
|
|
76
|
+
maxAge: null,
|
|
77
|
+
bootId: headers['BOOTID.UPNP.ORG'],
|
|
78
|
+
configId: headers['CONFIGID.UPNP.ORG'],
|
|
79
|
+
date: headers.DATE,
|
|
80
|
+
ext: headers.EXT,
|
|
81
|
+
opt: headers.OPT
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// Extract max-age from cache-control
|
|
85
|
+
if (analysis.cacheControl) {
|
|
86
|
+
const maxAgeMatch = analysis.cacheControl.match(/max-age\s*=\s*(\d+)/i);
|
|
87
|
+
if (maxAgeMatch) {
|
|
88
|
+
analysis.maxAge = parseInt(maxAgeMatch[1], 10);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return analysis;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function extractDeviceInfo(device, deviceXml) {
|
|
96
|
+
const info = {
|
|
97
|
+
friendlyName: null,
|
|
98
|
+
manufacturer: null,
|
|
99
|
+
manufacturerURL: null,
|
|
100
|
+
modelName: null,
|
|
101
|
+
modelNumber: null,
|
|
102
|
+
modelDescription: null,
|
|
103
|
+
serialNumber: null,
|
|
104
|
+
UDN: null,
|
|
105
|
+
deviceType: null,
|
|
106
|
+
services: []
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// Extract from device object if available
|
|
110
|
+
if (device?.description?.device) {
|
|
111
|
+
const desc = device.description.device;
|
|
112
|
+
info.friendlyName = desc.friendlyName;
|
|
113
|
+
info.manufacturer = desc.manufacturer;
|
|
114
|
+
info.manufacturerURL = desc.manufacturerURL;
|
|
115
|
+
info.modelName = desc.modelName;
|
|
116
|
+
info.modelNumber = desc.modelNumber;
|
|
117
|
+
info.modelDescription = desc.modelDescription;
|
|
118
|
+
info.serialNumber = desc.serialNumber;
|
|
119
|
+
info.UDN = desc.UDN;
|
|
120
|
+
info.deviceType = desc.deviceType;
|
|
121
|
+
|
|
122
|
+
// Extract services
|
|
123
|
+
if (desc.serviceList?.service) {
|
|
124
|
+
const services = Array.isArray(desc.serviceList.service) ?
|
|
125
|
+
desc.serviceList.service : [desc.serviceList.service];
|
|
126
|
+
info.services = services.map(svc => ({
|
|
127
|
+
serviceType: svc.serviceType,
|
|
128
|
+
serviceId: svc.serviceId,
|
|
129
|
+
controlURL: svc.controlURL,
|
|
130
|
+
eventSubURL: svc.eventSubURL,
|
|
131
|
+
SCPDURL: svc.SCPDURL
|
|
132
|
+
}));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return info;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function fetchDeviceDescription(location, timeout = 5000) {
|
|
140
|
+
if (!location) return null;
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
const { default: fetch } = await import('node-fetch');
|
|
144
|
+
const controller = new AbortController();
|
|
145
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
146
|
+
|
|
147
|
+
const response = await fetch(location, {
|
|
148
|
+
signal: controller.signal,
|
|
149
|
+
headers: {
|
|
150
|
+
'User-Agent': 'UPnP-Scanner/1.0'
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
clearTimeout(timeoutId);
|
|
155
|
+
|
|
156
|
+
if (response.ok) {
|
|
157
|
+
return await response.text();
|
|
158
|
+
} else {
|
|
159
|
+
dlog(`HTTP ${response.status} when fetching ${location}`);
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
} catch (e) {
|
|
163
|
+
if (e.name === 'AbortError') {
|
|
164
|
+
dlog("Fetch timeout for device description:", location);
|
|
165
|
+
} else {
|
|
166
|
+
dlog("Failed to fetch device description:", e?.message || e);
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function deduplicateDevices(devices) {
|
|
173
|
+
// Use Map to track unique devices by USN base (before ::)
|
|
174
|
+
const seen = new Map();
|
|
175
|
+
|
|
176
|
+
for (const device of devices) {
|
|
177
|
+
const headers = device.headers || {};
|
|
178
|
+
const usn = headers.USN || '';
|
|
179
|
+
const baseUsn = usn.split('::')[0]; // Get base USN without service type
|
|
180
|
+
const address = device.address || '';
|
|
181
|
+
|
|
182
|
+
// Create unique device signature
|
|
183
|
+
const signature = `${baseUsn}|${address}`;
|
|
184
|
+
|
|
185
|
+
if (!seen.has(signature)) {
|
|
186
|
+
// Store first occurrence
|
|
187
|
+
seen.set(signature, {
|
|
188
|
+
device,
|
|
189
|
+
searchTargets: new Set([headers.ST].filter(Boolean))
|
|
190
|
+
});
|
|
191
|
+
} else {
|
|
192
|
+
// Add search target to existing device
|
|
193
|
+
const existing = seen.get(signature);
|
|
194
|
+
if (headers.ST) {
|
|
195
|
+
existing.searchTargets.add(headers.ST);
|
|
196
|
+
}
|
|
197
|
+
// Merge any additional device info
|
|
198
|
+
if (device.description && !existing.device.description) {
|
|
199
|
+
existing.device.description = device.description;
|
|
200
|
+
}
|
|
201
|
+
dlog(`Deduplicated device: ${signature} (ST: ${headers.ST})`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Return deduplicated devices with aggregated search targets
|
|
206
|
+
return Array.from(seen.values()).map(entry => {
|
|
207
|
+
const device = {...entry.device};
|
|
208
|
+
device.searchTargets = Array.from(entry.searchTargets);
|
|
209
|
+
return device;
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function runWithUpnp(targetHost, timeoutMs, opts) {
|
|
214
|
+
let upnp;
|
|
215
|
+
if (process.env.UPNP_TEST_FAKE && globalThis.__upnpFakeFactory) {
|
|
216
|
+
upnp = globalThis.__upnpFakeFactory();
|
|
217
|
+
} else {
|
|
218
|
+
const { default: upnpModule } = await import('node-upnp-utils');
|
|
219
|
+
upnp = upnpModule;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const allDevices = [];
|
|
223
|
+
let matched = false;
|
|
224
|
+
const includeNonMatched = /^(1|true|yes|on)$/i.test(String(process.env.UPNP_INCLUDE_NON_MATCHED || ""));
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
// Perform discovery with multiple search targets
|
|
228
|
+
for (const searchTarget of SEARCH_TARGETS) {
|
|
229
|
+
try {
|
|
230
|
+
dlog(`Searching for ${searchTarget}`);
|
|
231
|
+
const devices = await upnp.discover({
|
|
232
|
+
timeout: Math.floor(timeoutMs / SEARCH_TARGETS.length),
|
|
233
|
+
st: searchTarget
|
|
234
|
+
});
|
|
235
|
+
allDevices.push(...devices);
|
|
236
|
+
dlog(`Found ${devices.length} devices for ${searchTarget}`);
|
|
237
|
+
} catch (e) {
|
|
238
|
+
dlog(`Error searching for ${searchTarget}:`, e?.message || e);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Deduplicate devices
|
|
243
|
+
const uniqueDevices = deduplicateDevices(allDevices);
|
|
244
|
+
dlog(`Total unique devices discovered: ${uniqueDevices.length}`);
|
|
245
|
+
|
|
246
|
+
const rows = [];
|
|
247
|
+
|
|
248
|
+
for (const device of uniqueDevices) {
|
|
249
|
+
const address = device.address;
|
|
250
|
+
const ipHit = ipMatches(targetHost, address);
|
|
251
|
+
const keepRow = ipHit || includeNonMatched;
|
|
252
|
+
|
|
253
|
+
if (!keepRow) continue;
|
|
254
|
+
|
|
255
|
+
const headers = device.headers || {};
|
|
256
|
+
const usn = headers.USN || 'unknown';
|
|
257
|
+
const location = headers.LOCATION || '';
|
|
258
|
+
const server = headers.SERVER || '';
|
|
259
|
+
const st = headers.ST || 'upnp:rootdevice';
|
|
260
|
+
|
|
261
|
+
// Enhanced OS detection
|
|
262
|
+
const { os, version } = extractOsFromServer(server);
|
|
263
|
+
|
|
264
|
+
// Enhanced SSDP analysis
|
|
265
|
+
const ssdpAnalysis = analyzeSsdpHeaders(headers, { address, port: 1900 });
|
|
266
|
+
|
|
267
|
+
// Fetch and parse device description if available
|
|
268
|
+
let deviceXml = null;
|
|
269
|
+
if (location) {
|
|
270
|
+
deviceXml = await fetchDeviceDescription(location, 3000);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Extract detailed device information
|
|
274
|
+
const deviceInfo = extractDeviceInfo(device, deviceXml);
|
|
275
|
+
|
|
276
|
+
// Build comprehensive info string
|
|
277
|
+
const infoParts = [];
|
|
278
|
+
infoParts.push(`type=${st}`);
|
|
279
|
+
|
|
280
|
+
if (deviceInfo.friendlyName) {
|
|
281
|
+
infoParts.push(`name="${deviceInfo.friendlyName}"`);
|
|
282
|
+
}
|
|
283
|
+
if (deviceInfo.manufacturer) {
|
|
284
|
+
infoParts.push(`manufacturer="${deviceInfo.manufacturer}"`);
|
|
285
|
+
}
|
|
286
|
+
if (deviceInfo.modelName) {
|
|
287
|
+
infoParts.push(`model="${deviceInfo.modelName}"`);
|
|
288
|
+
}
|
|
289
|
+
if (deviceInfo.modelNumber) {
|
|
290
|
+
infoParts.push(`modelNumber="${deviceInfo.modelNumber}"`);
|
|
291
|
+
}
|
|
292
|
+
if (os) {
|
|
293
|
+
infoParts.push(`os="${os}${version ? ` ${version}` : ''}"`);
|
|
294
|
+
}
|
|
295
|
+
if (ssdpAnalysis.maxAge) {
|
|
296
|
+
infoParts.push(`maxAge=${ssdpAnalysis.maxAge}s`);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
infoParts.push(`address=${address}`);
|
|
300
|
+
if (location) {
|
|
301
|
+
infoParts.push(`location=${location}`);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Enhanced banner with comprehensive data
|
|
305
|
+
const bannerObj = {
|
|
306
|
+
address,
|
|
307
|
+
headers: {
|
|
308
|
+
USN: usn,
|
|
309
|
+
SERVER: server,
|
|
310
|
+
ST: st,
|
|
311
|
+
LOCATION: location,
|
|
312
|
+
'CACHE-CONTROL': headers['CACHE-CONTROL'],
|
|
313
|
+
DATE: headers.DATE,
|
|
314
|
+
EXT: headers.EXT
|
|
315
|
+
},
|
|
316
|
+
ssdpAnalysis,
|
|
317
|
+
deviceInfo,
|
|
318
|
+
descriptionXML: deviceXml ? deviceXml.substring(0, 2000) : null, // Limit XML size
|
|
319
|
+
xmlTruncated: deviceXml && deviceXml.length > 2000
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
const row = {
|
|
323
|
+
probe_protocol: "upnp",
|
|
324
|
+
probe_port: 1900,
|
|
325
|
+
probe_info: (ipHit ? "Matched host — " : "Discovered — ") + infoParts.join(" "),
|
|
326
|
+
response_banner: JSON.stringify(bannerObj),
|
|
327
|
+
os,
|
|
328
|
+
osVersion: version,
|
|
329
|
+
ssdpHeaders: ssdpAnalysis,
|
|
330
|
+
deviceDetails: deviceInfo
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
rows.push(row);
|
|
334
|
+
|
|
335
|
+
if (ipHit) {
|
|
336
|
+
matched = true;
|
|
337
|
+
dlog(`Match detected for host ${targetHost} with device ${address}`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return { rows, matched };
|
|
342
|
+
|
|
343
|
+
} catch (e) {
|
|
344
|
+
dlog("UPnP discovery error:", e?.message || e);
|
|
345
|
+
return { rows: [], matched: false };
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export default {
|
|
350
|
+
id: "028",
|
|
351
|
+
name: "Enhanced UPnP Scanner",
|
|
352
|
+
description: "Comprehensive UPnP/SSDP discovery with active M-SEARCH probing, detailed header analysis, and enhanced device fingerprinting. Returns only instances matching the target host IP by default.",
|
|
353
|
+
priority: 346,
|
|
354
|
+
requirements: {},
|
|
355
|
+
protocols: ["upnp", "ssdp"],
|
|
356
|
+
ports: [1900],
|
|
357
|
+
runStrategy: "single",
|
|
358
|
+
|
|
359
|
+
async run(host, _port = 1900, opts = {}) {
|
|
360
|
+
const timeoutMs = Number(opts.timeoutMs ?? process.env.NSA_UPNP_TIMEOUT_MS ?? 15000); // Increased timeout
|
|
361
|
+
const data = [];
|
|
362
|
+
|
|
363
|
+
if (!isPrivateLike(host)) {
|
|
364
|
+
data.push({
|
|
365
|
+
probe_protocol: "upnp",
|
|
366
|
+
probe_port: 1900,
|
|
367
|
+
probe_info: "Non-local target — UPnP/SSDP not attempted (requires local network)",
|
|
368
|
+
response_banner: null
|
|
369
|
+
});
|
|
370
|
+
return {
|
|
371
|
+
up: false,
|
|
372
|
+
program: "UPnP/SSDP",
|
|
373
|
+
version: "Unknown",
|
|
374
|
+
os: null,
|
|
375
|
+
type: "upnp",
|
|
376
|
+
data
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const { rows, matched } = await runWithUpnp(host, timeoutMs, opts);
|
|
381
|
+
dlog(`Discovery complete: matched=${matched}, rows.length=${rows.length}`);
|
|
382
|
+
|
|
383
|
+
if (rows.length === 0) {
|
|
384
|
+
data.push({
|
|
385
|
+
probe_protocol: "upnp",
|
|
386
|
+
probe_port: 1900,
|
|
387
|
+
probe_info: "No UPnP/SSDP devices relevant to target IP discovered within timeout window",
|
|
388
|
+
response_banner: JSON.stringify({
|
|
389
|
+
searchTargets: SEARCH_TARGETS,
|
|
390
|
+
timeout: timeoutMs,
|
|
391
|
+
reason: "No responses received"
|
|
392
|
+
})
|
|
393
|
+
});
|
|
394
|
+
} else {
|
|
395
|
+
// Sort rows by relevance (matched first, then by device type)
|
|
396
|
+
rows.sort((a, b) => {
|
|
397
|
+
if (a.probe_info.includes("Matched host") && !b.probe_info.includes("Matched host")) return -1;
|
|
398
|
+
if (!a.probe_info.includes("Matched host") && b.probe_info.includes("Matched host")) return 1;
|
|
399
|
+
return 0;
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
data.push(...rows);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Add summary row for matched devices
|
|
406
|
+
if (matched) {
|
|
407
|
+
const matchedRows = rows.filter(r => r.probe_info.includes("Matched host"));
|
|
408
|
+
const uniqueDeviceTypes = [...new Set(matchedRows.map(r => {
|
|
409
|
+
const match = r.probe_info.match(/type=([^\s]+)/);
|
|
410
|
+
return match ? match[1] : 'unknown';
|
|
411
|
+
}))];
|
|
412
|
+
|
|
413
|
+
const summaryRow = {
|
|
414
|
+
probe_protocol: "upnp",
|
|
415
|
+
probe_port: 1900,
|
|
416
|
+
probe_info: `Host ${host} confirmed via UPnP/SSDP - ${matchedRows.length} device(s) found: ${uniqueDeviceTypes.join(', ')}`,
|
|
417
|
+
response_banner: JSON.stringify({
|
|
418
|
+
summary: true,
|
|
419
|
+
matchedDevices: matchedRows.length,
|
|
420
|
+
deviceTypes: uniqueDeviceTypes,
|
|
421
|
+
discoveredAt: new Date().toISOString()
|
|
422
|
+
})
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
data.unshift(summaryRow); // Add at beginning
|
|
426
|
+
dlog(`Added summary row for ${matchedRows.length} matched devices`);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return {
|
|
430
|
+
up: matched,
|
|
431
|
+
program: "UPnP/SSDP",
|
|
432
|
+
version: "1.1-Enhanced",
|
|
433
|
+
os: matched ? rows.find(r => r.os)?.os || null : null,
|
|
434
|
+
osVersion: matched ? rows.find(r => r.osVersion)?.osVersion || null : null,
|
|
435
|
+
type: "upnp",
|
|
436
|
+
deviceCount: rows.length,
|
|
437
|
+
searchTargets: SEARCH_TARGETS,
|
|
438
|
+
data
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
};
|