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,274 @@
1
+ // plugins/result_concluder.mjs — plug-and-play dispatcher with full metadata and evidence
2
+ import { normalizeService, upsertService, keyOf } from '../utils/conclusion_utils.mjs';
3
+
4
+ function pickResultsFromArgs(args) {
5
+ if (Array.isArray(args[0])) return args[0];
6
+ if (args.length >= 3 && args[2] && Array.isArray(args[2].results)) return args[2].results;
7
+ if (args.length === 1 && args[0] && Array.isArray(args[0].results)) return args[0].results;
8
+ return [];
9
+ }
10
+
11
+ // Stable slug from plugin name, falling back to IDs
12
+ function slugify(name, id) {
13
+ const base = String(name || '').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
14
+ if (base) return base;
15
+ const map = { '001':'ping_checker','002':'ssh_scanner','003':'port_scanner','004':'ftp_banner_check','005':'host_up_check','006':'http_probe','007':'snmp_scanner','009':'dns_scanner','010':'webapp_detector','011':'tls_scanner','012':'opensearch_scanner','013':'os_detector','014':'netbios__smb_scanner','015':'sunrpc_scanner','024':'syn_scanner','025':'db_scanner','026':'arp_scanner','027':'mdns_scanner','028':'upnp_scanner' };
16
+ return map[String(id)] || String(id);
17
+ }
18
+
19
+ function scoreOsLabel(label) {
20
+ const s = String(label||'').toLowerCase();
21
+ if (!s || s === 'unknown') return 0;
22
+ if (/red\s*hat|centos|rhel/.test(s)) return 120;
23
+ if (/ubuntu|debian/.test(s)) return 110;
24
+ if (/suse|opensuse|alpine/.test(s)) return 105;
25
+ if (/freebsd|openbsd|netbsd/.test(s)) return 104;
26
+ if (/solaris|aix|hp-ux/.test(s)) return 103;
27
+ if (/windows/.test(s)) return 100;
28
+ if (/macos|os\s*x|ios|apple/.test(s)) return 95;
29
+ if (/linux/.test(s)) return 20;
30
+ return 10;
31
+ }
32
+
33
+ function pickOs(currentOs, currentVersion, candidateOs, candidateVersion, source, curSource) {
34
+ if (!candidateOs || candidateOs === 'Unknown') return { os: currentOs, osVersion: currentVersion, source: curSource };
35
+ if (!currentOs || currentOs === 'Unknown') return { os: candidateOs, osVersion: candidateVersion, source };
36
+ const cScore = scoreOsLabel(currentOs);
37
+ const nScore = scoreOsLabel(candidateOs);
38
+ if (nScore > cScore) return { os: candidateOs, osVersion: candidateVersion, source };
39
+ if (nScore === cScore) {
40
+ if (String(candidateOs).length > String(currentOs).length) return { os: candidateOs, osVersion: candidateVersion, source };
41
+ const candIsDetector = /(^013$)|os\s*detector/i.test(String(source||''));
42
+ const curIsDetector = /(^013$)|os\s*detector/i.test(String(curSource||''));
43
+ if (candIsDetector && !curIsDetector) return { os: candidateOs, osVersion: candidateVersion, source };
44
+ }
45
+ return { os: currentOs, osVersion: currentVersion, source: curSource };
46
+ }
47
+
48
+ // Generic fallback when a plugin provides no adapter
49
+ function fallbackRecord(pluginName, result) {
50
+ const rows = Array.isArray(result?.data) ? result.data : [];
51
+ const row = rows.find(Boolean) || {};
52
+ const proto = row?.probe_protocol || result?.protocol || 'tcp';
53
+ const port = Number(row?.probe_port ?? (
54
+ /ftp/i.test(pluginName) ? 21 :
55
+ /ssh/i.test(pluginName) ? 22 :
56
+ /dns/i.test(pluginName) ? 53 :
57
+ /snmp/i.test(pluginName) ? 161 :
58
+ result?.port ?? 0
59
+ ));
60
+ let status = result?.up ? 'open' : 'unknown';
61
+
62
+ // Re-label ECONNREFUSED as closed (requested feature)
63
+ if (row?.probe_info && /refused|ECONNREFUSED/i.test(String(row.probe_info))) {
64
+ status = 'closed';
65
+ }
66
+
67
+ return [{
68
+ port, protocol: proto, service: (pluginName || 'unknown').toLowerCase().split(/\s+/)[0],
69
+ program: result?.program || 'Unknown', version: result?.version || 'Unknown',
70
+ status,
71
+ info: row?.probe_info || null, banner: row?.response_banner || null,
72
+ source: (pluginName || 'plugin').toLowerCase().split(/\s+/)[0],
73
+ evidence: rows
74
+ }];
75
+ }
76
+
77
+ function extractHostNameFromUpnp(result) {
78
+ const rows = Array.isArray(result?.data) ? result.data : [];
79
+ for (const row of rows) {
80
+ const banner = row?.response_banner;
81
+ if (banner) {
82
+ try {
83
+ const obj = JSON.parse(banner);
84
+ const xml = obj?.descriptionXML || '';
85
+ const match = xml.match(/<friendlyName>(.*?)<\/friendlyName>/);
86
+ if (match && match[1]) {
87
+ return match[1];
88
+ }
89
+ } catch {}
90
+ }
91
+ }
92
+ return null;
93
+ }
94
+
95
+ function extractHostNameFromMdns(result) {
96
+ const rows = Array.isArray(result?.data) ? result.data : [];
97
+ for (const row of rows) {
98
+ let name = null;
99
+ const banner = row?.response_banner;
100
+ if (banner) {
101
+ try {
102
+ const obj = JSON.parse(banner);
103
+ // 1. Prefer txt.fn (friendly name)
104
+ if (obj?.txt?.fn) return obj.txt.fn;
105
+ // 2. Fallback to txt.md (model description)
106
+ if (obj?.txt?.md) return obj.txt.md;
107
+ // 3. Fallback to name field in banner JSON
108
+ if (obj?.name) return obj.name;
109
+ // 4. Fallback to fullname
110
+ const fullname = obj?.fullname || '';
111
+ const nameMatch = fullname.match(/[^._]+/);
112
+ if (nameMatch && nameMatch[0]) return nameMatch[0];
113
+ } catch (e) {
114
+ // ignore JSON parse error
115
+ }
116
+ }
117
+ // 5. Always check probe_info for name="..." if not found above
118
+ if (row?.probe_info) {
119
+ const m = row.probe_info.match(/name="([^"]+)"/);
120
+ if (m && m[1]) return m[1];
121
+ }
122
+ }
123
+ return null;
124
+ }
125
+
126
+ export default {
127
+ id: "008",
128
+ name: "Result Concluder",
129
+ description: "Aggregates plugin results and produces a unified summary, host OS, and per-service findings.",
130
+ priority: 100000,
131
+ requirements: {},
132
+ runStrategy: "single",
133
+
134
+ async run(...args) {
135
+ const results = pickResultsFromArgs(args);
136
+ const services = [];
137
+ const evidence = [];
138
+ let os = null;
139
+ let osVersion = null;
140
+ let osSource = null;
141
+ let hostName = null;
142
+
143
+ const pushEvidence = (from, rows) => {
144
+ const max = Number(process.env.CONCLUDER_EVIDENCE_MAX || 200);
145
+ for (const d of (Array.isArray(rows) ? rows : [])) {
146
+ if (evidence.length >= max) break;
147
+ const piece = {
148
+ from: from || 'plugin',
149
+ protocol: d?.probe_protocol ?? null,
150
+ port: d?.probe_port ?? null,
151
+ status: d?.status ?? null,
152
+ info: d?.probe_info ?? null,
153
+ };
154
+ const banner = d?.response_banner;
155
+ if (banner) {
156
+ const s = String(banner);
157
+ piece.banner = s.length > 800 ? s.slice(0, 800) + '…' : s;
158
+ }
159
+ evidence.push(piece);
160
+ }
161
+ };
162
+
163
+ for (const r of results) {
164
+ const name = r?.name || r?.id || 'plugin';
165
+ const slug = slugify(name, r?.id);
166
+ const modPath = `./${slug}.mjs`;
167
+
168
+ // Prefer OS and osVersion provided by plugins, but pick the most specific; OS Detector wins ties
169
+ if (r?.result?.os) {
170
+ const picked = pickOs(os, osVersion, r.result.os, r.result.osVersion, String(r?.id || r?.name), osSource);
171
+ os = picked.os;
172
+ osVersion = picked.osVersion;
173
+ osSource = picked.source;
174
+ }
175
+
176
+ // Extract host name from UPnP Scanner if available
177
+ if (slug === 'upnp_scanner') {
178
+ hostName = extractHostNameFromUpnp(r?.result) || hostName;
179
+ }
180
+
181
+ // Extract host name from mDNS Scanner if available
182
+ if (slug === 'mdns_scanner') {
183
+ hostName = extractHostNameFromMdns(r?.result) || hostName;
184
+ }
185
+
186
+ let recs = null;
187
+ try {
188
+ const mod = await import(modPath);
189
+ if (typeof mod.conclude === 'function') {
190
+ recs = await mod.conclude({ host: typeof args[0] === 'string' ? args[0] : undefined, result: r?.result });
191
+ const authSet = mod?.authoritativePorts instanceof Set ? mod.authoritativePorts : null;
192
+ for (const item of (recs || [])) {
193
+ const rec = normalizeService({ ...item, source: item.source || slug });
194
+ const key = keyOf(rec);
195
+ const authoritative = (authSet && authSet.has(key)) || !!item.authoritative;
196
+ upsertService(services, rec, { authoritative });
197
+ }
198
+ if (r?.result?.data) pushEvidence(name, r.result.data);
199
+ continue;
200
+ }
201
+ } catch {
202
+ // no adapter -> fall through
203
+ }
204
+ for (const item of fallbackRecord(name, r?.result)) {
205
+ upsertService(services, normalizeService(item), { authoritative: false });
206
+ }
207
+ if (r?.result?.data) pushEvidence(name, r.result.data);
208
+ }
209
+
210
+ for (const svc of services) delete svc.__authoritative;
211
+
212
+ services.sort((a,b)=> (a.port - b.port) || String(a.protocol).localeCompare(String(b.protocol)) );
213
+
214
+ // Separate meta/non-service entries from real services
215
+ const META_PROTOCOLS = new Set(['assessment', 'icmp', 'os-detector', 'arp']);
216
+ const PORT_ZERO_META_PROTOCOLS = new Set(['api', 'tcp', 'udp']);
217
+ const isMetaEntry = (s) => {
218
+ if (META_PROTOCOLS.has(s.protocol)) return true;
219
+ if (s.port === 0 && PORT_ZERO_META_PROTOCOLS.has(s.protocol)) return true;
220
+ if (s.info && /Skipped:/i.test(String(s.info))) return true;
221
+ return false;
222
+ };
223
+
224
+ const metaEntries = services.filter(isMetaEntry);
225
+ const realServices = services.filter(s => !isMetaEntry(s));
226
+
227
+ // Move meta entries into evidence only
228
+ for (const m of metaEntries) {
229
+ evidence.push({
230
+ from: m.source || 'meta',
231
+ protocol: m.protocol,
232
+ port: m.port,
233
+ status: m.status,
234
+ info: m.info,
235
+ ...(m.banner ? { banner: m.banner } : {}),
236
+ });
237
+ }
238
+
239
+ // Replace services array contents with real services only
240
+ services.length = 0;
241
+ services.push(...realServices);
242
+
243
+ if (!os) {
244
+ const banners = services.flatMap(s => [s.banner, s.info, s.program]).filter(Boolean).join(' ').toLowerCase();
245
+ if (/vsftpd|pure-?ftpd|bftpd/.test(banners)) os = 'Linux';
246
+ else if (/filezilla|windows/.test(banners)) os = 'Windows';
247
+ else if (/apple|macos|mac\s*os|os\s*x/.test(banners)) os = 'macOS';
248
+ else os = null;
249
+ }
250
+
251
+ const hostUp = results.some(r => r?.result?.up === true) || services.some(s => s.status === 'open');
252
+ const open = services.filter(s => s.status === 'open');
253
+ const parts = [];
254
+ parts.push(hostUp ? `Host${hostName ? ` (${hostName})` : ''} is UP` : 'Host appears DOWN');
255
+ if (os) parts.push(`OS: ${os}`);
256
+ if (osVersion) parts.push(`Version: ${osVersion}`);
257
+ if (open.length) {
258
+ const top = open.slice(0, 3).map(s => `${s.service}/${s.port}`).join(', ');
259
+ const more = open.length > 3 ? ` (+${open.length - 3} more open)` : '';
260
+ parts.push(`Open: ${top}${more}`);
261
+ } else {
262
+ parts.push('No open services detected');
263
+ }
264
+
265
+ return {
266
+ summary: parts.join(' — '),
267
+ host: { up: hostUp, os, osVersion, name: hostName },
268
+ services,
269
+ evidence,
270
+ source_count: results.length,
271
+ os_source: osSource || null
272
+ };
273
+ }
274
+ };
@@ -0,0 +1,278 @@
1
+ // plugins/snmp_scanner.mjs
2
+ // Plugin to probe SNMP (UDP port 161) to detect device types (e.g., printers, routers) via sysDescr OID.
3
+ // Uses 'snmp-native' npm package, declared in dependencies.
4
+ // Returns { up: boolean, program: string, version: string, os: string|null, type: string, data: [{ probe_protocol, probe_port, probe_info, response_banner }], serialNumber: string, hardwareVersion: string, firmwareVersion: string, ip6: string|null, deviceWebPage: string|null, deviceWebPageInstruction: string }.
5
+
6
+ export const DEFAULT_COMMUNITIES = ['public', 'private'];
7
+ export const snmpCommunities = process.env.SNMP_COMMUNITY
8
+ ? String(process.env.SNMP_COMMUNITY).split(',').map(s => s.trim()).filter(Boolean)
9
+ : DEFAULT_COMMUNITIES;
10
+ const oidTable = {
11
+ default: [1, 3, 6, 1, 2, 1, 1, 1, 0], // sysDescr
12
+ epsonShortName: [1, 3, 6, 1, 2, 1, 1, 5, 0], // sysDescr EPSON6768F4 '1.3.6.1.2.1.1.5.0'
13
+ epsonModel: [1, 3, 6, 1, 4, 1, 1248, 1, 1, 3, 1, 29, 3, 1, 45, 0], // Epson model OID
14
+ epsonSerial: [1, 3, 6, 1, 4, 1, 1248, 1, 2, 2, 1, 1, 1, 4, 1], // Epson serial number
15
+ epsonVersions: [1, 3, 6, 1, 2, 1, 2, 2, 1, 2, 1], // Hardware and firmware versions
16
+ epsonIPv6: [1, 3, 6, 1, 4, 1, 1248, 1, 1, 3, 1, 4, 46, 1, 2, 2], // IPv6 address in URL
17
+ epsonName: [1, 3, 6, 1, 4, 1, 1248, 1, 1, 3, 1, 26, 2, 1, 3, 1], // Epson device full name
18
+ epsonVersion: [1, 3, 6, 1, 4, 1, 1248, 1, 1, 3, 1, 21, 1, 1, 2, 5], // Epson device version
19
+ epsonFirmware: null
20
+ };
21
+
22
+ /** Clamp helper: keep first N chars (used for Epson S/N = 10 chars) */
23
+ const clampSerial = (sn, n = 10) => (sn && sn.length > n ? sn.slice(0, n) : sn);
24
+
25
+ /**
26
+ * Parse an SNMP sysDescr for OS/family/version hints.
27
+ */
28
+ function fromSysDescr(v) {
29
+ const s = (v || '').toLowerCase();
30
+
31
+ if (s.includes('epson')) {
32
+ return { family: 'Epson', os: 'Embedded Linux', version: '' };
33
+ }
34
+ if (s.includes('cisco')) {
35
+ const iosMatch = s.match(/version\s+([0-9.]+)/);
36
+ return { family: 'Cisco', os: 'Cisco IOS', version: iosMatch ? iosMatch[1] : '' };
37
+ }
38
+ if (s.includes('palo alto')) {
39
+ const paMatch = s.match(/pa-([0-9]+)/);
40
+ return { family: 'Palo Alto Networks', os: 'PAN-OS', version: paMatch ? paMatch[1] : '' };
41
+ }
42
+ if (s.includes('synology')) {
43
+ const modelMatch = s.match(/diskstation\s+([a-z0-9+]+)/);
44
+ return { family: 'Synology', os: 'DiskStation Manager (DSM)', version: modelMatch ? modelMatch[1] : '' };
45
+ }
46
+ if (s.includes('windows')) {
47
+ const versionMatch = s.match(/version\s+([0-9.]+)/);
48
+ return { family: 'Microsoft', os: 'Windows', version: versionMatch ? versionMatch[1] : '' };
49
+ }
50
+ if (s.includes('linux')) {
51
+ const versionMatch = s.match(/linux\s+.*?([0-9.]+\S*)/);
52
+ return { family: 'Linux', os: 'Linux/Unix', version: versionMatch ? versionMatch[1] : '' };
53
+ }
54
+ if (s.includes('unix')) {
55
+ return { family: 'Unix', os: 'Linux/Unix', version: '' };
56
+ }
57
+ return { family: null, os: null, version: '' };
58
+ }
59
+
60
+ /** Parse "EEPS2 Hard Ver.1.00 Firm Ver.0.23" */
61
+ function parseVersions(versionString) {
62
+ const regex = /hard\s+ver\.(\d+\.\d+)\s+firm\s+ver\.(\d+\.\d+)/i;
63
+ const m = String(versionString || '').match(regex);
64
+ if (m && m.length === 3) {
65
+ return { hardwareVersion: m[1], firmwareVersion: m[2] };
66
+ }
67
+ return { hardwareVersion: null, firmwareVersion: null };
68
+ }
69
+
70
+ /**
71
+ * Extract a printer serial number from an SNMP VarBind.
72
+ */
73
+ function parseSerialNumber(snmpResponse) {
74
+ let raw = '';
75
+ if (snmpResponse && Buffer.isBuffer(snmpResponse.valueRaw)) {
76
+ raw = snmpResponse.valueRaw.toString('latin1');
77
+ } else if (snmpResponse && typeof snmpResponse.value === 'string') {
78
+ raw = snmpResponse.value;
79
+ } else {
80
+ return { serialNumber: '' };
81
+ }
82
+
83
+ const asciiStr = raw.replace(/[^\x20-\x7E]/g, ' ').replace(/\s+/g, ' ').trim();
84
+
85
+ const BLOCKLIST = new Set([ 'UNKNOWN', 'BDC', 'ST2', 'HTTP', 'IPP', 'EPSON', 'ET', 'SERIES', 'PRINT', 'SERVER' ]);
86
+
87
+ const candidates = [];
88
+ const re = /\b([A-Z0-9]{10,14})\b/g;
89
+ for (const m of asciiStr.matchAll(re)) {
90
+ const tok = m[1];
91
+ if (BLOCKLIST.has(tok)) continue;
92
+ const letters = (tok.match(/[A-Z]/g) || []).length;
93
+ const digits = (tok.match(/\d/g) || []).length;
94
+ if (letters >= 2 && digits >= 2) {
95
+ candidates.push({ tok, idx: m.index });
96
+ }
97
+ }
98
+ if (!candidates.length) return { serialNumber: '' };
99
+ candidates.sort((a, b) => (b.tok.length - a.tok.length) || (b.idx - a.idx));
100
+ return { serialNumber: candidates[0].tok };
101
+ }
102
+
103
+ export default {
104
+ id: '007',
105
+ name: 'SNMP Scanner',
106
+ description: 'Probes SNMP (UDP 161) to detect device types via sysDescr and Epson OIDs.',
107
+ priority: 70,
108
+ requirements: { host: "up", udp_open: [161] },
109
+ protocols: ['udp'],
110
+ ports: [161],
111
+ dependencies: ['snmp-native'],
112
+ async run(host, port, opts = {}) {
113
+ const options = opts; // drop-in rename; SNMP always uses port 161 (UDP)
114
+ let up = false;
115
+ let program = 'Unknown';
116
+ let version = 'Unknown';
117
+ let os = null;
118
+ let type = 'unknown';
119
+ let serialNumber = '';
120
+ let hardwareVersion = '';
121
+ let firmwareVersion = '';
122
+ let ip6 = null;
123
+ let deviceWebPage = null;
124
+ let deviceWebPageInstruction = '';
125
+ let deviceFullName = null;
126
+ let community = null;
127
+ const communitiesTried = [];
128
+ const data = [];
129
+
130
+ try {
131
+ const snmp = await import('snmp-native').catch(() => null);
132
+ if (!snmp?.default?.Session && !snmp?.Session) {
133
+ throw new Error('snmp-native package not properly loaded');
134
+ }
135
+ const Session = snmp.default?.Session || snmp.Session;
136
+
137
+ const useOid = options.oid ? options.oid.split('.').map(Number) : oidTable.default;
138
+
139
+ for (const comm of snmpCommunities) {
140
+ communitiesTried.push(comm);
141
+ const s = new Session({ host, community: comm, timeouts: [5000] });
142
+
143
+ try {
144
+ const vbs = await new Promise((resolve) => {
145
+ let settled = false;
146
+ const timer = setTimeout(() => { if (!settled) resolve([]); }, 2000);
147
+ try {
148
+ s.get({ oid: useOid }, (err, vb) => {
149
+ if (settled) return;
150
+ clearTimeout(timer);
151
+ settled = true;
152
+ resolve(err ? [] : (vb || []));
153
+ });
154
+ } catch { clearTimeout(timer); if (!settled) { settled = true; resolve([]); } }
155
+ });
156
+
157
+ const val = String(vbs?.[0]?.value || '');
158
+ if (val) {
159
+ up = true;
160
+ community = comm;
161
+ const parsed = fromSysDescr(val);
162
+ program = parsed.family || 'Unknown';
163
+ version = parsed.version || 'Unknown';
164
+ os = parsed.os;
165
+
166
+ if (/cisco|palo alto/i.test(program) || /router|switch/i.test(val)) type = 'router';
167
+ else if (/synology|epson|printer/i.test(program + ' ' + val)) type = 'printer';
168
+ else if (/microsoft|windows|linux|unix/i.test(program)) type = 'server';
169
+
170
+ let probeInfo = `SNMP response received: ${program} ${version}${os ? ` (OS: ${os})` : ''} (Type: ${type})`;
171
+ if (comm === 'public' || comm === 'private') {
172
+ probeInfo += ` WARNING: Default SNMP community string '${comm}' accepted — misconfiguration`;
173
+ }
174
+
175
+ data.push({
176
+ probe_protocol: 'udp',
177
+ probe_port: 161,
178
+ probe_info: probeInfo,
179
+ response_banner: val || null
180
+ });
181
+
182
+ // Epson extras
183
+ const isEpson = /epson/i.test(program + ' ' + val);
184
+ if (isEpson) {
185
+ const get = (oid) => new Promise((resolve) => {
186
+ let settled = false;
187
+ const timer = setTimeout(() => { if (!settled) { settled = true; resolve([]); } }, 2000);
188
+ try {
189
+ s.get({ oid }, (err, vb) => {
190
+ clearTimeout(timer);
191
+ if (!settled) { settled = true; resolve(err ? [] : (vb || [])); }
192
+ });
193
+ } catch { clearTimeout(timer); if (!settled) { settled = true; resolve([]); } }
194
+ });
195
+
196
+ const serialVbs = await get(oidTable.epsonSerial);
197
+ const { serialNumber: sn } = parseSerialNumber(serialVbs?.[0] || '');
198
+ if (sn) serialNumber = clampSerial(sn, 10);
199
+
200
+ const versionsVbs = await get(oidTable.epsonVersions);
201
+ const { hardwareVersion: hw, firmwareVersion: fw } = parseVersions(versionsVbs?.[0]?.value || '');
202
+ if (hw) hardwareVersion = hw;
203
+ if (fw) firmwareVersion = fw;
204
+
205
+ const nameVbs = await get(oidTable.epsonName);
206
+ const nameVal = String(nameVbs?.[0]?.value || '');
207
+ if (nameVal) {
208
+ program = nameVal;
209
+ deviceFullName = nameVal;
210
+ }
211
+
212
+ const versionVbs = await get(oidTable.epsonVersion);
213
+ const versionVal = String(versionVbs?.[0]?.value || '');
214
+ if (versionVal) version = versionVal;
215
+ }
216
+
217
+ // Only add 'Serial:' when a real serial exists
218
+ const infoPieces = [`SNMP response received: ${program} ${version}${os ? ` (OS: ${os})` : ''} (Type: ${type}`];
219
+ if (serialNumber) infoPieces.push(`Serial: ${serialNumber}`);
220
+ if (hardwareVersion) infoPieces.push(`HW Ver: ${hardwareVersion}`);
221
+ if (firmwareVersion) infoPieces.push(`FW Ver: ${firmwareVersion}`);
222
+ infoPieces.push(')');
223
+ if (comm === 'public' || comm === 'private') {
224
+ infoPieces.push(` WARNING: Default SNMP community string '${comm}' accepted — misconfiguration`);
225
+ }
226
+ data[0].probe_info = infoPieces.join(', ');
227
+
228
+ const bannerExtras = [];
229
+ if (serialNumber) bannerExtras.push(`Serial=${serialNumber}`);
230
+ if (hardwareVersion) bannerExtras.push(`HW=${hardwareVersion}`);
231
+ if (firmwareVersion) bannerExtras.push(`FW=${firmwareVersion}`);
232
+ if (bannerExtras.length) {
233
+ data[0].response_banner = `${val}\nAdditional Info: ${bannerExtras.join(', ')}`;
234
+ } else {
235
+ data[0].response_banner = val;
236
+ }
237
+
238
+ break;
239
+ } else {
240
+ data.push({
241
+ probe_protocol: 'udp',
242
+ probe_port: 161,
243
+ probe_info: `No SNMP response for community "${comm}"`,
244
+ response_banner: null
245
+ });
246
+ }
247
+ } finally {
248
+ try { s.close(); } catch {}
249
+ }
250
+ }
251
+ } catch (err) {
252
+ data.push({
253
+ probe_protocol: 'udp',
254
+ probe_port: 161,
255
+ probe_info: `Error: ${err.message}`,
256
+ response_banner: null
257
+ });
258
+ }
259
+
260
+ return { up, program, version, os, type, serialNumber, hardwareVersion, firmwareVersion, ip6, deviceWebPage, deviceWebPageInstruction, community, communitiesTried, data };
261
+ }
262
+ };
263
+
264
+ export async function conclude({ host, result }) {
265
+ const rows = Array.isArray(result?.data) ? result.data : [];
266
+ const row = rows[0] || null;
267
+ const isDefault = DEFAULT_COMMUNITIES.includes(result?.community);
268
+ const communityInfo = result?.community ? ` [community=${isDefault ? result.community : 'custom'}]` : '';
269
+ return [{
270
+ port: 161, protocol: 'udp', service: 'snmp',
271
+ program: result?.program || 'Unknown', version: result?.version || 'Unknown',
272
+ status: result?.up ? 'open' : 'no response',
273
+ info: row?.probe_info ? `${row.probe_info}${communityInfo}` : communityInfo || null,
274
+ banner: row?.response_banner ? `${row.response_banner}${communityInfo}` : null,
275
+ community: result?.community || null,
276
+ source: 'snmp', evidence: rows, authoritative: true
277
+ }];
278
+ }