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,276 @@
1
+
2
+ // plugins/opensearch_scanner.mjs
3
+ // OpenSearch Scanner — probes OpenSearch HTTP API ports and extracts version,
4
+ // plus (heuristically) Linux kernel and Node.js versions if present in banners.
5
+ //
6
+ // CHANGE: Default ports now only include 9200 (API) and 5601 (Dashboards).
7
+ // Previously 80/443 were included, which produced noisy entries in reports.
8
+ // You can still add 80/443 (or any others) via OPENSEARCH_SCANNER_PORTS.
9
+ //
10
+ // Env vars:
11
+ // OPENSEARCH_SCANNER_TIMEOUT_MS default 6000
12
+ // OPENSEARCH_SCANNER_PORTS CSV of port[:service] (default "9200:opensearch,5601:opensearch-dashboards")
13
+ // OPENSEARCH_SCANNER_SCHEMES CSV of schemes to try per HTTP port (default "http,https")
14
+ // OPENSEARCH_SCANNER_INSECURE_TLS 1/true to skip TLS verification for HTTPS
15
+ // OPENSEARCH_SCANNER_DEBUG 1/true to include extra debug info in banner
16
+ // OPENSEARCH_SCANNER_INCLUDE_TRANSPORT 1/true to also probe 9300 (transport, not HTTP)
17
+ //
18
+ // Notes:
19
+ // - We attempt HTTP(S) GET / on HTTP ports. 9300 (transport) is not HTTP —
20
+ // it's optional and only recorded as "transport port (not HTTP)" if enabled.
21
+ // - Version is read from JSON.version.number when available.
22
+ // - OS + Node.js versions are parsed from any banner/header that contains the
23
+ // opensearch-js UA format.
24
+ // - We record response headers and body (truncated) for Evidence.
25
+
26
+ const DEFAULT_PORTS = {
27
+ 9200: 'opensearch',
28
+ 5601: 'opensearch-dashboards',
29
+ };
30
+
31
+ function parseCsvEnv(name, fallback) {
32
+ const v = process.env[name];
33
+ if (!v) return fallback;
34
+ const arr = String(v).split(',').map(s => s.trim()).filter(Boolean);
35
+ return arr.length ? arr : fallback;
36
+ }
37
+
38
+ function parsePortsEnv(name, fallback) {
39
+ const v = process.env[name];
40
+ if (!v) return fallback;
41
+ const out = {};
42
+ for (const tok of String(v).split(',').map(s => s.trim()).filter(Boolean)) {
43
+ const [p, svc] = tok.split(':').map(s => s.trim());
44
+ const n = Number(p);
45
+ if (Number.isFinite(n)) out[n] = svc || (DEFAULT_PORTS[n] || `tcp-${n}`);
46
+ }
47
+ return Object.keys(out).length ? out : fallback;
48
+ }
49
+
50
+ // Use global fetch (Node 18+). If not present, dynamic import of node-fetch (only when needed).
51
+ async function getFetch() {
52
+ if (typeof fetch === 'function') return fetch;
53
+ const mod = await import('node-fetch');
54
+ return mod.default || mod;
55
+ }
56
+
57
+ // Best effort: build a simple https.Agent that can ignore certs if requested.
58
+ // We avoid importing 'https' unless we need it.
59
+ async function buildAgentIfNeeded(scheme, insecure) {
60
+ if (!insecure || scheme !== 'https') return undefined;
61
+ const https = await import('node:https');
62
+ return new https.Agent({ rejectUnauthorized: false });
63
+ }
64
+
65
+ function firstDefined(...xs) {
66
+ for (const x of xs) if (x !== undefined && x !== null) return x;
67
+ return undefined;
68
+ }
69
+
70
+ function normalizeUaBanner(str = '') {
71
+ // Return { osKernel, nodeVersion, ua } if matches the opensearch-js UA shape
72
+ const out = { osKernel: null, nodeVersion: null, ua: null };
73
+ const s = String(str);
74
+ const m = s.match(/opensearch-js\/[\d.]+\s*\(([^)]+)\)/i);
75
+ if (!m) return out;
76
+ out.ua = s;
77
+ const inside = m[1]; // e.g., 'linux 6.19.14-linuxkit-x64; Node.js v20.10.0'
78
+ const node = inside.match(/node\.js\s*v([\d.]+)/i);
79
+ if (node) out.nodeVersion = node[1];
80
+ const linux = inside.match(/linux\s*([^;]+)/i);
81
+ if (linux) out.osKernel = linux[1].trim();
82
+ return out;
83
+ }
84
+
85
+ function trunc(s, n = 1200) {
86
+ const str = String(s || '');
87
+ return str.length > n ? str.slice(0, n) + '…' : str;
88
+ }
89
+
90
+ export default {
91
+ id: "012",
92
+ name: "OpenSearch Scanner",
93
+ description: "Detects OpenSearch and extracts version; heuristically parses Linux/Node.js versions from banners.",
94
+ priority: 360,
95
+ requirements: {},
96
+
97
+ async run(host, _port, opts = {}) {
98
+ const timeoutMs = Number(process.env.OPENSEARCH_SCANNER_TIMEOUT_MS || 6000);
99
+ const portsMap = parsePortsEnv('OPENSEARCH_SCANNER_PORTS', { ...DEFAULT_PORTS });
100
+ const includeTransport = /^(1|true|yes|on)$/i.test(String(process.env.OPENSEARCH_SCANNER_INCLUDE_TRANSPORT || ''));
101
+ if (includeTransport && !('9300' in portsMap && portsMap[9300])) {
102
+ portsMap[9300] = 'opensearch-transport';
103
+ }
104
+ const schemes = parseCsvEnv('OPENSEARCH_SCANNER_SCHEMES', ['http', 'https']);
105
+ const insecure = /^(1|true|yes|on)$/i.test(String(process.env.OPENSEARCH_SCANNER_INSECURE_TLS || ''));
106
+ const debug = /^(1|true|yes|on)$/i.test(String(process.env.OPENSEARCH_SCANNER_DEBUG || ''));
107
+ const doFetch = await getFetch();
108
+
109
+ const perPort = [];
110
+
111
+ for (const [pStr, svc] of Object.entries(portsMap)) {
112
+ const port = Number(pStr);
113
+ if (port === 9300) {
114
+ // Not HTTP; we just note transport port present if opted in
115
+ perPort.push({
116
+ port, service: svc, success: false, status: 'unknown', version: null,
117
+ osKernel: null, nodeVersion: null, headers: {}, body: null, error: 'transport port (not HTTP)'
118
+ });
119
+ continue;
120
+ }
121
+
122
+ let success = false, pickedScheme = null, version = null, osKernel = null, nodeVersion = null;
123
+ let headersRecord = {}, bodyStr = null, statusText = null, errMsg = null;
124
+
125
+ for (const scheme of schemes) {
126
+ const url = `${scheme}://${host}:${port}/`;
127
+ try {
128
+ const agent = await buildAgentIfNeeded(scheme, insecure);
129
+ const ctrl = new AbortController();
130
+ const to = setTimeout(() => ctrl.abort(), timeoutMs);
131
+ const res = await doFetch(url, {
132
+ method: 'GET',
133
+ headers: {
134
+ 'User-Agent': 'opensearch-js/3.4.0 (opensearch-scanner)'
135
+ },
136
+ signal: ctrl.signal,
137
+ agent
138
+ }).catch(e => { throw e; });
139
+ clearTimeout(to);
140
+
141
+ statusText = `${res.status}`;
142
+ // Record headers into a plain object
143
+ headersRecord = {};
144
+ try {
145
+ for (const [k, v] of res.headers) headersRecord[k.toLowerCase()] = String(v);
146
+ } catch {}
147
+
148
+ // Try JSON first
149
+ let parsed = null;
150
+ try {
151
+ const ct = headersRecord['content-type'] || '';
152
+ if (/json/i.test(ct)) {
153
+ parsed = await res.json();
154
+ bodyStr = trunc(JSON.stringify(parsed));
155
+ } else {
156
+ // fallback to text (may include hint)
157
+ const t = await res.text();
158
+ bodyStr = trunc(t);
159
+ try { parsed = JSON.parse(t); } catch {}
160
+ }
161
+ } catch {
162
+ // ignore parse errors
163
+ }
164
+
165
+ version = firstDefined(parsed?.version?.number, parsed?.version?.distribution === 'opensearch' ? parsed?.version?.number : null, null);
166
+
167
+ // Heuristic UA extraction: look for opensearch-js UA within *any* bannerish header
168
+ const bannerish = headersRecord['server'] || headersRecord['x-powered-by'] || headersRecord['user-agent'] || '';
169
+ const uaBits = normalizeUaBanner(bannerish);
170
+ osKernel = uaBits.osKernel || null;
171
+ nodeVersion = uaBits.nodeVersion || null;
172
+
173
+ // Some proxies echo request headers back; try to read from body if present
174
+ if (!osKernel && bodyStr && /opensearch-js\//i.test(bodyStr)) {
175
+ const uaBody = normalizeUaBanner(bodyStr);
176
+ osKernel = uaBody.osKernel || osKernel;
177
+ nodeVersion = uaBody.nodeVersion || nodeVersion;
178
+ }
179
+
180
+ success = !!(version || bodyStr || Object.keys(headersRecord).length);
181
+ pickedScheme = scheme;
182
+ break; // stop after first scheme that returns
183
+ } catch (e) {
184
+ errMsg = e && e.message ? String(e.message) : 'fetch error';
185
+ // Try next scheme
186
+ }
187
+ }
188
+
189
+ perPort.push({
190
+ port,
191
+ service: svc,
192
+ scheme: pickedScheme,
193
+ success,
194
+ status: success ? 'open' : 'unknown',
195
+ version: version || null,
196
+ osKernel,
197
+ nodeVersion,
198
+ headers: headersRecord,
199
+ body: bodyStr,
200
+ error: success ? null : (errMsg || statusText || null)
201
+ });
202
+ }
203
+
204
+ // Build data rows for evidence
205
+ const data = perPort.map(r => {
206
+ const bits = [];
207
+ if (r.version) bits.push(`OpenSearch: ${r.version}`);
208
+ if (r.osKernel) bits.push(`Linux: ${r.osKernel}`);
209
+ if (r.nodeVersion) bits.push(`Node.js: v${r.nodeVersion}`);
210
+ const probe_info = bits.join(' | ') || (r.error ? `No banner (${r.error})` : 'No banner');
211
+ const bannerObj = {
212
+ headers: r.headers,
213
+ body: r.body,
214
+ scheme: r.scheme,
215
+ debug: debug ? { error: r.error } : undefined
216
+ };
217
+ return {
218
+ probe_protocol: 'tcp',
219
+ probe_port: r.port,
220
+ probe_service: r.service,
221
+ probe_info,
222
+ response_banner: JSON.stringify(bannerObj)
223
+ };
224
+ });
225
+
226
+ const up = perPort.some(r => r.success);
227
+
228
+ // Prefer the clearest program/version at the root API port (9200) if present
229
+ const root = perPort.find(r => r.port === 9200 && r.version);
230
+ const program = root ? 'OpenSearch' : (up ? 'OpenSearch (suspected)' : 'Unknown');
231
+ const version = root ? root.version : null;
232
+
233
+ return {
234
+ up,
235
+ program,
236
+ version,
237
+ os: null, // not authoritative; let concluder infer from other sources
238
+ osVersion: null,
239
+ data
240
+ };
241
+ }
242
+ };
243
+
244
+ // ---------------- Plug-and-Play concluder adapter ----------------
245
+ import { statusFrom } from '../utils/conclusion_utils.mjs';
246
+
247
+ export async function conclude({ host, result }) {
248
+ const rows = Array.isArray(result?.data) ? result.data : [];
249
+ const items = [];
250
+ for (const r of rows) {
251
+ const port = Number(r?.probe_port);
252
+ if (!Number.isFinite(port)) continue;
253
+ // Prefer the probe_service we set in run()
254
+ const svc = r?.probe_service || (port === 9200 ? 'opensearch' : (port === 5601 ? 'opensearch-dashboards' : 'opensearch'));
255
+ const info = r?.probe_info || null;
256
+ const banner = r?.response_banner || null;
257
+ const status = /OpenSearch:\s*\d+/.test(String(info||'')) ? 'open' : 'unknown';
258
+ items.push({
259
+ port,
260
+ protocol: 'tcp',
261
+ service: svc,
262
+ program: 'OpenSearch',
263
+ version: null, // per-port version is often same; leave null unless per-port differs
264
+ status,
265
+ info,
266
+ banner,
267
+ source: 'opensearch-scanner',
268
+ evidence: rows,
269
+ authoritative: true
270
+ });
271
+ }
272
+ return items;
273
+ }
274
+
275
+ // Authoritative for standard HTTP API / dashboards ports
276
+ export const authoritativePorts = new Set(['tcp:9200','tcp:5601']);
@@ -0,0 +1,436 @@
1
+ // plugins/os_detector.mjs
2
+ // OS Detector — infers OS from DNS/mDNS/UPnP evidence and conservatively falls back to prior plugin OS labels.
3
+ //
4
+ // Supports two invocation styles used by tests:
5
+ // 1) run(priorResultsArray)
6
+ // 2) run(host, port, { results: priorResultsArray, context })
7
+ // Returns an object suitable to be wrapped by the concluder.
8
+
9
+ const ID = "013";
10
+ const NAME = "OS Detector";
11
+
12
+ function asArray(x) {
13
+ if (!x) return [];
14
+ if (Array.isArray(x)) return x;
15
+ return [x];
16
+ }
17
+
18
+ function getResultsFromArgs(a, b, c) {
19
+ // Style 1: run([plugins])
20
+ if (Array.isArray(a) && b === undefined && c === undefined) {
21
+ return { prior: a, host: null, ctx: {} };
22
+ }
23
+ // Style 2: run(host, port, { results, context })
24
+ const host = typeof a === "string" ? a : null;
25
+ const opts = c || {};
26
+ return { prior: asArray(opts.results), host, ctx: opts.context || {} };
27
+ }
28
+
29
+ function toStr(x) {
30
+ return typeof x === "string" ? x : "";
31
+ }
32
+
33
+ function parseKeyVals(s) {
34
+ // Parse 'k=v; k2=v2' (or comma/space separated) into map
35
+ const out = {};
36
+ if (!s) return out;
37
+ const parts = String(s)
38
+ .split(/[;,\s]\s*/)
39
+ .map((p) => p.trim())
40
+ .filter(Boolean);
41
+ for (const p of parts) {
42
+ const m = p.match(/^([^=]+)=(.+)$/);
43
+ if (m) out[m[1].trim().toLowerCase()] = m[2].trim();
44
+ }
45
+ return out;
46
+ }
47
+
48
+ function extractAddresses(banner) {
49
+ const s = String(banner || "");
50
+ if (!s) return [];
51
+
52
+ // JSON banner first (preferred by mdns_scanner and upnp_scanner)
53
+ try {
54
+ const obj = JSON.parse(s);
55
+ // Check for address field (used by UPnP Scanner)
56
+ if (obj?.address) return [obj.address];
57
+ // Check for addresses or addrs arrays (used by mDNS Scanner)
58
+ if (Array.isArray(obj?.addresses)) return obj.addresses.map(String);
59
+ if (Array.isArray(obj?.addrs)) return obj.addrs.map(String);
60
+ } catch {}
61
+
62
+ // Key/val legacy: '...; addresses=ip1,ip2'
63
+ const m = s.match(/addresses=([^\s;]+)/i);
64
+ if (m) {
65
+ return m[1]
66
+ .split(/[,\s]+/)
67
+ .map((x) => x.trim())
68
+ .filter(Boolean);
69
+ }
70
+
71
+ // Inline JSON-like fragment
72
+ const mj = s.match(/"addresses"\s*:\s*\[([^\]]*)\]/i);
73
+ if (mj) {
74
+ return mj[1]
75
+ .split(",")
76
+ .map(x => x.replace(/["'\s]/g, ""))
77
+ .filter(Boolean);
78
+ }
79
+
80
+ return [];
81
+ }
82
+
83
+ function evidenceRow(info, port = null, proto = "os-detector", banner = null) {
84
+ return {
85
+ probe_protocol: proto,
86
+ probe_port: port,
87
+ probe_info: info,
88
+ response_banner: banner
89
+ };
90
+ }
91
+
92
+ /* ---------------- Helpers for mDNS Apple inference ---------------- */
93
+
94
+ function looksLikeAppleHost(s) {
95
+ const host = String(s || "").toLowerCase();
96
+ // iphone-of-joe.local, ANAHITs-iPad.local, ANAHITs-iMac.local, MacBook-Air.local, apple-tv.local, etc.
97
+ return /(iphone|ipad|ipod|imac|macbook|apple.?tv)/i.test(host);
98
+ }
99
+
100
+ function unescapeFullname(s) {
101
+ return String(s || "").replace(/\\032/g, " ");
102
+ }
103
+
104
+ function isAppleMdnsRow(r) {
105
+ const info = toStr(r?.probe_info);
106
+ const banner = toStr(r?.response_banner);
107
+
108
+ // Service types associated with Apple
109
+ const appleSvc =
110
+ /airplay\._tcp|raop\._tcp|companion-link\._tcp|_apple|_touch-able|_sleep-proxy/i.test(info);
111
+
112
+ // Host/fullname cues
113
+ const infoHostMatch = info.match(/host=([^\s]+)/i);
114
+ const hostLooksApple = infoHostMatch && looksLikeAppleHost(infoHostMatch[1]);
115
+
116
+ // Fullname in banner JSON (from mdns_scanner fallback path)
117
+ let fullnameLooksApple = false;
118
+ try {
119
+ const obj = JSON.parse(banner);
120
+ if (obj?.fullname && looksLikeAppleHost(unescapeFullname(obj.fullname))) {
121
+ fullnameLooksApple = true;
122
+ }
123
+ } catch {}
124
+
125
+ // TXT cues: model, ty, features, srcvers, or a MAC-like deviceid
126
+ let txtModel = null, hasAirplayish = false, hasDeviceIdMac = false;
127
+ try {
128
+ const obj = JSON.parse(banner);
129
+ const txt = obj?.txt || {};
130
+ txtModel = txt.model || txt.mdl || txt.ty || null;
131
+ hasAirplayish = Boolean(txt.features || txt.srcvers);
132
+ hasDeviceIdMac = /([0-9A-Fa-f]{2}[:\-]){5}[0-9A-Fa-f]{2}/.test(String(txt.deviceid || txt.rpBA || ""));
133
+ } catch {
134
+ // also try to scrape from probe_info
135
+ const kv = parseKeyVals(info);
136
+ txtModel = txtModel || kv.model || kv.mdl || kv.ty || null;
137
+ hasAirplayish = hasAirplayish || Boolean(kv.features || kv.srcvers);
138
+ hasDeviceIdMac = hasDeviceIdMac || /deviceid\s*=\s*([0-9A-Fa-f]{2}[:\-]){5}[0-9A-Fa-f]{2}/i.test(info);
139
+ }
140
+
141
+ const hasMacModelToken = /model\s*=\s*Mac/i.test(info) || /"model"\s*:\s*"?Mac/i.test(banner) || /^Mac/i.test(String(txtModel || ""));
142
+
143
+ return appleSvc || hostLooksApple || fullnameLooksApple || hasMacModelToken || hasAirplayish || hasDeviceIdMac;
144
+ }
145
+
146
+ function refineAppleFamilyFromModel(model, info, banner) {
147
+ const m = String(model || "");
148
+ if (/^i(phone|pad|pod)/i.test(m)) return "iOS";
149
+ if (/^Mac/i.test(m)) return "macOS";
150
+ if (/apple.?tv/i.test(m)) return "tvOS";
151
+
152
+ // Check hostname for iOS-specific patterns
153
+ const infoHostMatch = info.match(/host=([^\s]+)/i);
154
+ const host = infoHostMatch ? infoHostMatch[1].toLowerCase() : "";
155
+ if (host.includes('iphone') || host.includes('ipad') || host.includes('ipod')) {
156
+ return "iOS";
157
+ }
158
+ if (host.includes('imac') || host.includes('macbook')) {
159
+ return "macOS";
160
+ }
161
+ if (host.includes('apple') && host.includes('tv')) {
162
+ return "tvOS";
163
+ }
164
+
165
+ // Check fullname in banner
166
+ try {
167
+ const obj = JSON.parse(banner);
168
+ const fullname = unescapeFullname(obj?.fullname || "").toLowerCase();
169
+ if (looksLikeAppleHost(fullname)) {
170
+ if (fullname.includes('iphone') || fullname.includes('ipad') || fullname.includes('ipod')) {
171
+ return "iOS";
172
+ }
173
+ if (fullname.includes('imac') || fullname.includes('macbook')) {
174
+ return "macOS";
175
+ }
176
+ if (fullname.includes('apple') && host.includes('tv')) {
177
+ return "tvOS";
178
+ }
179
+ }
180
+ } catch {}
181
+
182
+ // Check service type for companion-link (macOS or iOS)
183
+ if (info.includes('_companion-link._tcp')) {
184
+ // If no hostname/model indicates otherwise, default to iOS for companion-link
185
+ // as it's common on both but iOS is more likely without macOS-specific cues
186
+ return "iOS";
187
+ }
188
+
189
+ return "macOS or iOS";
190
+ }
191
+
192
+ function inferFromMdns(prior, targetHost) {
193
+ // Collect mDNS rows
194
+ const mdnsRows = [];
195
+ for (const p of prior) {
196
+ const rows = asArray(p?.result?.data);
197
+ for (const r of rows) {
198
+ if (String(r?.probe_protocol || "").toLowerCase() === "mdns") {
199
+ mdnsRows.push(r);
200
+ }
201
+ }
202
+ }
203
+ if (mdnsRows.length === 0) return null;
204
+
205
+ // Require that at least one row ties to this host by addresses[]
206
+ for (const r of mdnsRows) {
207
+ if (!isAppleMdnsRow(r)) continue;
208
+
209
+ const info = toStr(r?.probe_info);
210
+ const banner = toStr(r?.response_banner);
211
+ const addresses = extractAddresses(banner);
212
+ const targetMatches = targetHost ? addresses.includes(targetHost) : addresses.length > 0;
213
+
214
+ if (!targetMatches) continue;
215
+
216
+ // Try to refine from model type if known
217
+ let model = null;
218
+ try {
219
+ const obj = JSON.parse(banner);
220
+ model = obj?.txt?.model || obj?.txt?.mdl || obj?.txt?.ty || null;
221
+ } catch {
222
+ const kv = parseKeyVals(info);
223
+ model = kv.model || kv.mdl || kv.ty || null;
224
+ }
225
+
226
+ const osLabel = refineAppleFamilyFromModel(model, info, banner);
227
+ const ev = `mDNS evidence: Apple (${model ? `model=${model}` : "service/hostname/TXT"}) matched; addresses includes ${targetHost ?? addresses.join(",")}`;
228
+ return {
229
+ os: osLabel,
230
+ osVersion: null,
231
+ osExtras: { mdns: true },
232
+ rows: [evidenceRow(ev, 5353, "mdns", banner)]
233
+ };
234
+ }
235
+
236
+ return null;
237
+ }
238
+
239
+ /* ---------------- DNS/BIND → Red Hat ---------------- */
240
+
241
+ function inferFromBind(prior) {
242
+ // Look for DNS Scanner rows exposing version.bind with BIND banners that contain RedHat tokens
243
+ for (const p of prior) {
244
+ const rows = asArray(p?.result?.data);
245
+ for (const r of rows) {
246
+ const banner = toStr(r?.response_banner);
247
+ const info = toStr(r?.probe_info);
248
+
249
+ const isBind = /version\.bind/i.test(info) || /bind/i.test(banner);
250
+ if (!isBind) continue;
251
+
252
+ // Common Red Hat markers in packaged BIND strings
253
+ // e.g. '...RedHat-9.11.4-26.P2.el7_9.16.tuxcare.els8'
254
+ const isRedHatish =
255
+ /redhat|\.el\d|centos|rhel/i.test(banner);
256
+ if (!isRedHatish) continue;
257
+
258
+ // Extract rhel major/minor from el7_9 or el7 token
259
+ let osVersion = null;
260
+ const em = banner.match(/\.el(\d+)(?:_(\d+))?/i);
261
+ if (em) {
262
+ const major = em[1];
263
+ const minor = em[2] ? `.${em[2]}` : "";
264
+ osVersion = `${major}${minor}`;
265
+ }
266
+
267
+ const tuxcare = /\.tuxcare\./i.test(banner);
268
+
269
+ return {
270
+ os: "Red Hat Enterprise Linux",
271
+ osVersion,
272
+ osExtras: { tuxcare },
273
+ rows: [
274
+ evidenceRow("BIND evidence: Red Hat family packaging", r?.probe_port ?? null, String(r?.probe_protocol || "dns"), banner)
275
+ ]
276
+ };
277
+ }
278
+ }
279
+ return null;
280
+ }
281
+
282
+ /* ---------------- UPnP → OS from Server Header ---------------- */
283
+
284
+ function inferFromUpnp(prior, targetHost) {
285
+ const upnpRows = [];
286
+ for (const p of prior) {
287
+ const rows = asArray(p?.result?.data);
288
+ for (const r of rows) {
289
+ if (String(r?.probe_protocol || "").toLowerCase() === "upnp") {
290
+ upnpRows.push(r);
291
+ }
292
+ }
293
+ }
294
+ if (upnpRows.length === 0) return null;
295
+
296
+ let bestMatch = null;
297
+ for (const r of upnpRows) {
298
+ const info = toStr(r?.probe_info);
299
+ const banner = toStr(r?.response_banner);
300
+ const addresses = extractAddresses(banner);
301
+ const targetMatches = targetHost ? addresses.includes(targetHost) : addresses.length > 0;
302
+
303
+ if (!targetMatches) continue;
304
+
305
+ const os = r.os || null;
306
+ const osVersion = r.osVersion || null;
307
+
308
+ if (os) {
309
+ const ev = `UPnP evidence: OS detected from server header; addresses includes ${targetHost ?? addresses.join(",")}`;
310
+ const candidate = {
311
+ os,
312
+ osVersion,
313
+ osExtras: { upnp: true },
314
+ rows: [evidenceRow(ev, 1900, "upnp", banner)]
315
+ };
316
+
317
+ // Prioritize candidates with osVersion
318
+ if (osVersion) {
319
+ return candidate; // Immediately return if osVersion is present
320
+ } else if (!bestMatch) {
321
+ bestMatch = candidate;
322
+ }
323
+ }
324
+ }
325
+ return bestMatch;
326
+ }
327
+
328
+ /* ---------------- Fallbacks (Ping/FTP/others) ---------------- */
329
+
330
+ function fallbackFromPluginOs(prior) {
331
+ // Prefer OS labels that came from "Ping Checker" (TTL) or explicit plugin os
332
+ let picked = null;
333
+ for (const p of prior) {
334
+ const name = String(p?.name || "").toLowerCase();
335
+ const os = p?.result?.os || null;
336
+ if (!os) continue;
337
+
338
+ // Ping Checker or explicit recognizable plugin OS is a good fallback
339
+ if (name.includes("ping")) {
340
+ picked = { os, rows: [evidenceRow("Baseline OS from Ping Checker")] };
341
+ break;
342
+ }
343
+ // Otherwise remember the first OS we see (e.g., FTP Banner Check -> Linux)
344
+ if (!picked) picked = { os, rows: [evidenceRow(`Fallback OS from ${p?.name || "previous plugin"}`)] };
345
+ }
346
+ return picked;
347
+ }
348
+
349
+ /* ---------------- Main ---------------- */
350
+
351
+ export default {
352
+ id: ID,
353
+ name: NAME,
354
+ description: "Infers OS from DNS/mDNS/UPnP evidence and conservatively falls back to prior plugin OS labels.",
355
+ // Runs after mDNS (345) and UPnP (346) to have their evidence.
356
+ priority: 365,
357
+ requirements: {},
358
+ protocols: [],
359
+ ports: [],
360
+
361
+ async run(a, b, c) {
362
+ const { prior, host } = getResultsFromArgs(a, b, c);
363
+
364
+ // 1) UPnP → OS from server header
365
+ const upnp = inferFromUpnp(prior, host);
366
+ if (upnp) {
367
+ return {
368
+ up: true,
369
+ program: NAME,
370
+ version: "1",
371
+ os: upnp.os,
372
+ osVersion: upnp.osVersion || null,
373
+ osExtras: upnp.osExtras || {},
374
+ type: "os",
375
+ data: upnp.rows
376
+ };
377
+ }
378
+
379
+ // 2) DNS/BIND → Red Hat family
380
+ const bind = inferFromBind(prior);
381
+ if (bind) {
382
+ return {
383
+ up: true,
384
+ program: NAME,
385
+ version: "1",
386
+ os: bind.os,
387
+ osVersion: bind.osVersion || null,
388
+ osExtras: bind.osExtras || {},
389
+ type: "os",
390
+ data: bind.rows
391
+ };
392
+ }
393
+
394
+ // 3) mDNS → Apple (service/hostname/TXT evidence + address tie)
395
+ const mdns = inferFromMdns(prior, host);
396
+ if (mdns) {
397
+ return {
398
+ up: true,
399
+ program: NAME,
400
+ version: "1",
401
+ os: mdns.os,
402
+ osVersion: mdns.osVersion,
403
+ osExtras: mdns.osExtras,
404
+ type: "os",
405
+ data: mdns.rows
406
+ };
407
+ }
408
+
409
+ // 4) Fallback: use any plugin-provided OS labels (Ping/FTP/etc.)
410
+ const fb = fallbackFromPluginOs(prior);
411
+ if (fb) {
412
+ return {
413
+ up: true,
414
+ program: NAME,
415
+ version: "1",
416
+ os: fb.os || "Unknown",
417
+ osVersion: null,
418
+ osExtras: {},
419
+ type: "os",
420
+ data: fb.rows
421
+ };
422
+ }
423
+
424
+ // Nothing conclusive
425
+ return {
426
+ up: true,
427
+ program: NAME,
428
+ version: "1",
429
+ os: "Unknown",
430
+ osVersion: null,
431
+ osExtras: {},
432
+ type: "os",
433
+ data: [evidenceRow("No decisive OS evidence")]
434
+ };
435
+ }
436
+ };