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,246 @@
1
+ // plugins/webapp_detector.mjs
2
+ // New plugin: Webapp Detector
3
+ // Uses `simple-wappalyzer` to fingerprint web applications present on a host.
4
+ // Tries HTTPS first (port 443), then HTTP (port 80), and can also try custom ports via opts.ports.
5
+ // NOTE: Unlike http_probe, undici/fetch cannot ignore TLS easily per-request, so self-signed
6
+ // HTTPS will usually fail and the plugin will fall back to HTTP.
7
+ //
8
+ // Add to package.json (dependencies):
9
+ // "simple-wappalyzer": "^1.14.0" // or latest
10
+ //
11
+ // Example use (plugin manager):
12
+ // webappDetector.run("192.168.1.1")
13
+ //
14
+ // Result shape example:
15
+ // {
16
+ // up: true,
17
+ // program: "WordPress + Nginx",
18
+ // version: "Unknown",
19
+ // os: null,
20
+ // type: "webapp",
21
+ // data: [
22
+ // {
23
+ // probe_protocol: "https",
24
+ // probe_port: 443,
25
+ // probe_info: "Detected web apps: WordPress, Nginx",
26
+ // response_banner: "200 OK\r\nserver: nginx\r\nx-powered-by: PHP/8.2"
27
+ // }
28
+ // ],
29
+ // apps: [ { name, categories, confidence, version?, slug, ... }, ... ]
30
+ // }
31
+
32
+ import wappalyzer from 'simple-wappalyzer';
33
+
34
+ const DEBUG =
35
+ String(process.env.DEBUG_MODE || '').toLowerCase() === '1' ||
36
+ String(process.env.DEBUG_MODE || '').toLowerCase() === 'true';
37
+
38
+ function log(...args) {
39
+ if (DEBUG) console.log('[webapp-detector]', ...args);
40
+ }
41
+
42
+ function parseExtraHeaders() {
43
+ try {
44
+ if (!process.env.HTTP_EXTRA_HEADERS) return {};
45
+ const h = JSON.parse(process.env.HTTP_EXTRA_HEADERS);
46
+ return h && typeof h === 'object' ? h : {};
47
+ } catch {
48
+ return {};
49
+ }
50
+ }
51
+
52
+ function buildBanner(statusCode, headers) {
53
+ const lines = [];
54
+ const statusLine = `${statusCode || 0}`;
55
+ lines.push(statusLine + (headers['status-message'] ? ' ' + headers['status-message'] : ''));
56
+ const pick = ['server', 'x-powered-by', 'www-authenticate', 'content-type', 'location', 'set-cookie'];
57
+ for (const k of pick) {
58
+ const v = headers[k];
59
+ if (!v) continue;
60
+ if (Array.isArray(v)) {
61
+ for (const vv of v) lines.push(`${k}: ${vv}`);
62
+ } else {
63
+ lines.push(`${k}: ${v}`);
64
+ }
65
+ }
66
+ return lines.join('\\r\\n');
67
+ }
68
+
69
+ function normalizeTarget(target) {
70
+ if (!target) return null;
71
+ if (typeof target === 'string') return target.replace(/^https?:\/\//i, '').split('/')[0];
72
+ return (target.host || target.hostname || target.name || '').replace(/^https?:\/\//i, '').split('/')[0];
73
+ }
74
+
75
+ async function fetchOnce(url, signal) {
76
+ const extra = parseExtraHeaders();
77
+ const headers = {
78
+ 'User-Agent': 'Mozilla/5.0 (compatible; NetworkSecurityAuditor/1.18.0; +https://example.invalid)',
79
+ DNT: '1',
80
+ ...extra,
81
+ };
82
+ // global fetch (undici) is available in Node >=18
83
+ const res = await fetch(url, { redirect: 'follow', headers, signal });
84
+ const finalUrl = res.url || url;
85
+ const statusCode = res.status;
86
+ const rawHeaders = {};
87
+ res.headers.forEach((v, k) => (rawHeaders[k.toLowerCase()] = v));
88
+ const html = await res.text();
89
+ return { url: finalUrl, statusCode, headers: rawHeaders, html };
90
+ }
91
+
92
+ async function tryDetectAt(url) {
93
+ const ctrl = new AbortController();
94
+ const timeoutMs = Number(process.env.WAPPALYZER_TIMEOUT_MS || 15000);
95
+ const t = setTimeout(() => ctrl.abort(), timeoutMs);
96
+ try {
97
+ log('fetch start —', url);
98
+ const { url: finalUrl, html, statusCode, headers } = await fetchOnce(url, ctrl.signal);
99
+ log('fetch end —', finalUrl, statusCode, `html=${html?.length ?? 0}`);
100
+ const apps = await detectFromHtml(finalUrl, html, statusCode, headers);
101
+ return { ok: true, finalUrl, statusCode, headers, apps };
102
+ } catch (e) {
103
+ log('fetch error —', url, e?.message || e);
104
+ return { ok: false, error: e };
105
+ } finally {
106
+ clearTimeout(t);
107
+ }
108
+ }
109
+
110
+ /** Run wappalyzer on provided HTML/headers. */
111
+ async function detectFromHtml(url, html, statusCode, headers) {
112
+ try {
113
+ const result = await wappalyzer({ url, html, statusCode, headers });
114
+ if (Array.isArray(result) && result.length) {
115
+ log('wappalyzer apps=', result.map(a => a.name).join(', '));
116
+ } else {
117
+ log('wappalyzer apps=∅');
118
+ }
119
+ return result || [];
120
+ } catch (e) {
121
+ log('wappalyzer error:', e?.message || e);
122
+ return [];
123
+ }
124
+ }
125
+
126
+ function summarizeApps(apps) {
127
+ if (!Array.isArray(apps) || !apps.length) return { program: null, version: 'Unknown', list: [] };
128
+ // Sort by confidence descending then name
129
+ const sorted = [...apps].sort((a, b) => (Number(b.confidence||0) - Number(a.confidence||0)) || String(a.name).localeCompare(String(b.name)));
130
+ const names = sorted.map(a => a.name).filter(Boolean);
131
+ const program = names.slice(0, 3).join(' + ') || null;
132
+ // If exactly 1 app and it has a version, expose it
133
+ const version = (sorted.length === 1 && sorted[0]?.version) ? String(sorted[0].version) : 'Unknown';
134
+ return { program, version, list: names };
135
+ }
136
+
137
+ export default {
138
+ id: '010',
139
+ name: 'Webapp Detector',
140
+ description: 'Identifies web applications and frameworks using simple-wappalyzer (tries HTTPS then HTTP).',
141
+ priority: 55, // run near HTTP probe
142
+ requirements: { host: 'up', tcp_open: [80, 443] }, // heuristic gate; still attempts both
143
+ protocols: ['tcp'],
144
+ ports: [80, 443],
145
+
146
+ /**
147
+ * @param {string} host - target hostname or IP
148
+ * @param {number} port - optional hint (ignored; detection tries 443 then 80 unless opts.ports provided)
149
+ * @param {object} opts - options: { ports?: number[] }
150
+ */
151
+ async run(host, port = 0, opts = {}) {
152
+ const result = {
153
+ up: false,
154
+ program: null,
155
+ version: 'Unknown',
156
+ os: null,
157
+ type: 'webapp',
158
+ data: [],
159
+ apps: [], // raw simple-wappalyzer results
160
+ };
161
+
162
+ let target = normalizeTarget(host);
163
+ if (!target) return result;
164
+
165
+ // Build candidate URLs
166
+ const set = new Set();
167
+ const addUrl = (proto, p) => {
168
+ const defaultPort = (proto === 'https' ? 443 : 80);
169
+ const portPart = (p && p !== defaultPort) ? `:${p}` : '';
170
+ set.add(`${proto}://${target}${portPart}/`);
171
+ };
172
+
173
+ // If specific ports given, try both schemes for each; else default to 443 then 80
174
+ const ports = Array.isArray(opts.ports) && opts.ports.length ? opts.ports : [443, 80];
175
+ for (const p of ports) {
176
+ if (p === 443) addUrl('https', 443);
177
+ else if (p === 80) addUrl('http', 80);
178
+ else { addUrl('https', p); addUrl('http', p); }
179
+ }
180
+
181
+ // Try in order added (prefers https:443, then http:80, then customs)
182
+ for (const url of set) {
183
+ const r = await tryDetectAt(url);
184
+ if (r.ok) {
185
+ result.up = true;
186
+ result.apps = r.apps || [];
187
+ const { program, version, list } = summarizeApps(result.apps);
188
+ if (program) result.program = program;
189
+ if (version) result.version = version;
190
+
191
+ // Prepare a concise banner
192
+ const proto = url.startsWith('https:') ? 'https' : 'http';
193
+ const portFromUrl = (() => {
194
+ try {
195
+ const u = new URL(url);
196
+ return Number(u.port || (u.protocol === 'https:' ? 443 : 80));
197
+ } catch { return proto === 'https' ? 443 : 80; }
198
+ })();
199
+
200
+ result.data.push({
201
+ probe_protocol: proto,
202
+ probe_port: portFromUrl,
203
+ probe_info: list.length ? `Detected web apps: ${list.join(', ')}` : `HTTP service detected (status ${r.statusCode})`,
204
+ response_banner: buildBanner(r.statusCode, r.headers),
205
+ });
206
+
207
+ // Stop after first successful detection
208
+ break;
209
+ } else {
210
+ // Log error row for visibility
211
+ const proto = url.startsWith('https:') ? 'https' : 'http';
212
+ const portFromUrl = (() => {
213
+ try { const u = new URL(url); return Number(u.port || (u.protocol === 'https:' ? 443 : 80)); } catch { return proto === 'https' ? 443 : 80; }
214
+ })();
215
+ result.data.push({
216
+ probe_protocol: proto,
217
+ probe_port: portFromUrl,
218
+ probe_info: `Webapp detect error: ${r.error?.message || String(r.error || 'unknown error')}`,
219
+ response_banner: null,
220
+ });
221
+ }
222
+ }
223
+
224
+ return result;
225
+ },
226
+ };
227
+
228
+ // Concluder adapter: emits detected apps as service records for result fusion.
229
+ export async function conclude({ host, result }) {
230
+ if (!result?.up || !Array.isArray(result.apps) || result.apps.length === 0) return [];
231
+
232
+ // Extract port from the first successful probe record
233
+ const probeRecord = result.data?.find(d => d.probe_port && d.probe_port > 0);
234
+ const port = probeRecord?.probe_port ?? 80;
235
+ const protocol = probeRecord?.probe_protocol ?? 'tcp';
236
+
237
+ // Emit one service record per detected app
238
+ return result.apps.map(app => ({
239
+ protocol,
240
+ port,
241
+ service: app.name ?? 'webapp',
242
+ version: app.version ?? null,
243
+ info: Array.isArray(app.categories) ? app.categories.join(', ') : 'webapp',
244
+ authoritative: false,
245
+ }));
246
+ }
@@ -0,0 +1,290 @@
1
+ // plugins/wsd_scanner.mjs
2
+ // Enhanced WS-Discovery Scanner — discovers WS-Discovery devices in the subnet with comprehensive analysis
3
+ // Filters results to include only devices matching the target host IP by default.
4
+ // Set WSD_INCLUDE_NON_MATCHED=1 to keep all discovered devices.
5
+
6
+ import dgram from 'dgram';
7
+ import { parseString } from 'xml2js';
8
+ import { v4 as uuidv4 } from 'uuid';
9
+ import { isPrivateLike } from '../utils/net_validation.mjs';
10
+
11
+ const DEBUG = /^(1|true|yes|on)$/i.test(String(process.env.DEBUG_MODE || process.env.WSD_DEBUG || ""));
12
+ function dlog(...a) { if (DEBUG) console.log("[wsd-scanner]", ...a); }
13
+
14
+ function ipMatches(target, address) {
15
+ const t = String(target || "").trim();
16
+ const a = String(address || "").trim();
17
+ return t === a;
18
+ }
19
+
20
+ /**
21
+ * Discovers WS-Discovery devices by sending a multicast Probe message.
22
+ * @param {string} targetHost The target host IP to filter for
23
+ * @param {number} timeout The time in milliseconds to wait for responses.
24
+ * @returns {Promise<Array<Object>>} A promise that resolves with an array of discovered devices.
25
+ */
26
+ function discoverWsDiscoveryDevices(targetHost, timeout = 5000) {
27
+ return new Promise((resolve, reject) => {
28
+ const client = dgram.createSocket('udp4');
29
+ const devices = [];
30
+ const knownAddresses = new Set();
31
+ const probeMessageId = `urn:uuid:${uuidv4()}`;
32
+ const includeNonMatched = /^(1|true|yes|on)$/i.test(String(process.env.WSD_INCLUDE_NON_MATCHED || ""));
33
+
34
+ // The multicast address and port for WS-Discovery
35
+ const multicastAddress = '239.255.255.250';
36
+ const multicastPort = 3702;
37
+
38
+ // XML-based WS-Discovery Probe message
39
+ const probeMessage = `
40
+ <s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"
41
+ xmlns:a="http://www.w3.org/2005/08/addressing"
42
+ xmlns:d="http://docs.oasis-open.org/ws-dd/ns/discovery/2009/01">
43
+ <s:Header>
44
+ <a:Action>http://docs.oasis-open.org/ws-dd/ns/discovery/2009/01/Probe</a:Action>
45
+ <a:MessageID>${probeMessageId}</a:MessageID>
46
+ <a:To>${multicastAddress}:${multicastPort}</a:To>
47
+ </s:Header>
48
+ <s:Body>
49
+ <d:Probe/>
50
+ </s:Body>
51
+ </s:Envelope>
52
+ `;
53
+
54
+ client.on('message', (msg, rinfo) => {
55
+ // Ignore messages that are not responses to our Probe
56
+ if (rinfo.address === client.address().address) return;
57
+ if (knownAddresses.has(rinfo.address)) return;
58
+
59
+ // Filter by target host unless includeNonMatched is set
60
+ const ipHit = ipMatches(targetHost, rinfo.address);
61
+ if (!ipHit && !includeNonMatched) {
62
+ dlog(`Filtering out non-matching device: ${rinfo.address} (target: ${targetHost})`);
63
+ return;
64
+ }
65
+
66
+ // Parse the XML response from the device
67
+ parseString(msg.toString(), (err, result) => {
68
+ if (err) {
69
+ dlog(`XML parse error from ${rinfo.address}:`, err.message);
70
+ return; // Silently ignore parsing errors
71
+ }
72
+
73
+ // Ensure this is a valid ProbeMatch message
74
+ try {
75
+ if (result && result['s:Envelope'] && result['s:Envelope']['s:Body'] &&
76
+ result['s:Envelope']['s:Body'][0]['d:ProbeMatches']) {
77
+ const probeMatch = result['s:Envelope']['s:Body'][0]['d:ProbeMatches'][0]['d:ProbeMatch'][0];
78
+ const xaddrs = probeMatch['d:XAddrs'] ? probeMatch['d:XAddrs'][0].split(' ') : [];
79
+
80
+ const endpointRef = probeMatch['a:EndpointReference'] && probeMatch['a:EndpointReference'][0]['a:Address']
81
+ ? probeMatch['a:EndpointReference'][0]['a:Address'][0] : 'Unknown';
82
+
83
+ const types = probeMatch['d:Types'] ? probeMatch['d:Types'][0] : 'Unknown';
84
+ const scopes = probeMatch['d:Scopes'] ? probeMatch['d:Scopes'][0] : null;
85
+ const metadataVersion = probeMatch['d:MetadataVersion'] ? probeMatch['d:MetadataVersion'][0] : null;
86
+
87
+ devices.push({
88
+ address: rinfo.address,
89
+ port: rinfo.port,
90
+ xaddrs: xaddrs,
91
+ endpointUuid: endpointRef,
92
+ types: types,
93
+ scopes: scopes,
94
+ metadataVersion: metadataVersion,
95
+ isMatched: ipHit,
96
+ timestamp: new Date().toISOString()
97
+ });
98
+ knownAddresses.add(rinfo.address);
99
+
100
+ dlog(`Discovered WS-Discovery device: ${rinfo.address} (matched: ${ipHit}), types: ${types}`);
101
+ }
102
+ } catch (parseErr) {
103
+ dlog(`Parse error processing response from ${rinfo.address}:`, parseErr.message);
104
+ }
105
+ });
106
+ });
107
+
108
+ client.on('error', (err) => {
109
+ dlog("WS-Discovery client error:", err.message);
110
+ client.close();
111
+ reject(err);
112
+ });
113
+
114
+ client.bind(() => {
115
+ try {
116
+ client.setBroadcast(true);
117
+ client.setMulticastTTL(128);
118
+
119
+ dlog(`Sending WS-Discovery probe to ${multicastAddress}:${multicastPort}`);
120
+ const buffer = Buffer.from(probeMessage);
121
+ client.send(buffer, multicastPort, multicastAddress, (err) => {
122
+ if (err) {
123
+ dlog("Failed to send WS-Discovery probe:", err.message);
124
+ client.close();
125
+ reject(err);
126
+ }
127
+ });
128
+ } catch (err) {
129
+ dlog("Error setting up WS-Discovery client:", err.message);
130
+ client.close();
131
+ reject(err);
132
+ }
133
+ });
134
+
135
+ setTimeout(() => {
136
+ dlog(`WS-Discovery timeout reached, found ${devices.length} devices`);
137
+ client.close();
138
+ resolve(devices);
139
+ }, timeout);
140
+ });
141
+ }
142
+
143
+ export default {
144
+ id: "016",
145
+ name: "Enhanced WS-Discovery Scanner",
146
+ description: "Discovers WS-Discovery enabled devices using multicast probe messages. Returns only devices matching the target host IP by default.",
147
+ priority: 400,
148
+ requirements: {},
149
+ protocols: ["udp"],
150
+ ports: [3702],
151
+ runStrategy: "single",
152
+
153
+ async run(host, port = 3702, opts = {}) {
154
+ const data = [];
155
+ let up = false;
156
+ let program = "WS-Discovery"; // Always set to WS-Discovery
157
+ let version = "1.1"; // Always set to 1.1
158
+
159
+ const timeout = opts.timeout || 5000;
160
+
161
+ if (!isPrivateLike(host)) {
162
+ data.push({
163
+ probe_protocol: "udp",
164
+ probe_port: port,
165
+ probe_info: "Non-local target — WS-Discovery not attempted (requires local network)",
166
+ response_banner: null
167
+ });
168
+ return {
169
+ up: false,
170
+ program: "WS-Discovery",
171
+ version: "1.1",
172
+ type: "wsdiscovery",
173
+ data
174
+ };
175
+ }
176
+
177
+ try {
178
+ dlog(`Starting WS-Discovery scan for host: ${host}`);
179
+ const devices = await discoverWsDiscoveryDevices(host, timeout);
180
+
181
+ const matchedDevices = devices.filter(d => d.isMatched);
182
+ const matched = matchedDevices.length > 0;
183
+
184
+ dlog(`Discovery complete: total=${devices.length}, matched=${matchedDevices.length}`);
185
+
186
+ if (devices.length > 0) {
187
+ up = matched;
188
+ // program and version already set above
189
+
190
+ // Sort devices - matched first
191
+ devices.sort((a, b) => {
192
+ if (a.isMatched && !b.isMatched) return -1;
193
+ if (!a.isMatched && b.isMatched) return 1;
194
+ return 0;
195
+ });
196
+
197
+ devices.forEach((device) => {
198
+ const xaddrsInfo = device.xaddrs.length > 0 ? device.xaddrs.join(', ') : 'No XAddrs';
199
+
200
+ // Build comprehensive info string
201
+ const infoParts = [];
202
+ if (device.isMatched) {
203
+ infoParts.push("Matched host —");
204
+ } else {
205
+ infoParts.push("Discovered —");
206
+ }
207
+ infoParts.push(`types="${device.types}"`);
208
+ infoParts.push(`address=${device.address}`);
209
+ if (device.scopes) {
210
+ infoParts.push(`scopes="${device.scopes}"`);
211
+ }
212
+ if (device.metadataVersion) {
213
+ infoParts.push(`metadataVersion=${device.metadataVersion}`);
214
+ }
215
+
216
+ const bannerObj = {
217
+ address: device.address,
218
+ endpointUuid: device.endpointUuid,
219
+ types: device.types,
220
+ xaddrs: device.xaddrs,
221
+ scopes: device.scopes,
222
+ metadataVersion: device.metadataVersion,
223
+ discoveredAt: device.timestamp,
224
+ isMatched: device.isMatched
225
+ };
226
+
227
+ data.push({
228
+ probe_protocol: 'udp',
229
+ probe_port: device.port,
230
+ probe_info: infoParts.join(' '),
231
+ response_banner: JSON.stringify(bannerObj),
232
+ device_address: device.address,
233
+ device_types: device.types,
234
+ endpoint_uuid: device.endpointUuid,
235
+ xaddrs: device.xaddrs,
236
+ scopes: device.scopes,
237
+ isMatched: device.isMatched
238
+ });
239
+ });
240
+
241
+ // Add summary row for matched devices
242
+ if (matched) {
243
+ const uniqueTypes = [...new Set(matchedDevices.map(d => d.types))];
244
+ const summaryRow = {
245
+ probe_protocol: 'udp',
246
+ probe_port: port,
247
+ probe_info: `Host ${host} confirmed via WS-Discovery - ${matchedDevices.length} device(s) found: ${uniqueTypes.join(', ')}`,
248
+ response_banner: JSON.stringify({
249
+ summary: true,
250
+ matchedDevices: matchedDevices.length,
251
+ deviceTypes: uniqueTypes,
252
+ discoveredAt: new Date().toISOString()
253
+ })
254
+ };
255
+ data.unshift(summaryRow);
256
+ }
257
+ } else {
258
+ data.push({
259
+ probe_protocol: 'udp',
260
+ probe_port: port,
261
+ probe_info: 'No WS-Discovery devices relevant to target IP discovered within timeout window',
262
+ response_banner: JSON.stringify({
263
+ timeout: timeout,
264
+ reason: "No responses received"
265
+ })
266
+ });
267
+ }
268
+ } catch (error) {
269
+ dlog("WS-Discovery scan error:", error.message);
270
+ data.push({
271
+ probe_protocol: 'udp',
272
+ probe_port: port,
273
+ probe_info: 'WS-Discovery scan failed',
274
+ response_banner: JSON.stringify({
275
+ error: error.message,
276
+ timestamp: new Date().toISOString()
277
+ })
278
+ });
279
+ }
280
+
281
+ return {
282
+ up,
283
+ program,
284
+ version,
285
+ type: 'wsdiscovery',
286
+ deviceCount: data.length - (up ? 1 : 0), // Exclude summary row from count
287
+ data
288
+ };
289
+ }
290
+ };