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.
- package/CONTRIBUTING.md +24 -0
- package/LICENSE +21 -0
- package/README.md +584 -0
- package/bin/nsauditor-ai-mcp.mjs +2 -0
- package/bin/nsauditor-ai.mjs +2 -0
- package/cli.mjs +939 -0
- package/config/services.json +304 -0
- package/docs/EULA-nsauditor-ai.md +324 -0
- package/index.mjs +15 -0
- package/mcp_server.mjs +382 -0
- package/package.json +44 -0
- package/plugin_manager.mjs +829 -0
- package/plugins/arp_scanner.mjs +162 -0
- package/plugins/db_scanner.mjs +248 -0
- package/plugins/dns_scanner.mjs +369 -0
- package/plugins/dnssd-scanner.mjs +245 -0
- package/plugins/ftp_banner_check.mjs +247 -0
- package/plugins/host_up_check.mjs +337 -0
- package/plugins/http_probe.mjs +290 -0
- package/plugins/llmnr_scanner.mjs +130 -0
- package/plugins/mdns_scanner.mjs +522 -0
- package/plugins/netbios_scanner.mjs +737 -0
- package/plugins/opensearch_scanner.mjs +276 -0
- package/plugins/os_detector.mjs +436 -0
- package/plugins/ping_checker.mjs +271 -0
- package/plugins/port_scanner.mjs +250 -0
- package/plugins/result_concluder.mjs +274 -0
- package/plugins/snmp_scanner.mjs +278 -0
- package/plugins/ssh_scanner.mjs +421 -0
- package/plugins/sunrpc_scanner.mjs +339 -0
- package/plugins/syn_scanner.mjs +314 -0
- package/plugins/tls_scanner.mjs +225 -0
- package/plugins/upnp_scanner.mjs +441 -0
- package/plugins/webapp_detector.mjs +246 -0
- package/plugins/wsd_scanner.mjs +290 -0
- package/utils/attack_map.mjs +180 -0
- package/utils/capabilities.mjs +53 -0
- package/utils/conclusion_utils.mjs +70 -0
- package/utils/cpe.mjs +74 -0
- package/utils/cve_validator.mjs +64 -0
- package/utils/cvss.mjs +129 -0
- package/utils/delta_reporter.mjs +110 -0
- package/utils/export_csv.mjs +82 -0
- package/utils/finding_queue.mjs +64 -0
- package/utils/finding_schema.mjs +36 -0
- package/utils/host_iterator.mjs +166 -0
- package/utils/license.mjs +29 -0
- package/utils/net_validation.mjs +66 -0
- package/utils/nvd_cache.mjs +77 -0
- package/utils/nvd_client.mjs +130 -0
- package/utils/oui.mjs +107 -0
- package/utils/plugin_discovery.mjs +89 -0
- package/utils/prompts.mjs +143 -0
- package/utils/raw_report_html.mjs +170 -0
- package/utils/redact.mjs +79 -0
- package/utils/report_html.mjs +236 -0
- package/utils/sarif.mjs +225 -0
- package/utils/scan_history.mjs +248 -0
- package/utils/scheduler.mjs +157 -0
- 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
|
+
};
|