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,737 @@
1
+ // plugins/netbios_scanner.mjs
2
+ // NetBIOS/SMB discovery plugin (UDP 137 NBNS, TCP 139/445 probes, and optional mDNS _smb._tcp.local)
3
+ // Optional SMB2 null session enumeration (gated by SMB_NULL_SESSION env var).
4
+ // Exports helpers used by tests: parseNbstatRData, buildMdnsQueryPTR, buildSmb2Negotiate,
5
+ // parseSmb2NegotiateResponse, buildSmb2SessionSetup, buildSmb2TreeConnect,
6
+ // parseSmb2Header, buildNetShareEnumAll, buildSamrConnect, parseNetShareEnumAllResponse,
7
+ // parseSamrEnumUsersResponse.
8
+ // Focus is correctness and small footprint suitable for unit tests.
9
+
10
+ import dgram from 'node:dgram';
11
+ import net from 'node:net';
12
+
13
+ /* ----------------------------- Helpers ----------------------------- */
14
+
15
+ function toHex(b){ return [...b].map(x=>x.toString(16).padStart(2,'0')).join(''); }
16
+
17
+ // Parse NBSTAT RDATA (RFC 1002) which is: <NUM_NAMES:1> [<NAME:15><SUFFIX:1><FLAGS:2>]... <UNIT_ID:6>
18
+ export function parseNbstatRData(buf){
19
+ const out = { names: [], mac: null };
20
+ if (!buf || buf.length < 1+6) return out;
21
+ let off = 0;
22
+ const num = buf.readUInt8(off++);
23
+ for (let i=0;i<num;i++){
24
+ if (off + 15 + 1 + 2 > buf.length) break;
25
+ const rawName = buf.subarray(off, off+15); off+=15;
26
+ const suffix = buf.readUInt8(off++);
27
+ const flags = buf.readUInt16BE(off); off+=2;
28
+ // Trim trailing spaces from 15-byte NetBIOS name
29
+ const name = rawName.toString('ascii').replace(/\s+$/g,'');
30
+ out.names.push({ name, suffix, flags, group: !!(flags & 0x8000) });
31
+ }
32
+ if (off + 6 <= buf.length){
33
+ const mac = buf.subarray(off, off+6);
34
+ out.mac = toHex(mac);
35
+ }
36
+ return out;
37
+ }
38
+
39
+ // Build an mDNS QU question for PTR _smb._tcp.local (ID=0, Flags=0, QDCOUNT=1)
40
+ export function buildMdnsQueryPTR(){
41
+ const labels = ['_smb','_tcp','local'];
42
+ const nameParts = [];
43
+ for (const l of labels){
44
+ const b = Buffer.from(l,'ascii');
45
+ nameParts.push(Buffer.from([b.length]));
46
+ nameParts.push(b);
47
+ }
48
+ nameParts.push(Buffer.from([0])); // root
49
+ const qname = Buffer.concat(nameParts);
50
+ const header = Buffer.alloc(12);
51
+ // ID=0, FLAGS=0 for mDNS multicast query
52
+ header.writeUInt16BE(0, 0);
53
+ header.writeUInt16BE(0, 2);
54
+ header.writeUInt16BE(1, 4); // QDCOUNT
55
+ header.writeUInt16BE(0, 6); // ANCOUNT
56
+ header.writeUInt16BE(0, 8); // NSCOUNT
57
+ header.writeUInt16BE(0, 10);// ARCOUNT
58
+ const qtype = Buffer.alloc(2); qtype.writeUInt16BE(12,0); // PTR
59
+ // CLASS IN (1) with QU (unicast-response) bit set
60
+ const qclass = Buffer.alloc(2); qclass.writeUInt16BE(0x8000 | 1, 0);
61
+ return Buffer.concat([header, qname, qtype, qclass]);
62
+ }
63
+
64
+ // Build a minimal SMB2 NEGOTIATE request (MS-SMB2 §2.2.3)
65
+ export function buildSmb2Negotiate(){
66
+ const hdr = Buffer.alloc(64, 0);
67
+ hdr.writeUInt32BE(0xfe534d42, 0); // SMB2 signature
68
+ hdr.writeUInt16LE(64, 4); // StructureSize
69
+ hdr.writeUInt16LE(1, 6); // CreditCharge
70
+ // Command = NEGOTIATE (0x0000) already zero at offset 12
71
+ hdr.writeUInt16LE(1, 14); // Credits requested
72
+ // Flags (offset 16) and NextCommand (offset 20) are zero from alloc
73
+ const body = Buffer.alloc(38, 0); // 36 fixed fields + 2 bytes for dialect
74
+ body.writeUInt16LE(36, 0); // StructureSize
75
+ body.writeUInt16LE(1, 2); // DialectCount
76
+ body.writeUInt16LE(0x0202, 36); // Dialect 2.02 (after 36-byte fixed header)
77
+ return Buffer.concat([hdr, body]);
78
+ }
79
+
80
+ export function parseSmb2NegotiateResponse(buf){
81
+ // trivial check: first 4 bytes == 0xfe 'S' 'M' 'B'
82
+ if (!buf || buf.length < 4) return { ok:false };
83
+ const sig = buf.readUInt32BE(0);
84
+ const ok = sig === 0xfe534d42;
85
+ return { ok, dialects: ok ? 1 : 0 };
86
+ }
87
+
88
+ // Prepend 4-byte NetBIOS Session Service header (RFC 1002 §4.3.1)
89
+ // SMB2 over TCP/445 requires this framing for all messages.
90
+ function wrapNbss(smb2Packet) {
91
+ const hdr = Buffer.alloc(4);
92
+ hdr.writeUInt32BE(smb2Packet.length, 0);
93
+ hdr[0] = 0x00; // Session Message type
94
+ return Buffer.concat([hdr, smb2Packet]);
95
+ }
96
+
97
+ // Strip 4-byte NBSS header if present, returning the SMB2 payload.
98
+ function stripNbss(buf) {
99
+ if (buf.length > 4 && buf[0] === 0x00) return buf.subarray(4);
100
+ return buf;
101
+ }
102
+
103
+ /* -------------------- SMB2 Null Session helpers -------------------- */
104
+
105
+ // SMB2 status codes
106
+ const STATUS_SUCCESS = 0x00000000;
107
+ const STATUS_MORE_PROCESSING = 0xC0000016;
108
+ const STATUS_ACCESS_DENIED = 0xC0000022;
109
+
110
+ // Parse an SMB2 header (64 bytes) — returns status, sessionId, treeId, command
111
+ export function parseSmb2Header(buf){
112
+ if (!buf || buf.length < 64) return null;
113
+ const sig = buf.readUInt32BE(0);
114
+ if (sig !== 0xfe534d42) return null;
115
+ const command = buf.readUInt16LE(12);
116
+ const status = buf.readUInt32LE(8);
117
+ // SessionId is at offset 40 (8 bytes), read as pair of UInt32LE
118
+ const sessionIdLo = buf.readUInt32LE(40);
119
+ const sessionIdHi = buf.readUInt32LE(44);
120
+ const treeId = buf.readUInt32LE(36);
121
+ return { command, status, sessionIdLo, sessionIdHi, treeId };
122
+ }
123
+
124
+ // Build NTLMSSP Negotiate token (Type 1) for anonymous auth
125
+ function buildNtlmsspNegotiate(){
126
+ // NTLMSSP_NEGOTIATE (type 1)
127
+ const sig = Buffer.from('NTLMSSP\0', 'ascii'); // 8 bytes
128
+ const type = Buffer.alloc(4); type.writeUInt32LE(1, 0);
129
+ // Flags: NTLMSSP_NEGOTIATE_UNICODE | NTLMSSP_REQUEST_TARGET | NTLMSSP_NEGOTIATE_NTLM
130
+ const flags = Buffer.alloc(4); flags.writeUInt32LE(0x00000207, 0);
131
+ // Domain name fields (empty)
132
+ const domLen = Buffer.alloc(4, 0); // Len(2) + MaxLen(2)
133
+ const domOff = Buffer.alloc(4); domOff.writeUInt32LE(0, 0);
134
+ // Workstation fields (empty)
135
+ const wkLen = Buffer.alloc(4, 0);
136
+ const wkOff = Buffer.alloc(4); wkOff.writeUInt32LE(0, 0);
137
+ return Buffer.concat([sig, type, flags, domLen, domOff, wkLen, wkOff]);
138
+ }
139
+
140
+ // Build NTLMSSP Authenticate token (Type 3) with null credentials
141
+ function buildNtlmsspAuth(){
142
+ const sig = Buffer.from('NTLMSSP\0', 'ascii');
143
+ const type = Buffer.alloc(4); type.writeUInt32LE(3, 0);
144
+ const payloadOffset = 8 + 4 + (6 * 8) + 4; // 88 bytes fixed header
145
+ // All security buffers empty (Len=0, MaxLen=0, Offset=payloadOffset)
146
+ const emptyField = () => {
147
+ const b = Buffer.alloc(8);
148
+ b.writeUInt16LE(0, 0); // Len
149
+ b.writeUInt16LE(0, 2); // MaxLen
150
+ b.writeUInt32LE(payloadOffset, 4); // Offset
151
+ return b;
152
+ };
153
+ const flags = Buffer.alloc(4); flags.writeUInt32LE(0x00000207, 0);
154
+ return Buffer.concat([
155
+ sig, type,
156
+ emptyField(), // LmChallengeResponse
157
+ emptyField(), // NtChallengeResponse
158
+ emptyField(), // DomainName
159
+ emptyField(), // UserName
160
+ emptyField(), // Workstation
161
+ emptyField(), // EncryptedRandomSessionKey
162
+ flags
163
+ ]);
164
+ }
165
+
166
+ // Build SMB2 SESSION_SETUP request with NTLMSSP token
167
+ export function buildSmb2SessionSetup(ntlmToken, sessionIdLo=0, sessionIdHi=0){
168
+ // SMB2 header (64 bytes)
169
+ const hdr = Buffer.alloc(64, 0);
170
+ hdr.writeUInt32BE(0xfe534d42, 0); // Protocol
171
+ hdr.writeUInt16LE(64, 4); // StructureSize
172
+ hdr.writeUInt16LE(1, 6); // CreditCharge
173
+ hdr.writeUInt16LE(0x0001, 12); // Command: SESSION_SETUP
174
+ hdr.writeUInt16LE(1, 14); // Credits requested
175
+ hdr.writeUInt32LE(sessionIdLo, 40);
176
+ hdr.writeUInt32LE(sessionIdHi, 44);
177
+
178
+ // SESSION_SETUP request body (MS-SMB2 2.2.5)
179
+ const bodyFixed = Buffer.alloc(24, 0);
180
+ bodyFixed.writeUInt16LE(25, 0); // StructureSize (25)
181
+ bodyFixed.writeUInt8(0, 2); // Flags
182
+ bodyFixed.writeUInt8(0, 3); // SecurityMode
183
+ bodyFixed.writeUInt32LE(0, 4); // Capabilities
184
+ bodyFixed.writeUInt32LE(0, 8); // Channel
185
+ // SecurityBufferOffset = header(64) + body fixed(24) = 88
186
+ bodyFixed.writeUInt16LE(88, 12); // SecurityBufferOffset
187
+ bodyFixed.writeUInt16LE(ntlmToken.length, 14); // SecurityBufferLength
188
+
189
+ return Buffer.concat([hdr, bodyFixed, ntlmToken]);
190
+ }
191
+
192
+ // Build SMB2 TREE_CONNECT request
193
+ export function buildSmb2TreeConnect(host, sessionIdLo, sessionIdHi){
194
+ const pathStr = `\\\\${host}\\IPC$`;
195
+ const pathBuf = Buffer.from(pathStr, 'utf16le');
196
+
197
+ const hdr = Buffer.alloc(64, 0);
198
+ hdr.writeUInt32BE(0xfe534d42, 0);
199
+ hdr.writeUInt16LE(64, 4); // StructureSize
200
+ hdr.writeUInt16LE(1, 6); // CreditCharge
201
+ hdr.writeUInt16LE(0x0003, 12); // Command: TREE_CONNECT
202
+ hdr.writeUInt16LE(1, 14); // Credits
203
+ hdr.writeUInt32LE(sessionIdLo, 40);
204
+ hdr.writeUInt32LE(sessionIdHi, 44);
205
+
206
+ const body = Buffer.alloc(8, 0);
207
+ body.writeUInt16LE(9, 0); // StructureSize (9)
208
+ body.writeUInt16LE(0, 2); // Flags/Reserved
209
+ // PathOffset = 64 + 8 = 72
210
+ body.writeUInt16LE(72, 4); // PathOffset
211
+ body.writeUInt16LE(pathBuf.length, 6); // PathLength
212
+
213
+ return Buffer.concat([hdr, body, pathBuf]);
214
+ }
215
+
216
+ // Build a minimal NetShareEnumAll RPC request over SMB2 named pipe (srvsvc)
217
+ // This builds a simplified DCE/RPC bind + NetShareEnumAll request
218
+ export function buildNetShareEnumAll(host){
219
+ // Server name as UTF-16LE null-terminated
220
+ const serverName = Buffer.from(`\\\\${host}\0`, 'utf16le');
221
+ // Simplified: return a marker buffer that the real pipe would receive
222
+ // In real implementation this would be a full DCE/RPC request
223
+ const marker = Buffer.from('NETSHAREENUMALL', 'ascii');
224
+ return Buffer.concat([marker, serverName]);
225
+ }
226
+
227
+ // Build a minimal SAMR connect/enumerate RPC request
228
+ export function buildSamrConnect(host){
229
+ const serverName = Buffer.from(`\\\\${host}\0`, 'utf16le');
230
+ const marker = Buffer.from('SAMRENUMUSERS', 'ascii');
231
+ return Buffer.concat([marker, serverName]);
232
+ }
233
+
234
+ // Parse NetShareEnumAll response — extract share names from response buffer
235
+ export function parseNetShareEnumAllResponse(buf){
236
+ if (!buf || buf.length < 4) return [];
237
+ try {
238
+ // Look for share names encoded as UTF-16LE strings
239
+ // In a real srvsvc response, shares are in a list of SHARE_INFO_1 structures
240
+ // For our probe: if we got data back with STATUS_SUCCESS, extract text segments
241
+ const shares = [];
242
+ let off = 0;
243
+ while (off + 2 <= buf.length) {
244
+ // Scan for printable UTF-16LE sequences (heuristic for share names)
245
+ const ch = buf.readUInt16LE(off);
246
+ if (ch >= 0x20 && ch < 0x7f) {
247
+ let end = off;
248
+ while (end + 2 <= buf.length) {
249
+ const c = buf.readUInt16LE(end);
250
+ if (c === 0 || c < 0x20 || c >= 0x7f) break;
251
+ end += 2;
252
+ }
253
+ if (end > off) {
254
+ const name = buf.subarray(off, end).toString('utf16le');
255
+ if (name.length >= 1 && name.length <= 80) shares.push(name);
256
+ off = end + 2;
257
+ continue;
258
+ }
259
+ }
260
+ off += 2;
261
+ }
262
+ return shares;
263
+ } catch { return []; }
264
+ }
265
+
266
+ // Parse SAMR user enum response — extract user names
267
+ export function parseSamrEnumUsersResponse(buf){
268
+ if (!buf || buf.length < 4) return [];
269
+ try {
270
+ const users = [];
271
+ let off = 0;
272
+ while (off + 2 <= buf.length) {
273
+ const ch = buf.readUInt16LE(off);
274
+ if (ch >= 0x20 && ch < 0x7f) {
275
+ let end = off;
276
+ while (end + 2 <= buf.length) {
277
+ const c = buf.readUInt16LE(end);
278
+ if (c === 0 || c < 0x20 || c >= 0x7f) break;
279
+ end += 2;
280
+ }
281
+ if (end > off) {
282
+ const name = buf.subarray(off, end).toString('utf16le');
283
+ if (name.length >= 1 && name.length <= 80) users.push(name);
284
+ off = end + 2;
285
+ continue;
286
+ }
287
+ }
288
+ off += 2;
289
+ }
290
+ return users;
291
+ } catch { return []; }
292
+ }
293
+
294
+ /* ----------------------------- Scanner core ----------------------------- */
295
+
296
+ const DEBUG = /^(1|true|yes|on)$/i.test(String(process.env.NETBIOS_DEBUG || ''));
297
+ const TIMEOUT = Number(process.env.NETBIOS_TIMEOUT_MS || 1500);
298
+ const MDNS_TIMEOUT = Number(process.env.MDNS_TIMEOUT_MS || 1500);
299
+ const ENABLE_MDNS = /^(1|true|yes|on)$/i.test(String(process.env.NETBIOS_ENABLE_MDNS || '1'));
300
+ const ENABLE_NULL_SESSION = /^(1|true|yes|on)$/i.test(String(process.env.SMB_NULL_SESSION || ''));
301
+ const NULL_SESSION_TIMEOUT = Number(process.env.SMB_NULL_SESSION_TIMEOUT || 5000);
302
+
303
+ function dlog(...a){ if (DEBUG) console.log('[netbios]', ...a); }
304
+
305
+ function encodeNbnsName(questionName='*'){
306
+ // RFC1002 NBNS name is 16 bytes (15 name padded with spaces + suffix) then encoded to 32 ASCII bytes.
307
+ const raw = Buffer.alloc(16, 0x20);
308
+ raw.write(questionName.slice(0,15).toUpperCase(), 0, 'ascii');
309
+ // Name encoding: each byte -> two ASCII chars: 'A' + high nibble, 'A' + low nibble
310
+ const out = Buffer.alloc(32);
311
+ for (let i=0;i<16;i++){
312
+ const c = raw[i];
313
+ out[i*2] = 0x41 + ((c >> 4) & 0x0f);
314
+ out[i*2+1] = 0x41 + (c & 0x0f);
315
+ }
316
+ return out;
317
+ }
318
+
319
+ function buildNbnsNodeStatusQuery(){
320
+ const header = Buffer.alloc(12);
321
+ const id = Math.floor(Math.random()*0xffff) & 0xffff;
322
+ header.writeUInt16BE(id, 0);
323
+ header.writeUInt16BE(0x0010, 2); // flags: RD=0
324
+ header.writeUInt16BE(1, 4); // QDCOUNT=1
325
+ // AN/NS/AR = 0
326
+ const qname = Buffer.concat([Buffer.from([0x20]), encodeNbnsName('*'), Buffer.from([0x00])]);
327
+ const qtype = Buffer.alloc(2); qtype.writeUInt16BE(0x0021, 0); // NBSTAT
328
+ const qclass = Buffer.alloc(2); qclass.writeUInt16BE(0x0001, 0); // IN
329
+ return Buffer.concat([header, qname, qtype, qclass]);
330
+ }
331
+
332
+ function parseDnsName(buf, off){
333
+ const parts = [];
334
+ let i = off;
335
+ while (true){
336
+ const len = buf[i++];
337
+ if (len === 0) break;
338
+ if ((len & 0xC0) === 0xC0){
339
+ // pointer
340
+ const ptr = ((len & 0x3F) << 8) | buf[i++];
341
+ const [nm] = parseDnsName(buf, ptr);
342
+ parts.push(nm);
343
+ break;
344
+ } else {
345
+ parts.push(buf.toString('ascii', i, i+len));
346
+ i += len;
347
+ }
348
+ }
349
+ return [parts.join('.'), i];
350
+ }
351
+
352
+ function parseNbnsResponse(buf){
353
+ // Very small parser that finds the first NBSTAT answer RDATA
354
+ try{
355
+ let off = 12;
356
+ const qd = buf.readUInt16BE(4);
357
+ const an = buf.readUInt16BE(6);
358
+ // skip questions
359
+ for (let i=0;i<qd;i++){
360
+ const [, n] = parseDnsName(buf, off);
361
+ off = n + 4;
362
+ }
363
+ // answers
364
+ for (let i=0;i<an;i++){
365
+ const [, n1] = parseDnsName(buf, off); off = n1;
366
+ const type = buf.readUInt16BE(off); off+=2;
367
+ /*const klass =*/ off+=2;
368
+ /*const ttl =*/ off+=4;
369
+ const rdlen = buf.readUInt16BE(off); off+=2;
370
+ if (type === 0x0021){ // NBSTAT
371
+ const rdata = buf.subarray(off, off+rdlen);
372
+ return parseNbstatRData(rdata);
373
+ }
374
+ off += rdlen;
375
+ }
376
+ }catch{}
377
+ return null;
378
+ }
379
+
380
+ function sendUdp(host, port, payload){
381
+ return new Promise(resolve => {
382
+ const s = dgram.createSocket('udp4');
383
+ let settled = false;
384
+ const to = setTimeout(()=>{ if(!settled){ settled=true; try{s.close();}catch{} resolve(null);} }, TIMEOUT);
385
+ s.on('message', m => { if (settled) return; settled=true; clearTimeout(to); try{s.close();}catch{} resolve(m); });
386
+ s.on('error', ()=>{ if (settled) return; settled=true; clearTimeout(to); try{s.close();}catch{} resolve(null); });
387
+ s.send(payload, port, host, err => {
388
+ if (err && !settled){ settled=true; clearTimeout(to); try{s.close();}catch{} resolve(null); }
389
+ });
390
+ });
391
+ }
392
+
393
+ async function probeUdp137(host){
394
+ const q = buildNbnsNodeStatusQuery();
395
+ dlog('UDP/137 sending NBSTAT query len', q.length);
396
+ const res = await sendUdp(host, 137, q);
397
+ if (!res) return null;
398
+ const parsed = parseNbnsResponse(res);
399
+ return parsed;
400
+ }
401
+
402
+ // Minimal TCP 445 SMB2 negotiate (best-effort)
403
+ async function probeTcp445(host){
404
+ return new Promise(resolve => {
405
+ const sock = net.createConnection({ host, port:445 });
406
+ const to = setTimeout(()=>{ try{sock.destroy();}catch{} resolve(null); }, TIMEOUT);
407
+ sock.on('connect', () => {
408
+ try { sock.write(wrapNbss(buildSmb2Negotiate())); } catch {}
409
+ });
410
+ sock.on('data', (chunk) => {
411
+ clearTimeout(to);
412
+ try{ sock.destroy(); }catch{}
413
+ // Strip NBSS header before parsing SMB2 response
414
+ resolve(parseSmb2NegotiateResponse(stripNbss(chunk)));
415
+ });
416
+ sock.on('error', () => { clearTimeout(to); try { sock.destroy(); } catch {} resolve(null); });
417
+ sock.on('timeout', () => { clearTimeout(to); try { sock.destroy(); } catch {} resolve(null); });
418
+ });
419
+ }
420
+
421
+ // mDNS browse for _smb._tcp.local with QU (request unicast response)
422
+ async function probeMdnsSmb(){
423
+ if (!ENABLE_MDNS) return null;
424
+ return new Promise(resolve => {
425
+ const s = dgram.createSocket('udp4');
426
+ let settled = false;
427
+ const q = buildMdnsQueryPTR();
428
+ const cleanup = () => { try{s.close();}catch{} };
429
+ const to = setTimeout(()=>{ if(!settled){ settled=true; cleanup(); resolve(null); } }, MDNS_TIMEOUT);
430
+ s.on('message', (m) => {
431
+ if (settled) return;
432
+ settled = true;
433
+ clearTimeout(to);
434
+ cleanup();
435
+ resolve(m);
436
+ });
437
+ s.on('error', () => { if(!settled){ settled=true; clearTimeout(to); cleanup(); resolve(null);} });
438
+ try {
439
+ // bind ephemeral so unicast replies can reach us
440
+ s.bind(0, () => {
441
+ try {
442
+ s.setMulticastTTL?.(1);
443
+ } catch {}
444
+ s.send(q, 5353, '224.0.0.251', (err) => {
445
+ if (err) { if(!settled){ settled=true; clearTimeout(to); cleanup(); resolve(null);} }
446
+ });
447
+ });
448
+ } catch {
449
+ if(!settled){ settled=true; clearTimeout(to); cleanup(); resolve(null); }
450
+ }
451
+ });
452
+ }
453
+
454
+ // SMB2 null session probe — attempts anonymous auth, IPC$ tree connect, and share/user enum
455
+ async function probeNullSession(host){
456
+ const result = { nullSessionAllowed: false, shares: [], users: [] };
457
+ if (!ENABLE_NULL_SESSION) return result;
458
+
459
+ return new Promise(resolve => {
460
+ let resolved = false;
461
+ const safeResolve = (v) => { if (resolved) return; resolved = true; resolve(v); };
462
+
463
+ const sock = net.createConnection({ host, port: 445 });
464
+ const to = setTimeout(() => {
465
+ try { sock.destroy(); } catch {}
466
+ safeResolve(result);
467
+ }, NULL_SESSION_TIMEOUT);
468
+
469
+ let phase = 'negotiate'; // negotiate | session-setup-1 | session-setup-2 | tree-connect | done
470
+ let sessionIdLo = 0;
471
+ let sessionIdHi = 0;
472
+ let chunks = Buffer.alloc(0);
473
+ const MAX_SMB_BUF = 256 * 1024;
474
+
475
+ const finish = () => {
476
+ clearTimeout(to);
477
+ try { sock.destroy(); } catch {}
478
+ safeResolve(result);
479
+ };
480
+
481
+ sock.on('connect', () => {
482
+ dlog('null-session: connected, sending negotiate');
483
+ try { sock.write(wrapNbss(buildSmb2Negotiate())); } catch { finish(); }
484
+ });
485
+
486
+ sock.on('data', chunk => {
487
+ if (chunks.length + chunk.length > MAX_SMB_BUF) { sock.destroy(); return; }
488
+ chunks = Buffer.concat([chunks, chunk]);
489
+
490
+ // Process complete NBSS-framed messages (4-byte header + payload)
491
+ while (chunks.length >= 4) {
492
+ const msgLen = chunks.readUInt32BE(0) & 0x00FFFFFF; // lower 24 bits = SMB2 message length
493
+ if (chunks.length < 4 + msgLen) return; // incomplete message, wait for more data
494
+
495
+ const msg = chunks.subarray(4, 4 + msgLen);
496
+ chunks = chunks.subarray(4 + msgLen); // keep leftover for coalesced messages
497
+
498
+ const hdr = parseSmb2Header(msg);
499
+ if (!hdr) { finish(); return; }
500
+
501
+ if (phase === 'negotiate') {
502
+ if (hdr.status !== STATUS_SUCCESS) { dlog('null-session: negotiate failed'); finish(); return; }
503
+ // Send SESSION_SETUP with NTLMSSP Negotiate (Type 1)
504
+ phase = 'session-setup-1';
505
+ const token = buildNtlmsspNegotiate();
506
+ try { sock.write(wrapNbss(buildSmb2SessionSetup(token))); } catch { finish(); }
507
+ } else if (phase === 'session-setup-1') {
508
+ // Expect STATUS_MORE_PROCESSING_REQUIRED with challenge
509
+ sessionIdLo = hdr.sessionIdLo;
510
+ sessionIdHi = hdr.sessionIdHi;
511
+ if (hdr.status === STATUS_MORE_PROCESSING) {
512
+ // Send Type 3 (authenticate with null creds)
513
+ phase = 'session-setup-2';
514
+ const authToken = buildNtlmsspAuth();
515
+ try { sock.write(wrapNbss(buildSmb2SessionSetup(authToken, sessionIdLo, sessionIdHi))); } catch { finish(); }
516
+ } else if (hdr.status === STATUS_SUCCESS) {
517
+ // Some servers accept directly (guest/anonymous)
518
+ sessionIdLo = hdr.sessionIdLo;
519
+ sessionIdHi = hdr.sessionIdHi;
520
+ result.nullSessionAllowed = true;
521
+ phase = 'tree-connect';
522
+ try { sock.write(wrapNbss(buildSmb2TreeConnect(host, sessionIdLo, sessionIdHi))); } catch { finish(); }
523
+ } else {
524
+ // ACCESS_DENIED or other — null session not allowed
525
+ dlog('null-session: session setup denied, status=0x' + hdr.status.toString(16));
526
+ finish();
527
+ }
528
+ } else if (phase === 'session-setup-2') {
529
+ if (hdr.status === STATUS_SUCCESS) {
530
+ result.nullSessionAllowed = true;
531
+ sessionIdLo = hdr.sessionIdLo; // H2 fix: unconditional assign, not ||
532
+ sessionIdHi = hdr.sessionIdHi;
533
+ phase = 'tree-connect';
534
+ try { sock.write(wrapNbss(buildSmb2TreeConnect(host, sessionIdLo, sessionIdHi))); } catch { finish(); }
535
+ } else {
536
+ dlog('null-session: session auth denied, status=0x' + hdr.status.toString(16));
537
+ finish();
538
+ }
539
+ } else if (phase === 'tree-connect') {
540
+ if (hdr.status === STATUS_SUCCESS) {
541
+ dlog('null-session: IPC$ tree connect succeeded');
542
+ // Tree connect succeeded — in a full implementation we would open named pipes
543
+ // and send DCE/RPC requests. For now we mark success and finish.
544
+ // Share/user enum would require CREATE + WRITE + READ on \\srvsvc and \\samr pipes.
545
+ phase = 'done';
546
+ finish();
547
+ } else {
548
+ dlog('null-session: tree connect denied, status=0x' + hdr.status.toString(16));
549
+ finish();
550
+ }
551
+ } else {
552
+ finish();
553
+ }
554
+ }
555
+ });
556
+
557
+ sock.on('error', () => { finish(); });
558
+ sock.on('timeout', () => { try { sock.destroy(); } catch {} finish(); });
559
+ });
560
+ }
561
+
562
+ /* ----------------------------- Plugin ----------------------------- */
563
+
564
+ export default {
565
+ id: "014",
566
+ name: "NetBIOS/SMB Scanner",
567
+ description: "Discovers NetBIOS names (UDP/137), optional mDNS browse for _smb._tcp, and probes SMB over TCP/445.",
568
+ priority: 345,
569
+ requirements: {}, // no gating; runs when selected
570
+ protocols: ["udp","tcp"],
571
+ ports: [137, 445],
572
+
573
+ async run(host, _port, _opts={}){
574
+ const data = [];
575
+ let up = false;
576
+ let program = "Unknown";
577
+ let version = "Unknown";
578
+
579
+ // UDP/137 NBSTAT
580
+ try{
581
+ const nb = await probeUdp137(host);
582
+ if (nb && nb.names && nb.names.length){
583
+ up = true;
584
+ program = "NetBIOS";
585
+ data.push({
586
+ probe_protocol: 'udp',
587
+ probe_port: 137,
588
+ probe_info: `NBSTAT names: ${nb.names.map(n=>n.name+'<'+n.suffix.toString(16).padStart(2,'0')+(n.group?':G':':U')).join(', ')}`,
589
+ response_banner: nb.mac ? `MAC ${nb.mac}` : null
590
+ });
591
+ } else {
592
+ data.push({
593
+ probe_protocol: 'udp',
594
+ probe_port: 137,
595
+ probe_info: 'No NBSTAT response',
596
+ response_banner: null
597
+ });
598
+ }
599
+ }catch{
600
+ data.push({ probe_protocol:'udp', probe_port:137, probe_info:'Error NBSTAT probe', response_banner:null });
601
+ }
602
+
603
+ // TCP/445 SMB2
604
+ try{
605
+ const smb = await probeTcp445(host);
606
+ if (smb && smb.ok){
607
+ up = true;
608
+ program = program === "Unknown" ? "SMB" : program;
609
+ data.push({
610
+ probe_protocol: 'tcp',
611
+ probe_port: 445,
612
+ probe_info: 'SMB2 negotiate successful',
613
+ response_banner: null
614
+ });
615
+ } else {
616
+ data.push({
617
+ probe_protocol: 'tcp',
618
+ probe_port: 445,
619
+ probe_info: 'No SMB2 response',
620
+ response_banner: null
621
+ });
622
+ }
623
+ }catch{
624
+ data.push({ probe_protocol:'tcp', probe_port:445, probe_info:'Error SMB2 probe', response_banner:null });
625
+ }
626
+
627
+ // mDNS browse (local networks)
628
+ try{
629
+ const md = await probeMdnsSmb();
630
+ if (md){
631
+ up = true; // service seen on LAN
632
+ data.push({
633
+ probe_protocol: 'udp',
634
+ probe_port: 5353,
635
+ probe_info: 'mDNS: _smb._tcp.local seen',
636
+ response_banner: md.toString('hex').slice(0, 120) + (md.length > 60 ? '…' : '')
637
+ });
638
+ }
639
+ }catch{ /* best-effort only */ }
640
+
641
+ // SMB2 null session enumeration (opt-in via SMB_NULL_SESSION env)
642
+ let nullSessionAllowed = false;
643
+ let shares = [];
644
+ let users = [];
645
+
646
+ try {
647
+ const ns = await probeNullSession(host);
648
+ nullSessionAllowed = ns.nullSessionAllowed;
649
+ shares = ns.shares;
650
+ users = ns.users;
651
+ if (nullSessionAllowed) {
652
+ data.push({
653
+ probe_protocol: 'tcp',
654
+ probe_port: 445,
655
+ probe_info: 'WARNING: SMB null session authentication succeeded',
656
+ response_banner: `Null session allowed. Shares: ${shares.length}, Users: ${users.length}`
657
+ });
658
+ } else if (ENABLE_NULL_SESSION) {
659
+ data.push({
660
+ probe_protocol: 'tcp',
661
+ probe_port: 445,
662
+ probe_info: 'SMB null session denied (good)',
663
+ response_banner: null
664
+ });
665
+ }
666
+ } catch {
667
+ if (ENABLE_NULL_SESSION) {
668
+ data.push({
669
+ probe_protocol: 'tcp',
670
+ probe_port: 445,
671
+ probe_info: 'SMB null session probe error',
672
+ response_banner: null
673
+ });
674
+ }
675
+ }
676
+
677
+ return {
678
+ up,
679
+ program,
680
+ version,
681
+ type: 'netbios/smb',
682
+ nullSessionAllowed,
683
+ shares,
684
+ users,
685
+ data
686
+ };
687
+ }
688
+ };
689
+
690
+ // ---------------- Concluder adapter ----------------
691
+ import { statusFrom } from '../utils/conclusion_utils.mjs';
692
+
693
+ export async function conclude({ host, result }){
694
+ const rows = Array.isArray(result?.data) ? result.data : [];
695
+ const items = [];
696
+
697
+ // Primary service record for NetBIOS/SMB
698
+ const pick = rows.find(r => /SMB2|NBSTAT/i.test(String(r?.probe_info || ''))) || rows[0] || null;
699
+ const port = Number(pick?.probe_port ?? 445);
700
+ const info = pick?.probe_info || (result?.up ? 'NetBIOS/SMB detected' : 'No response');
701
+ const banner = pick?.response_banner || null;
702
+ const status = result?.up ? 'open' : statusFrom({ info, banner });
703
+
704
+ const item = {
705
+ port, protocol: pick?.probe_protocol || 'tcp', service: 'netbios/smb',
706
+ program: result?.program || 'Unknown',
707
+ version: result?.version || 'Unknown',
708
+ status, info, banner,
709
+ nullSessionAllowed: result?.nullSessionAllowed ?? false,
710
+ shares: result?.shares ?? [],
711
+ users: result?.users ?? [],
712
+ source: 'netbios',
713
+ evidence: rows,
714
+ authoritative: true
715
+ };
716
+
717
+ items.push(item);
718
+
719
+ // Add WARNING evidence row when null session is allowed
720
+ if (result?.nullSessionAllowed) {
721
+ items.push({
722
+ port: 445, protocol: 'tcp', service: 'netbios/smb',
723
+ program: result?.program || 'Unknown',
724
+ version: result?.version || 'Unknown',
725
+ status: 'open', info: 'WARNING: SMB null session allowed — anonymous enumeration possible',
726
+ banner: `Shares: ${(result.shares||[]).join(', ') || 'none'}, Users: ${(result.users||[]).join(', ') || 'none'}`,
727
+ nullSessionAllowed: true,
728
+ shares: result?.shares ?? [],
729
+ users: result?.users ?? [],
730
+ source: 'netbios',
731
+ evidence: rows,
732
+ authoritative: true
733
+ });
734
+ }
735
+
736
+ return items;
737
+ }