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,369 @@
1
+ // plugins/dns_scanner.mjs
2
+ // DNS Scanner — UDP 53 version.bind (CHAOS/TXT) + example.com (IN/A, MX)
3
+ // Adds TCP fallback for version.bind to recover banners on servers that drop CHAOS/TXT over UDP.
4
+ // Optional AXFR zone transfer detection (TCP).
5
+ // Keeps shapes/names so existing tests and concluder continue to pass.
6
+ //
7
+ // Env:
8
+ // DNS_SCANNER_TIMEOUT_MS default 2000
9
+ // DNS_SCANNER_USE_TCP 1/true => enable TCP fallback for version.bind (default on)
10
+ // DNS_SCANNER_DEBUG 1/true => include extra error details in banners
11
+ // DNS_CHECK_AXFR 1/true => attempt AXFR zone transfer (default off)
12
+ // DNS_AXFR_DOMAIN target domain for AXFR (required when DNS_CHECK_AXFR=true)
13
+
14
+ import dgram from 'node:dgram';
15
+ import net from 'node:net';
16
+ import crypto from 'node:crypto';
17
+
18
+ const DEBUG = /^(1|true|yes|on)$/i.test(String(process.env.DNS_SCANNER_DEBUG || ''));
19
+ const TIMEOUT = Number(process.env.DNS_SCANNER_TIMEOUT_MS || 2000);
20
+ const USE_TCP = /^(1|true|yes|on)$/i.test(String(process.env.DNS_SCANNER_USE_TCP || '1'));
21
+
22
+ const QTYPE = { A:1, MX:15, TXT:16, SRV:33, AXFR:252 };
23
+ const QCLASS = { IN:1, CH:3 };
24
+
25
+ function encodeName(name){
26
+ const parts = String(name).split('.').filter(Boolean);
27
+ const bufs = [];
28
+ for (const label of parts){
29
+ const b = Buffer.from(label, 'utf8');
30
+ if (b.length > 63) throw new Error('DNS label too long');
31
+ bufs.push(Buffer.from([b.length]), b);
32
+ }
33
+ bufs.push(Buffer.from([0]));
34
+ return Buffer.concat(bufs);
35
+ }
36
+
37
+ function buildQuery({ id, qname, qtype, qclass }){
38
+ const header = Buffer.alloc(12);
39
+ header.writeUInt16BE(id & 0xffff, 0); // ID
40
+ header.writeUInt16BE(0x0100, 2); // RD=1
41
+ header.writeUInt16BE(1, 4); // QDCOUNT
42
+ // AN/NS/AR = 0
43
+ const q = Buffer.concat([ encodeName(qname), Buffer.from([0, qtype]), Buffer.from([0, qclass]) ]);
44
+ return Buffer.concat([header, q]);
45
+ }
46
+
47
+ function readName(buf, offset, depth=0){
48
+ if (depth > 10) throw new Error('name pointer loop');
49
+ const labels = [];
50
+ let i = offset;
51
+ while (true){
52
+ if (i >= buf.length) throw new Error('name OOR');
53
+ const len = buf[i];
54
+ if (len === 0){ i += 1; break; }
55
+ if ((len & 0xC0) === 0xC0){
56
+ if (i + 1 >= buf.length) throw new Error('name pointer OOB');
57
+ const ptr = ((len & 0x3F) << 8) | buf[i+1];
58
+ if (ptr >= buf.length) throw new Error('name pointer target OOB');
59
+ const [pname] = readName(buf, ptr, depth+1);
60
+ labels.push(pname);
61
+ i += 2; break;
62
+ }
63
+ if (i + 1 + len > buf.length) throw new Error('name label OOB');
64
+ const label = buf.slice(i+1, i+1+len).toString('utf8');
65
+ labels.push(label);
66
+ i += 1 + len;
67
+ }
68
+ return [labels.join('.'), i];
69
+ }
70
+
71
+ function parseResponse(buf){
72
+ if (!Buffer.isBuffer(buf) || buf.length < 12) throw new Error('bad DNS response');
73
+ const id = buf.readUInt16BE(0);
74
+ const flags = buf.readUInt16BE(2);
75
+ const qd = buf.readUInt16BE(4);
76
+ const an = buf.readUInt16BE(6);
77
+ const ns = buf.readUInt16BE(8);
78
+ const ar = buf.readUInt16BE(10);
79
+ const rcode = flags & 0x000f;
80
+
81
+ let off = 12;
82
+ for (let qi=0; qi<qd; qi++){ const [,n] = readName(buf, off); off = n + 4; }
83
+
84
+ const answers = [];
85
+ const total = Math.min(an + ns + ar, 512);
86
+ for (let i=0; i<total; i++){
87
+ if (off >= buf.length) break;
88
+ const [, n1] = readName(buf, off); off = n1;
89
+ if (off + 10 > buf.length) break;
90
+ const type = buf.readUInt16BE(off); off+=2;
91
+ const klass = buf.readUInt16BE(off); off+=2;
92
+ const ttl = buf.readUInt32BE(off); off+=4;
93
+ const rdlen = buf.readUInt16BE(off); off+=2;
94
+ if (off + rdlen > buf.length) break;
95
+ const rdata = buf.slice(off, off+rdlen); off += rdlen;
96
+
97
+ let data = null;
98
+ if (type === QTYPE.A && rdlen === 4){
99
+ data = `${rdata[0]}.${rdata[1]}.${rdata[2]}.${rdata[3]}`;
100
+ } else if (type === QTYPE.MX && rdlen >= 3){
101
+ const preference = rdata.readUInt16BE(0);
102
+ // MX exchange name is encoded starting at offset 2 within rdata.
103
+ // We need to resolve it against the full message buffer for pointer support.
104
+ const [exchange] = readName(buf, off - rdlen + 2);
105
+ data = `${preference} ${exchange}`;
106
+ } else if (type === QTYPE.TXT){
107
+ const chunks = []; let p=0;
108
+ while (p < rdata.length){ const ln = rdata[p++]; chunks.push(rdata.slice(p, p+ln).toString('utf8')); p+=ln; }
109
+ data = chunks.join(' | ');
110
+ } else {
111
+ data = `RDATA len ${rdlen}`;
112
+ }
113
+ answers.push({ type, class: klass, ttl, data });
114
+ }
115
+ return { id, rcode, answers };
116
+ }
117
+
118
+ function inferProgramVersion(txt){
119
+ if (!txt) return { program:'Unknown', version:'Unknown' };
120
+ const s = String(txt);
121
+ let m = /dnsmasq[-\s]?([0-9][\w.\-]*)/i.exec(s); if (m) return { program:'dnsmasq', version:m[1] };
122
+ m = /(BIND|named)[-\s]?([0-9][\w.\-]*)/i.exec(s); if (m) return { program:'BIND', version:m[2] };
123
+ m = /unbound[-\s]?([0-9][\w.\-]*)/i.exec(s); if (m) return { program:'Unbound', version:m[1] };
124
+ m = /PowerDNS[-\s]?([0-9][\w.\-]*)/i.exec(s); if (m) return { program:'PowerDNS', version:m[1] };
125
+ m = /(Microsoft|MS)\s*DNS[^\d]*([0-9][\w.\-]*)?/i.exec(s); if (m) return { program:'Microsoft DNS', version:m[2] || 'Unknown' };
126
+ return { program:'DNS', version:'Unknown' };
127
+ }
128
+
129
+ function sendUdpQuery({ host, port, qname, qtype, qclass, timeoutMs=TIMEOUT }){
130
+ return new Promise((resolve)=>{
131
+ const s = dgram.createSocket('udp4');
132
+ const id = crypto.randomInt(0, 0xffff);
133
+ const q = buildQuery({ id, qname, qtype, qclass });
134
+
135
+ let done=false;
136
+ const finish = (ok, info, parsed=null, raw=null) => { if(done) return; done=true; try{s.close();}catch{} resolve({ok,info,parsed,raw}); };
137
+ const t = setTimeout(()=>finish(false, 'No DNS response'), timeoutMs);
138
+
139
+ s.on('message', msg => {
140
+ try{
141
+ const rr = parseResponse(msg);
142
+ if (rr.id !== id) return; // ignore stray
143
+ clearTimeout(t);
144
+ if (rr.rcode !== 0) return finish(false, `DNS error rcode=${rr.rcode}`, rr, msg);
145
+ finish(true, 'DNS reply OK', rr, msg);
146
+ }catch(e){
147
+ clearTimeout(t);
148
+ finish(false, `Parse error: ${e.message}`);
149
+ }
150
+ });
151
+ s.on('error', err => {
152
+ clearTimeout(t);
153
+ finish(false, String(err?.code || 'UDP error'));
154
+ });
155
+ s.send(q, port, host, err => {
156
+ if (err){ clearTimeout(t); finish(false, String(err?.code || 'UDP send error')); }
157
+ });
158
+ });
159
+ }
160
+
161
+ // TCP fallback ONLY for version.bind (CHAOS/TXT)
162
+ function sendTcpVersionBind(host, port=53, timeoutMs=TIMEOUT){
163
+ const id = crypto.randomInt(0, 0xffff);
164
+ const q = buildQuery({ id, qname:'version.bind', qtype:QTYPE.TXT, qclass:QCLASS.CH });
165
+ const len = Buffer.alloc(2); len.writeUInt16BE(q.length, 0);
166
+
167
+ return new Promise(resolve=>{
168
+ const s = net.createConnection({ host, port });
169
+ let chunks = [], done=false;
170
+ const finish = (ok, info, parsed=null, raw=null) => { if(done) return; done=true; try{s.destroy();}catch{} resolve({ok,info,parsed,raw}); };
171
+ const t = setTimeout(()=>finish(false, 'TCP timeout'), timeoutMs);
172
+
173
+ s.on('connect', ()=> s.write(Buffer.concat([len, q])));
174
+ s.on('data', c => chunks.push(c));
175
+ s.on('error', err => { clearTimeout(t); finish(false, String(err?.code || 'TCP error')); });
176
+ s.on('close', ()=>{
177
+ if (done) return;
178
+ clearTimeout(t);
179
+ if (!chunks.length) return finish(false, 'No reply on TCP');
180
+ try{
181
+ const buf = Buffer.concat(chunks);
182
+ const rr = parseResponse(buf.subarray(2)); // strip 2-byte length
183
+ if (rr.rcode !== 0) return finish(false, `DNS error rcode=${rr.rcode}`, rr, buf);
184
+ finish(true, 'DNS reply OK (TCP)', rr, buf);
185
+ }catch(e){
186
+ finish(false, `Parse error: ${e.message}`);
187
+ }
188
+ });
189
+ });
190
+ }
191
+
192
+ // TCP AXFR zone transfer query
193
+ function sendAxfrQuery(host, domain, port=53, timeoutMs=TIMEOUT){
194
+ const id = crypto.randomInt(0, 0xffff);
195
+ const q = buildQuery({ id, qname: domain, qtype: QTYPE.AXFR, qclass: QCLASS.IN });
196
+ const len = Buffer.alloc(2);
197
+ len.writeUInt16BE(q.length, 0);
198
+
199
+ return new Promise(resolve => {
200
+ const s = net.createConnection({ host, port });
201
+ let buf = Buffer.alloc(0);
202
+ let done = false;
203
+ const records = [];
204
+
205
+ const finish = (ok, info) => {
206
+ if (done) return;
207
+ done = true;
208
+ try { s.destroy(); } catch {}
209
+ resolve({ ok, info, records });
210
+ };
211
+
212
+ const t = setTimeout(() => finish(false, 'AXFR timeout'), timeoutMs);
213
+
214
+ s.on('connect', () => s.write(Buffer.concat([len, q])));
215
+ const MAX_AXFR_RECORDS = 10000;
216
+ s.on('data', chunk => {
217
+ buf = Buffer.concat([buf, chunk]);
218
+ while (buf.length >= 2) {
219
+ const msgLen = buf.readUInt16BE(0);
220
+ if (buf.length < 2 + msgLen) break;
221
+ const msg = buf.subarray(2, 2 + msgLen);
222
+ buf = buf.subarray(2 + msgLen);
223
+ try {
224
+ const rr = parseResponse(msg);
225
+ if (rr.rcode !== 0) {
226
+ clearTimeout(t);
227
+ return finish(false, `AXFR refused (rcode=${rr.rcode})`);
228
+ }
229
+ records.push(...rr.answers);
230
+ if (records.length > MAX_AXFR_RECORDS) {
231
+ clearTimeout(t);
232
+ return finish(true, `AXFR success: ${records.length}+ records (truncated)`);
233
+ }
234
+ } catch (e) {
235
+ clearTimeout(t);
236
+ return finish(false, `AXFR parse error: ${e.message}`);
237
+ }
238
+ }
239
+ });
240
+ s.on('error', err => { clearTimeout(t); finish(false, String(err?.code || 'TCP error')); });
241
+ s.on('close', () => {
242
+ clearTimeout(t);
243
+ if (records.length > 0) finish(true, `AXFR success: ${records.length} records`);
244
+ else finish(false, 'AXFR: no records received');
245
+ });
246
+ });
247
+ }
248
+
249
+ export default {
250
+ id: "009",
251
+ name: "dns_scanner",
252
+ description: "Queries version.bind (CHAOS/TXT) and A example.com; TCP fallback for version.bind; records RCODE on non-recursive/blocked servers.",
253
+ priority: 340,
254
+ requirements: {},
255
+
256
+ async run(host, port=53, opts={}){
257
+ // normalize
258
+ const targetPort = Number(port) > 0 && Number(port) < 65536 ? Number(port) : 53;
259
+ const timeoutMs = Number(opts.timeoutMs || process.env.DNS_TIMEOUT_MS || TIMEOUT);
260
+ const testQname = String(opts.qname || process.env.DNS_QNAME || 'example.com');
261
+
262
+ const data = [];
263
+ let up = false, program = 'Unknown', version = 'Unknown';
264
+
265
+ // 1) version.bind over UDP
266
+ let vb = await sendUdpQuery({ host, port:targetPort, qname:'version.bind', qtype:QTYPE.TXT, qclass:QCLASS.CH, timeoutMs });
267
+
268
+ // 1b) fallback to TCP if UDP failed or gave an rcode
269
+ if ((!vb?.ok) && USE_TCP){
270
+ const vbTcp = await sendTcpVersionBind(host, targetPort, timeoutMs);
271
+ if (vbTcp?.ok) vb = vbTcp;
272
+ }
273
+
274
+ {
275
+ const entry = {
276
+ probe_protocol: 'udp', // keep tests simple; transport is not critical for evidence table
277
+ probe_port: targetPort,
278
+ probe_service: 'dns',
279
+ probe_info: vb?.ok ? 'version.bind reply' : (vb?.info || 'No DNS response'),
280
+ response_banner: null
281
+ };
282
+ if (vb?.parsed){
283
+ const txt = vb.parsed.answers.filter(a=>a.type===QTYPE.TXT).map(a=>a.data).join(' | ');
284
+ if (txt){
285
+ entry.response_banner = txt;
286
+ const pv = inferProgramVersion(txt);
287
+ program = pv.program; version = pv.version;
288
+ up = true;
289
+ }
290
+ }
291
+ data.push(entry);
292
+ }
293
+
294
+ // 2) A example.com over UDP (basic service behavior / recursion)
295
+ const ares = await sendUdpQuery({ host, port:targetPort, qname:testQname, qtype:QTYPE.A, qclass:QCLASS.IN, timeoutMs });
296
+ {
297
+ const entry = {
298
+ probe_protocol: 'udp',
299
+ probe_port: targetPort,
300
+ probe_service: 'dns',
301
+ probe_info: ares?.ok ? `A ${testQname} reply` : (ares?.info || 'No DNS response'),
302
+ response_banner: null
303
+ };
304
+ if (ares?.parsed){
305
+ const ips = ares.parsed.answers.filter(a=>a.type===QTYPE.A).map(a=>a.data);
306
+ if (ips.length){ entry.response_banner = `A ${testQname} -> ${ips.join(', ')}`; up = true; }
307
+ }
308
+ data.push(entry);
309
+ }
310
+
311
+ // 3) MX record query over UDP
312
+ const mxRes = await sendUdpQuery({ host, port:targetPort, qname:testQname, qtype:QTYPE.MX, qclass:QCLASS.IN, timeoutMs });
313
+ {
314
+ const entry = {
315
+ probe_protocol: 'udp',
316
+ probe_port: targetPort,
317
+ probe_service: 'dns',
318
+ probe_info: mxRes?.ok ? `MX ${testQname} reply` : (mxRes?.info || 'No DNS response'),
319
+ response_banner: null
320
+ };
321
+ if (mxRes?.parsed){
322
+ const mxs = mxRes.parsed.answers.filter(a=>a.type===QTYPE.MX).map(a=>a.data);
323
+ if (mxs.length){ entry.response_banner = `MX ${testQname} -> ${mxs.join(', ')}`; up = true; }
324
+ }
325
+ data.push(entry);
326
+ }
327
+
328
+ // 4) AXFR zone transfer (opt-in via env)
329
+ const checkAxfr = /^(1|true|yes|on)$/i.test(String(process.env.DNS_CHECK_AXFR || ''));
330
+ const axfrDomain = String(process.env.DNS_AXFR_DOMAIN || opts.axfrDomain || '');
331
+ let axfrAllowed = null;
332
+
333
+ if (checkAxfr && axfrDomain) {
334
+ const axfrRes = await sendAxfrQuery(host, axfrDomain, targetPort, timeoutMs);
335
+ axfrAllowed = axfrRes.ok;
336
+ data.push({
337
+ probe_protocol: 'tcp',
338
+ probe_port: targetPort,
339
+ probe_service: 'dns',
340
+ probe_info: axfrRes.ok ? `AXFR ${axfrDomain} allowed` : `AXFR ${axfrDomain} denied`,
341
+ response_banner: axfrRes.info
342
+ });
343
+ }
344
+
345
+ return { up, type:'dns', program, version, axfrAllowed, data };
346
+ }
347
+ };
348
+
349
+ // ---------------- Plug-and-Play concluder adapter ----------------
350
+ import { statusFrom } from '../utils/conclusion_utils.mjs';
351
+
352
+ export async function conclude({ host, result }){
353
+ const rows = Array.isArray(result?.data) ? result.data : [];
354
+ const pick = rows.find(r => /version\.bind|example\.com/i.test(String(r?.probe_info||''))) || rows[0] || null;
355
+ const port = Number(pick?.probe_port ?? 53);
356
+ const info = pick?.probe_info || (result?.up ? 'DNS reply' : 'No DNS response');
357
+ const banner = pick?.response_banner || null;
358
+ const status = result?.up ? 'open' : statusFrom({ info, banner });
359
+ return [{
360
+ port, protocol:'udp', service:'dns',
361
+ program: result?.program || 'Unknown',
362
+ version: result?.version || 'Unknown',
363
+ status, info, banner,
364
+ axfrAllowed: result?.axfrAllowed ?? null,
365
+ source: 'dns',
366
+ evidence: rows,
367
+ authoritative: true
368
+ }];
369
+ }
@@ -0,0 +1,245 @@
1
+ // plugins/dnssd-scanner.mjs
2
+
3
+ const DEBUG = /^(1|true|yes|on)$/i.test(String(process.env.DEBUG_MODE || process.env.DNSSD_DEBUG || ""));
4
+ function dlog(...a) { if (DEBUG) console.log("[dnssd-scanner]", ...a); }
5
+
6
+ // Use mock if in test mode
7
+ let dnssd;
8
+ if (process.env.DNSSD_TEST_FAKE && globalThis.__dnssdFakeFactory) {
9
+ dnssd = globalThis.__dnssdFakeFactory();
10
+ } else {
11
+ try {
12
+ // Try different ways to get the browser
13
+ const mod = await import('dnssd');
14
+
15
+ // Case 1: mod exports Browser directly
16
+ if (typeof mod.Browser === 'function') {
17
+ dnssd = {
18
+ createBrowser: (type) => new mod.Browser(type)
19
+ };
20
+ }
21
+ // Case 2: mod.default exports Browser
22
+ else if (mod.default && typeof mod.default.Browser === 'function') {
23
+ dnssd = {
24
+ createBrowser: (type) => new mod.default.Browser(type)
25
+ };
26
+ }
27
+ // Case 3: mod exports createBrowser
28
+ else if (typeof mod.createBrowser === 'function') {
29
+ dnssd = mod;
30
+ }
31
+ // Case 4: mod.default exports createBrowser
32
+ else if (mod.default && typeof mod.default.createBrowser === 'function') {
33
+ dnssd = mod.default;
34
+ }
35
+ // No valid export found
36
+ else {
37
+ throw new Error('Could not find Browser or createBrowser in dnssd module');
38
+ }
39
+ } catch (e) {
40
+ dlog('Failed to initialize dnssd:', e);
41
+ // Provide fallback that logs errors
42
+ dnssd = {
43
+ createBrowser: () => {
44
+ throw new Error('DNS-SD browser not available: ' + e.message);
45
+ }
46
+ };
47
+ }
48
+ }
49
+
50
+
51
+
52
+ // Common service types to discover
53
+ const SERVICE_TYPES = [
54
+ '_http._tcp',
55
+ '_https._tcp',
56
+ '_printer._tcp',
57
+ '_ipp._tcp',
58
+ '_pdl-datastream._tcp',
59
+ '_scanner._tcp',
60
+ '_airport._tcp',
61
+ '_airplay._tcp',
62
+ '_raop._tcp',
63
+ '_spotify-connect._tcp',
64
+ '_workstation._tcp',
65
+ '_companion-link._tcp',
66
+ '_device-info._tcp',
67
+ '_googlecast._tcp'
68
+ ];
69
+
70
+ async function discoverServices(targetHost, timeoutMs) {
71
+ return new Promise((resolve) => {
72
+ const services = new Map();
73
+ const browsers = [];
74
+ const dataRows = [];
75
+
76
+ for (const type of SERVICE_TYPES) {
77
+ try {
78
+ const browser = dnssd.createBrowser(type);
79
+
80
+ browser.on('serviceUp', service => {
81
+ const addresses = service.addresses || [];
82
+ const isTargetHost = addresses.some(addr => addr === targetHost);
83
+
84
+ services.set(`${service.name}|${service.type}`, {
85
+ service,
86
+ isTargetHost
87
+ });
88
+ });
89
+
90
+ browser.on('serviceDown', service => {
91
+ // Only push to dataRows, do not add to services map
92
+ dataRows.push({
93
+ probe_protocol: "dnssd",
94
+ probe_port: 5353,
95
+ probe_info: `Service "${service.name}" (${service.type}) went offline`,
96
+ response_banner: JSON.stringify({
97
+ name: service.name,
98
+ type: service.type,
99
+ addresses: service.addresses,
100
+ offline: true,
101
+ discoveredAt: new Date().toISOString()
102
+ })
103
+ });
104
+ });
105
+
106
+ browser.start();
107
+ browsers.push(browser);
108
+
109
+ } catch (e) {
110
+ dlog(`Error creating browser for ${type}:`, e?.message || e);
111
+ }
112
+ }
113
+
114
+ setTimeout(() => {
115
+ browsers.forEach(browser => browser.stop());
116
+
117
+ // Only process discovered services with a valid service object
118
+ for (const entry of services.values()) {
119
+ if (!entry.service) continue;
120
+
121
+ const service = entry.service;
122
+ const isTargetHost = entry.isTargetHost;
123
+
124
+ const txtRecords = Object.entries(service.txt || {})
125
+ .map(([key, value]) => `${key}=${value}`)
126
+ .join('; ');
127
+
128
+ const infoParts = [
129
+ `name="${service.name}"`,
130
+ // Fix type display
131
+ `type=${service.type?.name || service.type}._${service.type?.protocol || 'tcp'}`,
132
+ `port=${service.port}`,
133
+ `addresses=${(service.addresses || []).join(',')}`,
134
+ ];
135
+
136
+ if (txtRecords) {
137
+ infoParts.push(`txt=[${txtRecords}]`);
138
+ }
139
+
140
+ dataRows.push({
141
+ probe_protocol: "dnssd",
142
+ probe_port: 5353,
143
+ probe_info: (isTargetHost ? "Matched host — " : "Discovered — ") + infoParts.join(" "),
144
+ response_banner: JSON.stringify({
145
+ name: service.name,
146
+ type: service.type,
147
+ port: service.port,
148
+ addresses: service.addresses,
149
+ txt: service.txt || {},
150
+ discoveredAt: new Date().toISOString()
151
+ })
152
+ });
153
+ }
154
+
155
+ resolve(dataRows);
156
+ }, timeoutMs);
157
+ });
158
+ }
159
+
160
+ export default {
161
+ id: "018",
162
+ name: "DNS-SD Service Scanner",
163
+ description: "Discovers DNS-SD/mDNS services on the network with comprehensive service type detection",
164
+ priority: 347,
165
+ requirements: {},
166
+ protocols: ["dnssd", "mdns"],
167
+ ports: [5353],
168
+ runStrategy: "single",
169
+
170
+ async run(host, _port = 5353, opts = {}) {
171
+ const timeoutMs = Number(opts.timeoutMs ?? process.env.NSA_DNSSD_TIMEOUT_MS ?? 10000);
172
+ const data = [];
173
+ const includeNonMatched = /^(1|true|yes|on)$/i.test(String(process.env.DNSSD_INCLUDE_NON_MATCHED || ""));
174
+
175
+ try {
176
+ const discoveries = await discoverServices(host, timeoutMs);
177
+ let matched = false;
178
+ const matchedRows = [];
179
+ const otherRows = [];
180
+
181
+ // First pass - separate matched from non-matched
182
+ for (const entry of discoveries) {
183
+ const addresses = JSON.parse(entry.response_banner)?.addresses || [];
184
+ const isMatch = addresses.includes(host);
185
+
186
+ if (isMatch) {
187
+ matched = true;
188
+ matchedRows.push(entry);
189
+ } else if (includeNonMatched) {
190
+ otherRows.push(entry);
191
+ }
192
+ }
193
+
194
+ // Add summary first if we have any data
195
+ if (matchedRows.length > 0 || (includeNonMatched && otherRows.length > 0)) {
196
+ data.push({
197
+ probe_protocol: "dnssd",
198
+ probe_port: 5353,
199
+ probe_info: matched ?
200
+ `Host ${host} provides ${matchedRows.length} DNS-SD service(s)` :
201
+ `No DNS-SD services found for host ${host} (${otherRows.length} other services discovered)`,
202
+ response_banner: JSON.stringify({
203
+ summary: true,
204
+ servicesFound: matchedRows.length + otherRows.length,
205
+ matchedHost: matched,
206
+ discoveredAt: new Date().toISOString()
207
+ })
208
+ });
209
+ }
210
+
211
+ // Add matched rows first
212
+ data.push(...matchedRows);
213
+
214
+ // Add other rows only if includeNonMatched is true
215
+ if (includeNonMatched) {
216
+ data.push(...otherRows);
217
+ }
218
+
219
+ return {
220
+ up: matched,
221
+ program: "DNS-SD/mDNS",
222
+ version: "Unknown",
223
+ type: "dnssd",
224
+ data
225
+ };
226
+
227
+ } catch (e) {
228
+ dlog("DNS-SD discovery error:", e?.message || e);
229
+ data.push({
230
+ probe_protocol: "dnssd",
231
+ probe_port: 5353,
232
+ probe_info: `DNS-SD discovery failed: ${e?.message || 'Unknown error'}`,
233
+ response_banner: null
234
+ });
235
+
236
+ return {
237
+ up: false,
238
+ program: "DNS-SD/mDNS",
239
+ version: "Unknown",
240
+ type: "dnssd",
241
+ data
242
+ };
243
+ }
244
+ }
245
+ };