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,421 @@
1
+ // plugins/ssh_scanner.mjs
2
+ // SSH Scanner — connects to TCP/22, parses the RFC 4253 identification string,
3
+ // and optionally performs SSH_MSG_KEXINIT exchange to extract supported algorithms.
4
+
5
+ import net from "node:net";
6
+ import crypto from "node:crypto";
7
+
8
+ /** Your parser, kept exactly as provided (minor safety trims) */
9
+ export function parseSshBanner(b) {
10
+ const line = (b || "").split(/\r?\n/)[0] || "";
11
+ const m = /^SSH-([0-9.]+)-([^\s]+)(?:\s+(.+))?/.exec(line);
12
+ if (!m) return null;
13
+
14
+ const proto = m[1] || "";
15
+ const prodToken = m[2] || "";
16
+ const trail = (m[3] || "").trim();
17
+
18
+ let product = prodToken;
19
+ let version = "";
20
+ let pm = /^(OpenSSH)[-_]?(\d[\w.]+)/i.exec(prodToken);
21
+ if (pm) {
22
+ product = pm[1];
23
+ version = pm[2];
24
+ } else {
25
+ pm = /^([A-Za-z]+_?SSH)[-_]?(\d[\w.]+)/.exec(prodToken);
26
+ if (pm) {
27
+ product = pm[1];
28
+ version = pm[2];
29
+ }
30
+ }
31
+
32
+ let os = "";
33
+ let osVersion = "";
34
+
35
+ // Detect Solaris from Sun_SSH even when there is no trailing comment
36
+ if (/Sun_SSH/i.test(prodToken)) {
37
+ os = "Solaris";
38
+ }
39
+
40
+ if (trail) {
41
+ const tok = trail.split(/\s+/)[0];
42
+ const om = /^(Ubuntu|Debian|Raspbian|FreeBSD|OpenBSD|Alpine|Oracle|SUSE|CentOS|RedHat|Arch|Manjaro|Gentoo)-(.+)$/.exec(tok);
43
+ if (om) {
44
+ os = om[1];
45
+ osVersion = om[2];
46
+ }
47
+ }
48
+
49
+ return { name: "ssh", product: product || "ssh", version: version || "", proto: proto || "", os, osVersion };
50
+ }
51
+
52
+ /* ---- Weak algorithm definitions ---- */
53
+ const WEAK_KEX = ['diffie-hellman-group1-sha1', 'diffie-hellman-group14-sha1', 'diffie-hellman-group-exchange-sha1'];
54
+ const WEAK_CIPHERS = ['aes128-cbc', 'aes192-cbc', 'aes256-cbc', '3des-cbc', 'blowfish-cbc', 'arcfour', 'arcfour128', 'arcfour256'];
55
+ const WEAK_MACS = ['hmac-sha1', 'hmac-md5', 'hmac-sha1-96', 'hmac-md5-96'];
56
+
57
+ /* ---- Client KEXINIT offered algorithms ---- */
58
+ const CLIENT_KEX = 'curve25519-sha256,ecdh-sha2-nistp256,diffie-hellman-group14-sha256';
59
+ const CLIENT_HOST_KEY = 'ssh-ed25519,ecdsa-sha2-nistp256,rsa-sha2-512,rsa-sha2-256';
60
+ const CLIENT_ENCRYPTION = 'aes256-gcm@openssh.com,aes128-gcm@openssh.com,aes256-ctr,aes128-ctr';
61
+ const CLIENT_MAC = 'hmac-sha2-256-etm@openssh.com,hmac-sha2-256';
62
+ const CLIENT_COMPRESSION = 'none';
63
+
64
+ /* ---- SSH binary packet helpers ---- */
65
+
66
+ function encodeNameList(str) {
67
+ const buf = Buffer.from(str, 'utf8');
68
+ const len = Buffer.alloc(4);
69
+ len.writeUInt32BE(buf.length);
70
+ return Buffer.concat([len, buf]);
71
+ }
72
+
73
+ export function buildClientKexinit() {
74
+ const parts = [
75
+ Buffer.from([20]), // SSH_MSG_KEXINIT
76
+ crypto.randomBytes(16), // cookie
77
+ encodeNameList(CLIENT_KEX),
78
+ encodeNameList(CLIENT_HOST_KEY),
79
+ encodeNameList(CLIENT_ENCRYPTION), // c2s
80
+ encodeNameList(CLIENT_ENCRYPTION), // s2c
81
+ encodeNameList(CLIENT_MAC), // c2s
82
+ encodeNameList(CLIENT_MAC), // s2c
83
+ encodeNameList(CLIENT_COMPRESSION), // c2s
84
+ encodeNameList(CLIENT_COMPRESSION), // s2c
85
+ encodeNameList(''), // languages c2s
86
+ encodeNameList(''), // languages s2c
87
+ Buffer.from([0]), // first_kex_packet_follows
88
+ Buffer.alloc(4, 0), // reserved
89
+ ];
90
+ const payload = Buffer.concat(parts);
91
+
92
+ // SSH binary packet: [4:packet_length][1:padding_length][payload][padding]
93
+ // padding must be at least 4 bytes and bring total (padding_length + payload + padding) to multiple of 8
94
+ const blockSize = 8;
95
+ const minPadding = 4;
96
+ let paddingLen = blockSize - ((1 + payload.length + minPadding) % blockSize);
97
+ if (paddingLen < minPadding) paddingLen += blockSize;
98
+ // ensure paddingLen stays within a single unsigned byte
99
+ if (paddingLen > 255) paddingLen = minPadding; // defensive
100
+
101
+ const packetLength = 1 + payload.length + paddingLen;
102
+ const header = Buffer.alloc(5);
103
+ header.writeUInt32BE(packetLength, 0);
104
+ header[4] = paddingLen;
105
+ const padding = crypto.randomBytes(paddingLen);
106
+
107
+ return Buffer.concat([header, payload, padding]);
108
+ }
109
+
110
+ function readNameList(buf, offset) {
111
+ if (offset + 4 > buf.length) return { value: [], next: buf.length };
112
+ const len = buf.readUInt32BE(offset);
113
+ if (offset + 4 + len > buf.length) return { value: [], next: buf.length };
114
+ const str = buf.subarray(offset + 4, offset + 4 + len).toString('utf8');
115
+ return { value: str ? str.split(',') : [], next: offset + 4 + len };
116
+ }
117
+
118
+ export function parseServerKexinit(packetPayload) {
119
+ // packetPayload starts at the SSH_MSG_KEXINIT byte (20)
120
+ if (!packetPayload || packetPayload.length < 17 || packetPayload[0] !== 20) return null;
121
+
122
+ let offset = 1 + 16; // skip type + cookie
123
+ const names = [
124
+ 'kex', 'hostKey',
125
+ 'encryptionC2S', 'encryptionS2C',
126
+ 'macC2S', 'macS2C',
127
+ 'compressionC2S', 'compressionS2C',
128
+ 'languagesC2S', 'languagesS2C',
129
+ ];
130
+ const result = {};
131
+ for (const name of names) {
132
+ const { value, next } = readNameList(packetPayload, offset);
133
+ result[name] = value;
134
+ offset = next;
135
+ }
136
+
137
+ return {
138
+ kex: result.kex,
139
+ hostKey: result.hostKey,
140
+ encryption: [...new Set([...result.encryptionC2S, ...result.encryptionS2C])],
141
+ mac: [...new Set([...result.macC2S, ...result.macS2C])],
142
+ compression: [...new Set([...result.compressionC2S, ...result.compressionS2C])],
143
+ };
144
+ }
145
+
146
+ function findWeakAlgorithms(algorithms) {
147
+ if (!algorithms) return [];
148
+ const weak = [];
149
+ for (const a of algorithms.kex || []) { if (WEAK_KEX.includes(a)) weak.push(a); }
150
+ for (const a of algorithms.encryption || []) { if (WEAK_CIPHERS.includes(a)) weak.push(a); }
151
+ for (const a of algorithms.mac || []) { if (WEAK_MACS.includes(a)) weak.push(a); }
152
+ return weak;
153
+ }
154
+
155
+ function extractKexinitPayload(binaryBuf) {
156
+ // Reads an SSH binary packet from the buffer and returns the payload if it's KEXINIT
157
+ if (binaryBuf.length < 5) return null;
158
+ const packetLength = binaryBuf.readUInt32BE(0);
159
+ if (binaryBuf.length < 4 + packetLength) return null;
160
+ const paddingLength = binaryBuf[4];
161
+ const payloadLength = packetLength - 1 - paddingLength;
162
+ if (payloadLength < 1) return null;
163
+ const payload = binaryBuf.subarray(5, 5 + payloadLength);
164
+ if (payload[0] !== 20) return null; // not KEXINIT
165
+ return payload;
166
+ }
167
+
168
+ function shouldCheckAlgorithms() {
169
+ const v = (process.env.SSH_CHECK_ALGORITHMS ?? 'true').toLowerCase();
170
+ return v !== 'false' && v !== '0' && v !== 'no';
171
+ }
172
+
173
+ async function readSshBanner(host, port, timeoutMs) {
174
+ return new Promise((resolve) => {
175
+ const result = {
176
+ up: false,
177
+ program: "Unknown",
178
+ version: "Unknown",
179
+ os: null,
180
+ type: "ssh",
181
+ algorithms: null,
182
+ weakAlgorithms: [],
183
+ data: []
184
+ };
185
+
186
+ let done = false;
187
+ let connected = false;
188
+ let rawBuf = Buffer.alloc(0); // always accumulate raw bytes
189
+ let bannerLine = null;
190
+ let phase = 'banner'; // 'banner' | 'kexinit' | 'done'
191
+ let kexTimer = null;
192
+
193
+ const finalize = (probe_info, banner = null) => {
194
+ if (done) return;
195
+ done = true;
196
+ phase = 'done';
197
+ if (kexTimer) { clearTimeout(kexTimer); kexTimer = null; }
198
+
199
+ // Parse banner if present
200
+ if (banner && typeof banner === "string") {
201
+ const parsed = parseSshBanner(banner);
202
+ if (parsed) {
203
+ result.up = true;
204
+ result.program = parsed.product || "Unknown";
205
+ result.version = parsed.version || "Unknown";
206
+ result.os = parsed.os ? (parsed.osVersion ? `${parsed.os} ${parsed.osVersion}` : parsed.os) : null;
207
+ probe_info = probe_info || `SSH banner parsed (proto ${parsed.proto || "?"})`;
208
+ } else {
209
+ // We got a line but couldn't parse — still useful evidence.
210
+ result.up = connected || !!banner;
211
+ probe_info = probe_info || "SSH banner received (unparsed)";
212
+ }
213
+ }
214
+
215
+ // If connection was refused, still mark host up (closed port heuristic)
216
+ if (!banner && !result.up && connected) result.up = true;
217
+
218
+ result.data.push({
219
+ probe_protocol: "tcp",
220
+ probe_port: port,
221
+ probe_info,
222
+ response_banner: banner
223
+ });
224
+
225
+ resolve(result);
226
+ };
227
+
228
+ const tryParseKexinit = (binaryBuf) => {
229
+ const payload = extractKexinitPayload(binaryBuf);
230
+ if (!payload) return false;
231
+ const algorithms = parseServerKexinit(payload);
232
+ if (algorithms) {
233
+ result.algorithms = algorithms;
234
+ result.weakAlgorithms = findWeakAlgorithms(algorithms);
235
+ }
236
+ return true;
237
+ };
238
+
239
+ try {
240
+ const sock = net.createConnection({ host, port });
241
+
242
+ // Set conservative timeout (env override)
243
+ const to = Number(process.env.SSH_BANNER_TIMEOUT || timeoutMs || 1500);
244
+ sock.setTimeout(Number.isFinite(to) && to > 0 ? to : 1500);
245
+ // Do NOT setEncoding — keep raw Buffers to preserve binary KEXINIT data
246
+
247
+ const checkAlgs = shouldCheckAlgorithms();
248
+
249
+ sock.on("connect", () => {
250
+ connected = true;
251
+ // For SSH, server sends banner first—no need to send anything
252
+ });
253
+
254
+ const MAX_SSH_BUF = 256 * 1024; // 256KB — more than enough for banner + KEXINIT
255
+ sock.on("data", (chunk) => {
256
+ const chunkBuf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
257
+ rawBuf = Buffer.concat([rawBuf, chunkBuf]);
258
+ if (rawBuf.length > MAX_SSH_BUF) {
259
+ try { sock.destroy(); } catch {}
260
+ finalize('SSH data exceeded safe limit', bannerLine);
261
+ return;
262
+ }
263
+
264
+ if (phase === 'banner') {
265
+ // Look for newline in the raw buffer (SSH banner is ASCII, terminated by \n)
266
+ const nlIdx = rawBuf.indexOf(0x0a); // \n
267
+ if (nlIdx === -1) return; // need more data
268
+
269
+ // Extract the banner line (strip trailing \r if present)
270
+ let lineEnd = nlIdx;
271
+ if (lineEnd > 0 && rawBuf[lineEnd - 1] === 0x0d) lineEnd--; // strip \r
272
+ const line = rawBuf.subarray(0, lineEnd).toString('utf8');
273
+
274
+ // Remainder after the banner line (may contain binary KEXINIT)
275
+ const remainder = rawBuf.subarray(nlIdx + 1);
276
+
277
+ if (!checkAlgs) {
278
+ // Banner-only mode
279
+ try { sock.end(); } catch {}
280
+ finalize("SSH banner received", line);
281
+ return;
282
+ }
283
+
284
+ // Check if server KEXINIT is already in the remainder
285
+ if (remainder.length > 0 && tryParseKexinit(remainder)) {
286
+ try { sock.end(); } catch {}
287
+ finalize("SSH banner received", line);
288
+ return;
289
+ }
290
+
291
+ // Switch to KEXINIT phase
292
+ bannerLine = line;
293
+ phase = 'kexinit';
294
+ // Reset rawBuf to only hold the remainder (post-banner binary data)
295
+ rawBuf = remainder;
296
+
297
+ // Send our client banner identification string (required before KEXINIT)
298
+ sock.write('SSH-2.0-NSAuditor_1.0\r\n');
299
+ // Send our KEXINIT
300
+ sock.write(buildClientKexinit());
301
+
302
+ // Set a separate timeout for KEXINIT exchange
303
+ const kexTimeoutMs = Number(process.env.SSH_KEXINIT_TIMEOUT || 3000);
304
+ kexTimer = setTimeout(() => {
305
+ // KEXINIT timed out — finalize with banner only (graceful fallback)
306
+ try { sock.end(); } catch {}
307
+ finalize("SSH banner received", bannerLine);
308
+ }, kexTimeoutMs);
309
+ } else if (phase === 'kexinit') {
310
+ // Try to extract KEXINIT payload from accumulated binary data
311
+ if (tryParseKexinit(rawBuf)) {
312
+ try { sock.end(); } catch {}
313
+ finalize("SSH banner received", bannerLine);
314
+ }
315
+ }
316
+ });
317
+
318
+ sock.on("timeout", () => {
319
+ try { sock.destroy(new Error("Timeout")); } catch {}
320
+ });
321
+
322
+ sock.on("error", (err) => {
323
+ if (phase === 'kexinit' && bannerLine) {
324
+ // KEXINIT failed but we have the banner — graceful fallback
325
+ finalize("SSH banner received", bannerLine);
326
+ return;
327
+ }
328
+ // ECONNREFUSED implies host up, port closed (like your FTP plugin)
329
+ if (err?.code === "ECONNREFUSED") {
330
+ finalize("Connection refused - host up, SSH port closed", null);
331
+ } else if (err?.code === "ETIMEDOUT") {
332
+ finalize("Timeout", null);
333
+ } else {
334
+ finalize(`Error: ${err?.code || err?.message || String(err)}`, null);
335
+ }
336
+ });
337
+
338
+ sock.on("close", () => {
339
+ if (!done && phase === 'kexinit' && bannerLine) {
340
+ // Connection closed during KEXINIT — graceful fallback with banner
341
+ finalize("SSH banner received", bannerLine);
342
+ } else if (!done) {
343
+ finalize(connected ? "Connection closed before banner" : "No response", null);
344
+ }
345
+ });
346
+ } catch (err) {
347
+ finalize(`Exception: ${err?.message || String(err)}`, null);
348
+ }
349
+ });
350
+ }
351
+
352
+ export default {
353
+ id: "002",
354
+ name: "SSH Scanner",
355
+ description: "Connects to SSH (TCP 22), reads the identification banner, and extracts product, version, and OS hints.",
356
+ priority: 50,
357
+ requirements: { host: "up", tcp_open: [22] },
358
+ protocols: ["tcp"],
359
+ ports: [22],
360
+
361
+ /**
362
+ * @param {string} host
363
+ * @param {number} [port=22]
364
+ * @param {object} [options]
365
+ * @returns {Promise<object>} result object (manager wraps with id/name)
366
+ */
367
+ async run(host, port = 22, options = {}) {
368
+ console.log(`Running SSH Scanner on ${host}:${port}`);
369
+ const res = await readSshBanner(host, port, options?.timeoutMs || 1500);
370
+ // Mirror your other plugins’ console style (manager will also print the wrapped result)
371
+ console.log("SSH Scanner Result:", JSON.stringify({
372
+ id: this.id,
373
+ name: this.name,
374
+ result: res
375
+ }, null, 2));
376
+ return res; // manager expects only the "result" object
377
+ }
378
+ };
379
+
380
+ import { statusFrom } from '../utils/conclusion_utils.mjs';
381
+
382
+ export async function conclude({ host, result }) {
383
+ const rows = Array.isArray(result?.data) ? result.data : [];
384
+ const items = [];
385
+ for (const r of rows) {
386
+ if (String(r?.probe_protocol||'') !== 'tcp') continue;
387
+ const port = Number(r?.probe_port ?? 22);
388
+ const info = r?.probe_info || '';
389
+ const banner = r?.response_banner || '';
390
+ // SSH timeout policy
391
+ let status;
392
+ if (/timeout/i.test(String(info))) {
393
+ const pol = String(process.env.CONCLUDER_SSH_TIMEOUT_AS || 'filtered').toLowerCase();
394
+ status = (pol === 'closed' || pol === 'unknown' || pol === 'filtered') ? pol : 'filtered';
395
+ } else {
396
+ status = statusFrom({ info, banner, fallbackUp: result?.up });
397
+ }
398
+ items.push({
399
+ port, protocol: 'tcp', service: 'ssh',
400
+ program: result?.program || 'Unknown',
401
+ version: result?.version || 'Unknown',
402
+ status, info: info || null, banner: banner || null,
403
+ algorithms: result?.algorithms || null,
404
+ weakAlgorithms: result?.weakAlgorithms || [],
405
+ source: 'ssh', evidence: rows, authoritative: true
406
+ });
407
+ }
408
+ if (!items.length) {
409
+ items.push({
410
+ port: 22, protocol: 'tcp', service: 'ssh',
411
+ program: result?.program || 'Unknown',
412
+ version: result?.version || 'Unknown',
413
+ status: result?.up ? 'open' : 'unknown',
414
+ info: null, banner: null,
415
+ algorithms: result?.algorithms || null,
416
+ weakAlgorithms: result?.weakAlgorithms || [],
417
+ source: 'ssh', evidence: rows, authoritative: true
418
+ });
419
+ }
420
+ return items;
421
+ }