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