nsauditor-ai 0.2.55 → 0.2.57
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/README.md +76 -62
- package/cli.mjs +210 -179
- package/mcp_server.mjs +71 -31
- package/package.json +1 -1
- package/plugin_manager.mjs +49 -8
- package/plugins/040_tls_cert_auditor.mjs +176 -56
- package/plugins/050_tribe_health.mjs +13 -34
- package/plugins/060_dns_sec_auditor.mjs +24 -30
- package/plugins/dns_scanner.mjs +16 -8
- package/plugins/ftp_banner_check.mjs +21 -13
- package/plugins/host_up_check.mjs +31 -72
- package/plugins/http_probe.mjs +93 -25
- package/plugins/mcp_scanner.mjs +15 -1
- package/plugins/netbios_scanner.mjs +32 -13
- package/plugins/opensearch_scanner.mjs +4 -0
- package/plugins/port_scanner.mjs +29 -49
- package/plugins/result_concluder.mjs +122 -30
- package/plugins/snmp_scanner.mjs +20 -8
- package/plugins/syn_scanner.mjs +2 -1
- package/plugins/upnp_scanner.mjs +82 -13
- package/plugins/webapp_detector.mjs +82 -15
- package/utils/conclusion_utils.mjs +13 -1
- package/utils/cookie_redaction.mjs +50 -0
- package/utils/delta_reporter.mjs +70 -25
- package/utils/ee_load.mjs +45 -0
- package/utils/executive_report.mjs +48 -8
- package/utils/export_csv.mjs +14 -22
- package/utils/mcp_call_digest.mjs +31 -0
- package/utils/net_validation.mjs +107 -37
- package/utils/output_dir.mjs +70 -0
- package/utils/plugin_discovery.mjs +4 -2
- package/utils/ports_spec.mjs +46 -0
- package/utils/report_inputs.mjs +64 -1
- package/utils/report_md.mjs +51 -112
- package/utils/sarif.mjs +33 -93
- package/utils/scan_delta.mjs +514 -38
- package/utils/scan_delta_view.mjs +5 -3
- package/utils/scan_history.mjs +149 -0
- package/utils/scheduler.mjs +17 -5
- package/utils/service_flags.mjs +363 -0
- package/utils/watch_cycle.mjs +37 -0
- package/utils/webhook.mjs +2 -1
package/plugins/port_scanner.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import fsp from "node:fs/promises";
|
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
10
|
import { mapLimit } from "../utils/concurrency.mjs";
|
|
11
|
+
import { parsePortsSpec, uniqInts } from "../utils/ports_spec.mjs";
|
|
11
12
|
|
|
12
13
|
/* ------------------------------ helpers ------------------------------ */
|
|
13
14
|
|
|
@@ -16,48 +17,9 @@ const toInt = (v, d) => {
|
|
|
16
17
|
return Number.isFinite(n) && n >= 0 ? n : d;
|
|
17
18
|
};
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Parse a CLI-style ports spec string into TCP/UDP port arrays.
|
|
25
|
-
*
|
|
26
|
-
* Accepted formats (entries comma-separated, whitespace tolerated):
|
|
27
|
-
* "8090" → { tcp: [8090], udp: [] }
|
|
28
|
-
* "8090,9090" → { tcp: [8090, 9090], udp: [] }
|
|
29
|
-
* "8090/tcp" → { tcp: [8090], udp: [] }
|
|
30
|
-
* "8090/udp" → { tcp: [], udp: [8090] }
|
|
31
|
-
* "8090,9090/udp" → { tcp: [8090], udp: [9090] }
|
|
32
|
-
* "8090/tcp,9090/udp" → { tcp: [8090], udp: [9090] }
|
|
33
|
-
*
|
|
34
|
-
* Default protocol when not specified: TCP.
|
|
35
|
-
*
|
|
36
|
-
* Malformed entries (non-numeric, out-of-range 1–65535, empty, unknown
|
|
37
|
-
* protocol suffix) are silently skipped — defensive for sloppy CLI input.
|
|
38
|
-
*
|
|
39
|
-
* @param {string} spec
|
|
40
|
-
* @returns {{ tcp: number[], udp: number[] }}
|
|
41
|
-
*/
|
|
42
|
-
export function parsePortsSpec(spec) {
|
|
43
|
-
const out = { tcp: [], udp: [] };
|
|
44
|
-
if (typeof spec !== 'string') return out;
|
|
45
|
-
const entries = spec.split(',').map(s => s.trim()).filter(Boolean);
|
|
46
|
-
for (const entry of entries) {
|
|
47
|
-
// Reject entries with more than one '/' separator (e.g. "8090/tcp/extra")
|
|
48
|
-
const parts = entry.split('/');
|
|
49
|
-
if (parts.length > 2) continue;
|
|
50
|
-
const portStr = parts[0];
|
|
51
|
-
const proto = (parts[1] || 'tcp').toLowerCase();
|
|
52
|
-
if (proto !== 'tcp' && proto !== 'udp') continue;
|
|
53
|
-
const port = Number(portStr);
|
|
54
|
-
if (!Number.isInteger(port) || port < 1 || port > 65535) continue;
|
|
55
|
-
out[proto].push(port);
|
|
56
|
-
}
|
|
57
|
-
out.tcp = uniqInts(out.tcp);
|
|
58
|
-
out.udp = uniqInts(out.udp);
|
|
59
|
-
return out;
|
|
60
|
-
}
|
|
20
|
+
// parsePortsSpec lives in utils/ports_spec.mjs (1.3.0 item 12: the webapp detector parses --ports the same way) and is
|
|
21
|
+
// re-exported here, where the CLI flag's tests and the build-5 text leg import it.
|
|
22
|
+
export { parsePortsSpec };
|
|
61
23
|
|
|
62
24
|
/**
|
|
63
25
|
* ⚠️ THE PACKAGE'S OWN DATA IS FOUND RELATIVE TO THE PACKAGE, NOT TO THE CALLER.
|
|
@@ -122,9 +84,14 @@ async function readPortSet(fp) {
|
|
|
122
84
|
* purpose must not silently get the full set back.
|
|
123
85
|
*/
|
|
124
86
|
export async function loadConfigPortsFromServicesJson(cwd = process.cwd(), pkgRoot = PKG_ROOT) {
|
|
125
|
-
const
|
|
87
|
+
const overridePath = path.join(cwd, "config", "services.json");
|
|
88
|
+
const override = await readPortSet(overridePath);
|
|
126
89
|
if (override.tcp.length || override.udp.length) return { ...override, source: "override" };
|
|
127
|
-
|
|
90
|
+
const floor = { ...(await readPortSet(path.join(pkgRoot, "config", "services.json"))), source: "package" };
|
|
91
|
+
// B6-4j (1.3.0): a cwd file that EXISTS and yields nothing is ignored OUT LOUD (run() warns), named by its RELATIVE path —
|
|
92
|
+
// the working directory's absolute path is the operator's layout, which does not travel in a log.
|
|
93
|
+
const present = await fsp.access(overridePath).then(() => true, () => false);
|
|
94
|
+
return present ? { ...floor, overrideIgnored: path.join("config", "services.json") } : floor;
|
|
128
95
|
}
|
|
129
96
|
|
|
130
97
|
function classifyTcpError(err) {
|
|
@@ -249,7 +216,12 @@ export default {
|
|
|
249
216
|
priority: 30,
|
|
250
217
|
protocols: ["tcp", "udp"],
|
|
251
218
|
ports: [],
|
|
252
|
-
|
|
219
|
+
// 1.3.0 lane 1 F, ruling (ii): NOT gated on `host: "up"`. The port scan is the measurement that
|
|
220
|
+
// decides liveness, so it does not wait for a liveness guess: with no discovery evidence it still
|
|
221
|
+
// runs, and its own `up` (a port answered) opens every `host: "up"` gate downstream. Gated, a host
|
|
222
|
+
// that drops ping and the common ports lost its port scan and read "Host appears DOWN" over an
|
|
223
|
+
// open uncommon port (driven through the CLI: `003 skipped "host not up"`).
|
|
224
|
+
requirements: {},
|
|
253
225
|
|
|
254
226
|
// run(host, _portIgnored, opts)
|
|
255
227
|
async run(host, _port = 0, opts = {}) {
|
|
@@ -287,6 +259,10 @@ export default {
|
|
|
287
259
|
+ `the surface cannot be measured, not that nothing is listening. Check that the file is `
|
|
288
260
|
+ `readable (it ships mode 0644), or pass the ports explicitly.`);
|
|
289
261
|
}
|
|
262
|
+
if (cfg.overrideIgnored) {
|
|
263
|
+
console.warn(`[port_scanner] WARNING: ${cfg.overrideIgnored} in the working directory yields no ports — it lists none in `
|
|
264
|
+
+ `the forms read here (a "services" array, or "tcp" / "udp" arrays) — so the package's default port set is scanned instead.`);
|
|
265
|
+
}
|
|
290
266
|
}
|
|
291
267
|
|
|
292
268
|
// Additive merge of CLI --ports flag (string spec)
|
|
@@ -334,12 +310,16 @@ export default {
|
|
|
334
310
|
const udpClosed = data.filter(d => d.probe_protocol === "udp" && d.status === "closed").map(d => d.probe_port); // typically empty
|
|
335
311
|
const udpNoResponse = data.filter(d => d.probe_protocol === "udp" && d.status === "no-response").map(d => d.probe_port);
|
|
336
312
|
|
|
337
|
-
//
|
|
338
|
-
|
|
339
|
-
|
|
313
|
+
// `up` means a port answered — open or refused; a timeout, an unreachable or a socket error is
|
|
314
|
+
// not an answer. (1.3.0 lane 1 F: 'filtered' — every timeout, EHOSTUNREACH, ENETUNREACH and other
|
|
315
|
+
// socket error, classifyTcpError — used to count too, so a host that answered nothing read UP from
|
|
316
|
+
// its own timeouts; driven against a silent 127.0.0.2: `up: true, tcpFiltered: [22, 8443]`.
|
|
317
|
+
// Ungated, that would read every silent host UP and open every downstream gate.)
|
|
318
|
+
const anyTcpAnswer = tcpOpen.length > 0 || tcpClosed.length > 0;
|
|
319
|
+
const anyUdpOpen = udpOpen.length > 0;
|
|
340
320
|
|
|
341
321
|
return {
|
|
342
|
-
up:
|
|
322
|
+
up: anyTcpAnswer || anyUdpOpen,
|
|
343
323
|
program: "Unknown",
|
|
344
324
|
version: "Unknown",
|
|
345
325
|
os: null,
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
// plugins/result_concluder.mjs — plug-and-play dispatcher with full metadata and evidence
|
|
2
|
+
import { readdir } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
2
5
|
import { normalizeService, upsertService, keyOf } from '../utils/conclusion_utils.mjs';
|
|
3
6
|
|
|
4
7
|
function pickResultsFromArgs(args) {
|
|
@@ -8,14 +11,79 @@ function pickResultsFromArgs(args) {
|
|
|
8
11
|
return [];
|
|
9
12
|
}
|
|
10
13
|
|
|
11
|
-
//
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
if (
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
// The second argument (or opts.adapters in the (host, port, { results }) form) may carry the PluginManager's adapter
|
|
15
|
+
// registry: a Map (or plain object) from plugin id to { conclude, authoritativePorts, cloudProvider }.
|
|
16
|
+
function pickOptsFromArgs(args) {
|
|
17
|
+
if (Array.isArray(args[0]) && args[1] && typeof args[1] === 'object') return args[1];
|
|
18
|
+
if (args.length >= 3 && args[2] && typeof args[2] === 'object') return args[2];
|
|
19
|
+
if (args.length === 1 && args[0] && typeof args[0] === 'object' && !Array.isArray(args[0])) return args[0];
|
|
20
|
+
return {};
|
|
17
21
|
}
|
|
18
22
|
|
|
23
|
+
// A plugin name as a label — the default `source` of an adapter record that names none. It no longer RESOLVES
|
|
24
|
+
// anything: until 1.3.0 the concluder imported `./<this slug>.mjs` and read only a named `conclude`, so 014 and 024
|
|
25
|
+
// (slug ≠ file name) and 040 / 050 / 060 (conclude on the default object) were never reached.
|
|
26
|
+
function nameSlug(name) {
|
|
27
|
+
return String(name || 'plugin').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'plugin';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** One adapter-registry entry from a plugin module: `conclude` named or on the default object, by plugin ID. */
|
|
31
|
+
function adapterEntry(mod) {
|
|
32
|
+
const p = mod?.default;
|
|
33
|
+
if (!p || typeof p !== 'object' || p.id == null) return null;
|
|
34
|
+
const conclude = typeof mod.conclude === 'function' ? mod.conclude
|
|
35
|
+
: (typeof p.conclude === 'function' ? p.conclude.bind(p) : null);
|
|
36
|
+
return {
|
|
37
|
+
id: String(p.id),
|
|
38
|
+
name: typeof p.name === 'string' ? p.name : null,
|
|
39
|
+
conclude,
|
|
40
|
+
authoritativePorts: mod.authoritativePorts ?? p.authoritativePorts ?? null,
|
|
41
|
+
cloudProvider: p.cloudProvider ?? null,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The concluder's own directory as a registry, built once — used for any id the caller's registry does not carry
|
|
46
|
+
// (a direct concluder.run, or a test manager with injected plugins).
|
|
47
|
+
const SELF = fileURLToPath(import.meta.url);
|
|
48
|
+
let ownRegistry = null;
|
|
49
|
+
function ownDirectoryAdapters() {
|
|
50
|
+
ownRegistry ??= (async () => {
|
|
51
|
+
const map = new Map();
|
|
52
|
+
let names = [];
|
|
53
|
+
try { names = (await readdir(path.dirname(SELF))).filter((f) => f.endsWith('.mjs')).sort(); } catch { return map; }
|
|
54
|
+
for (const f of names) {
|
|
55
|
+
if (f === path.basename(SELF)) continue;
|
|
56
|
+
try {
|
|
57
|
+
const e = adapterEntry(await import(pathToFileURL(path.join(path.dirname(SELF), f)).href));
|
|
58
|
+
if (e && !map.has(e.id)) map.set(e.id, e);
|
|
59
|
+
} catch { /* a plugin that fails to load has no adapter here */ }
|
|
60
|
+
}
|
|
61
|
+
return map;
|
|
62
|
+
})();
|
|
63
|
+
return ownRegistry;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function toRegistry(adapters) {
|
|
67
|
+
if (adapters instanceof Map) return adapters;
|
|
68
|
+
if (adapters && typeof adapters === 'object') return new Map(Object.entries(adapters));
|
|
69
|
+
return new Map();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// A result with NO id (some callers pass names only) resolves by the plugin's DECLARED name — never by a file name
|
|
73
|
+
// derived from it, which is what left 014 / 024 unreached.
|
|
74
|
+
function byDeclaredName(registry) {
|
|
75
|
+
const out = new Map();
|
|
76
|
+
for (const e of registry.values()) {
|
|
77
|
+
const n = typeof e?.name === 'string' ? e.name.trim().toLowerCase() : '';
|
|
78
|
+
if (n && !out.has(n)) out.set(n, e);
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Adapter payloads that travel under a namespace. A record without a positive port is evidence, not a service row,
|
|
84
|
+
// and these ride along onto its evidence entry so moving it there loses nothing.
|
|
85
|
+
export const ADAPTER_PAYLOAD_KEYS = ['certAudit', 'tribeHealth', 'dnsSecurity'];
|
|
86
|
+
|
|
19
87
|
function scoreOsLabel(label) {
|
|
20
88
|
const s = String(label||'').toLowerCase();
|
|
21
89
|
if (!s || s === 'unknown') return 0;
|
|
@@ -133,6 +201,9 @@ export default {
|
|
|
133
201
|
|
|
134
202
|
async run(...args) {
|
|
135
203
|
const results = pickResultsFromArgs(args);
|
|
204
|
+
const supplied = toRegistry(pickOptsFromArgs(args).adapters);
|
|
205
|
+
const own = await ownDirectoryAdapters();
|
|
206
|
+
const pendingAttach = [];
|
|
136
207
|
const services = [];
|
|
137
208
|
const evidence = [];
|
|
138
209
|
let os = null;
|
|
@@ -162,8 +233,8 @@ export default {
|
|
|
162
233
|
|
|
163
234
|
for (const r of results) {
|
|
164
235
|
const name = r?.name || r?.id || 'plugin';
|
|
165
|
-
const
|
|
166
|
-
const
|
|
236
|
+
const id = String(r?.id ?? '');
|
|
237
|
+
const slug = nameSlug(name);
|
|
167
238
|
|
|
168
239
|
// Prefer OS and osVersion provided by plugins, but pick the most specific; OS Detector wins ties
|
|
169
240
|
if (r?.result?.os) {
|
|
@@ -173,33 +244,43 @@ export default {
|
|
|
173
244
|
osSource = picked.source;
|
|
174
245
|
}
|
|
175
246
|
|
|
176
|
-
//
|
|
177
|
-
|
|
247
|
+
// Host names by plugin ID (they keyed on a name slug, and the UPnP one never matched: the plugin is named
|
|
248
|
+
// "Enhanced UPnP Scanner").
|
|
249
|
+
if (id === '028') {
|
|
178
250
|
hostName = extractHostNameFromUpnp(r?.result) || hostName;
|
|
179
251
|
}
|
|
180
|
-
|
|
181
|
-
// Extract host name from mDNS Scanner if available
|
|
182
|
-
if (slug === 'mdns_scanner') {
|
|
252
|
+
if (id === '027') {
|
|
183
253
|
hostName = extractHostNameFromMdns(r?.result) || hostName;
|
|
184
254
|
}
|
|
185
255
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
256
|
+
// Resolve the adapter by ID: the caller's registry first, then this directory. A cloudProvider plugin is
|
|
257
|
+
// EXEMPT — its findings travel raw (cloud_finding_summary / harvestCloudFindings), and through a service-record
|
|
258
|
+
// adapter they carry no port and collapse into one fabricated row.
|
|
259
|
+
const entry = id
|
|
260
|
+
? (supplied.get(id) ?? own.get(id))
|
|
261
|
+
: (byDeclaredName(supplied).get(String(name).trim().toLowerCase())
|
|
262
|
+
?? byDeclaredName(own).get(String(name).trim().toLowerCase()));
|
|
263
|
+
if (entry && typeof entry.conclude === 'function' && !entry.cloudProvider) {
|
|
264
|
+
let recs = null;
|
|
265
|
+
try {
|
|
266
|
+
recs = await entry.conclude({ host: typeof args[0] === 'string' ? args[0] : undefined, result: r?.result });
|
|
267
|
+
} catch {
|
|
268
|
+
recs = null; // an adapter that throws falls through to the fallback record, as before
|
|
269
|
+
}
|
|
270
|
+
if (recs) {
|
|
271
|
+
const authSet = entry.authoritativePorts instanceof Set ? entry.authoritativePorts : null;
|
|
272
|
+
for (const item of recs) {
|
|
273
|
+
const { attachOnly, ...rest } = item || {};
|
|
274
|
+
const rec = normalizeService({ ...rest, source: rest.source || slug });
|
|
275
|
+
const authoritative = (authSet && authSet.has(keyOf(rec))) || !!rest.authoritative;
|
|
276
|
+
// An attach-only record (060's domain DNS-posture audit) lands only on a service a port-level probe
|
|
277
|
+
// found; decided after every result is in, so result order does not matter.
|
|
278
|
+
if (attachOnly) pendingAttach.push(rec);
|
|
279
|
+
else upsertService(services, rec, { authoritative });
|
|
197
280
|
}
|
|
198
281
|
if (r?.result?.data) pushEvidence(name, r.result.data);
|
|
199
282
|
continue;
|
|
200
283
|
}
|
|
201
|
-
} catch {
|
|
202
|
-
// no adapter -> fall through
|
|
203
284
|
}
|
|
204
285
|
for (const item of fallbackRecord(name, r?.result)) {
|
|
205
286
|
upsertService(services, normalizeService(item), { authoritative: false });
|
|
@@ -207,6 +288,12 @@ export default {
|
|
|
207
288
|
if (r?.result?.data) pushEvidence(name, r.result.data);
|
|
208
289
|
}
|
|
209
290
|
|
|
291
|
+
const unattached = [];
|
|
292
|
+
for (const rec of pendingAttach) {
|
|
293
|
+
if (services.some((s) => keyOf(s) === keyOf(rec))) upsertService(services, rec, { authoritative: false });
|
|
294
|
+
else unattached.push(rec);
|
|
295
|
+
}
|
|
296
|
+
|
|
210
297
|
for (const svc of services) delete svc.__authoritative;
|
|
211
298
|
|
|
212
299
|
services.sort((a,b)=> (a.port - b.port) || String(a.protocol).localeCompare(String(b.protocol)) );
|
|
@@ -215,24 +302,28 @@ export default {
|
|
|
215
302
|
const META_PROTOCOLS = new Set(['assessment', 'icmp', 'os-detector', 'arp']);
|
|
216
303
|
const PORT_ZERO_META_PROTOCOLS = new Set(['api', 'tcp', 'udp']);
|
|
217
304
|
const isMetaEntry = (s) => {
|
|
305
|
+
// A service has a port. A record without a positive integer one (no port at all keys to `<proto>:NaN`) is
|
|
306
|
+
// evidence — 040's "no TLS found", 060's per-domain findings — never a service row with port null.
|
|
307
|
+
if (!(Number.isInteger(s.port) && s.port > 0)) return true;
|
|
218
308
|
if (META_PROTOCOLS.has(s.protocol)) return true;
|
|
219
309
|
if (s.port === 0 && PORT_ZERO_META_PROTOCOLS.has(s.protocol)) return true;
|
|
220
310
|
if (s.info && /Skipped:/i.test(String(s.info))) return true;
|
|
221
311
|
return false;
|
|
222
312
|
};
|
|
223
313
|
|
|
224
|
-
const metaEntries = services.filter(isMetaEntry);
|
|
314
|
+
const metaEntries = services.filter(isMetaEntry).concat(unattached);
|
|
225
315
|
const realServices = services.filter(s => !isMetaEntry(s));
|
|
226
316
|
|
|
227
|
-
// Move meta entries into evidence only
|
|
317
|
+
// Move meta entries into evidence only — with any namespaced adapter payload, so nothing is lost on the way
|
|
228
318
|
for (const m of metaEntries) {
|
|
229
319
|
evidence.push({
|
|
230
320
|
from: m.source || 'meta',
|
|
231
321
|
protocol: m.protocol,
|
|
232
|
-
port: m.port,
|
|
322
|
+
port: Number.isFinite(m.port) ? m.port : null,
|
|
233
323
|
status: m.status,
|
|
234
324
|
info: m.info,
|
|
235
325
|
...(m.banner ? { banner: m.banner } : {}),
|
|
326
|
+
...Object.fromEntries(ADAPTER_PAYLOAD_KEYS.filter((k) => m[k] != null).map((k) => [k, m[k]])),
|
|
236
327
|
});
|
|
237
328
|
}
|
|
238
329
|
|
|
@@ -251,7 +342,8 @@ export default {
|
|
|
251
342
|
const hostUp = results.some(r => r?.result?.up === true) || services.some(s => s.status === 'open');
|
|
252
343
|
const open = services.filter(s => s.status === 'open');
|
|
253
344
|
const parts = [];
|
|
254
|
-
|
|
345
|
+
// Not UP means no probe got an answer — no evidence either way, never "DOWN" (1.3.0 lane 1 F).
|
|
346
|
+
parts.push(hostUp ? `Host${hostName ? ` (${hostName})` : ''} is UP` : 'No evidence the host is up');
|
|
255
347
|
if (os) parts.push(`OS: ${os}`);
|
|
256
348
|
if (osVersion) parts.push(`Version: ${osVersion}`);
|
|
257
349
|
if (open.length) {
|
package/plugins/snmp_scanner.mjs
CHANGED
|
@@ -4,6 +4,10 @@
|
|
|
4
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
5
|
|
|
6
6
|
export const DEFAULT_COMMUNITIES = ['public', 'private'];
|
|
7
|
+
// ⚠️ A COMMUNITY STRING THE OPERATOR SUPPLIED (SNMP_COMMUNITY) IS A CREDENTIAL — it authenticates to their devices — so
|
|
8
|
+
// it is never written into a result, a record or an evidence row. Only a DEFAULT community is recorded by name, because
|
|
9
|
+
// a default community answering IS the finding; any other string is recorded as the word 'custom'.
|
|
10
|
+
export const communityLabel = (comm) => (DEFAULT_COMMUNITIES.includes(comm) ? comm : 'custom');
|
|
7
11
|
export const snmpCommunities = process.env.SNMP_COMMUNITY
|
|
8
12
|
? String(process.env.SNMP_COMMUNITY).split(',').map(s => s.trim()).filter(Boolean)
|
|
9
13
|
: DEFAULT_COMMUNITIES;
|
|
@@ -124,6 +128,7 @@ export default {
|
|
|
124
128
|
let deviceWebPageInstruction = '';
|
|
125
129
|
let deviceFullName = null;
|
|
126
130
|
let community = null;
|
|
131
|
+
let communityCustom = false; // a custom (operator-supplied) string ANSWERED — not a finding, and not "not tested"
|
|
127
132
|
const communitiesTried = [];
|
|
128
133
|
const data = [];
|
|
129
134
|
|
|
@@ -137,7 +142,7 @@ export default {
|
|
|
137
142
|
const useOid = options.oid ? options.oid.split('.').map(Number) : oidTable.default;
|
|
138
143
|
|
|
139
144
|
for (const comm of snmpCommunities) {
|
|
140
|
-
communitiesTried.push(comm);
|
|
145
|
+
communitiesTried.push(communityLabel(comm));
|
|
141
146
|
const s = new Session({ host, community: comm, timeouts: [5000] });
|
|
142
147
|
|
|
143
148
|
try {
|
|
@@ -157,7 +162,7 @@ export default {
|
|
|
157
162
|
const val = String(vbs?.[0]?.value || '');
|
|
158
163
|
if (val) {
|
|
159
164
|
up = true;
|
|
160
|
-
community = comm;
|
|
165
|
+
if (DEFAULT_COMMUNITIES.includes(comm)) community = comm; else communityCustom = true;
|
|
161
166
|
const parsed = fromSysDescr(val);
|
|
162
167
|
program = parsed.family || 'Unknown';
|
|
163
168
|
version = parsed.version || 'Unknown';
|
|
@@ -168,7 +173,7 @@ export default {
|
|
|
168
173
|
else if (/microsoft|windows|linux|unix/i.test(program)) type = 'server';
|
|
169
174
|
|
|
170
175
|
let probeInfo = `SNMP response received: ${program} ${version}${os ? ` (OS: ${os})` : ''} (Type: ${type})`;
|
|
171
|
-
if (comm
|
|
176
|
+
if (DEFAULT_COMMUNITIES.includes(comm)) {
|
|
172
177
|
probeInfo += ` WARNING: Default SNMP community string '${comm}' accepted — misconfiguration`;
|
|
173
178
|
}
|
|
174
179
|
|
|
@@ -220,7 +225,7 @@ export default {
|
|
|
220
225
|
if (hardwareVersion) infoPieces.push(`HW Ver: ${hardwareVersion}`);
|
|
221
226
|
if (firmwareVersion) infoPieces.push(`FW Ver: ${firmwareVersion}`);
|
|
222
227
|
infoPieces.push(')');
|
|
223
|
-
if (comm
|
|
228
|
+
if (DEFAULT_COMMUNITIES.includes(comm)) {
|
|
224
229
|
infoPieces.push(` WARNING: Default SNMP community string '${comm}' accepted — misconfiguration`);
|
|
225
230
|
}
|
|
226
231
|
data[0].probe_info = infoPieces.join(', ');
|
|
@@ -240,7 +245,7 @@ export default {
|
|
|
240
245
|
data.push({
|
|
241
246
|
probe_protocol: 'udp',
|
|
242
247
|
probe_port: 161,
|
|
243
|
-
probe_info: `No SNMP response for community "${comm}"`,
|
|
248
|
+
probe_info: `No SNMP response for community "${communityLabel(comm)}"`,
|
|
244
249
|
response_banner: null
|
|
245
250
|
});
|
|
246
251
|
}
|
|
@@ -257,7 +262,7 @@ export default {
|
|
|
257
262
|
});
|
|
258
263
|
}
|
|
259
264
|
|
|
260
|
-
return { up, program, version, os, type, serialNumber, hardwareVersion, firmwareVersion, ip6, deviceWebPage, deviceWebPageInstruction, community, communitiesTried, data };
|
|
265
|
+
return { up, program, version, os, type, serialNumber, hardwareVersion, firmwareVersion, ip6, deviceWebPage, deviceWebPageInstruction, community, communityCustom, communitiesTried, data };
|
|
261
266
|
}
|
|
262
267
|
};
|
|
263
268
|
|
|
@@ -265,14 +270,21 @@ export async function conclude({ host, result }) {
|
|
|
265
270
|
const rows = Array.isArray(result?.data) ? result.data : [];
|
|
266
271
|
const row = rows[0] || null;
|
|
267
272
|
const isDefault = DEFAULT_COMMUNITIES.includes(result?.community);
|
|
268
|
-
|
|
273
|
+
// A result written by an earlier release carries a custom string in `community`; the field is masked here as well.
|
|
274
|
+
// (Its evidence rows are carried as they were written — this adapter does not rewrite another release's evidence.)
|
|
275
|
+
const custom = result?.communityCustom === true || (result?.community != null && !isDefault);
|
|
276
|
+
const communityInfo = isDefault ? ` [community=${result.community}]` : custom ? ' [community=custom]' : '';
|
|
269
277
|
return [{
|
|
270
278
|
port: 161, protocol: 'udp', service: 'snmp',
|
|
271
279
|
program: result?.program || 'Unknown', version: result?.version || 'Unknown',
|
|
272
280
|
status: result?.up ? 'open' : 'no response',
|
|
273
281
|
info: row?.probe_info ? `${row.probe_info}${communityInfo}` : communityInfo || null,
|
|
274
282
|
banner: row?.response_banner ? `${row.response_banner}${communityInfo}` : null,
|
|
275
|
-
community: result
|
|
283
|
+
community: isDefault ? result.community : null,
|
|
284
|
+
...(custom ? { communityCustom: true } : {}),
|
|
285
|
+
// 1.3.0 (s1) B: what was TRIED, as labels — a default community is MEASURED only where it was tried. Re-labelled here,
|
|
286
|
+
// so a raw result an earlier release wrote (which carried the strings themselves) never puts one on the record.
|
|
287
|
+
communitiesTried: Array.isArray(result?.communitiesTried) ? result.communitiesTried.map(communityLabel) : null,
|
|
276
288
|
source: 'snmp', evidence: rows, authoritative: true
|
|
277
289
|
}];
|
|
278
290
|
}
|
package/plugins/syn_scanner.mjs
CHANGED
|
@@ -299,7 +299,8 @@ export async function conclude({ host, result }) {
|
|
|
299
299
|
port,
|
|
300
300
|
protocol: proto,
|
|
301
301
|
service: r?.service || "unknown",
|
|
302
|
-
|
|
302
|
+
// nmap is the tool that saw the port, not the program listening on it (1.3.0).
|
|
303
|
+
program: "Unknown",
|
|
303
304
|
version: r?.response_banner || "Unknown",
|
|
304
305
|
status,
|
|
305
306
|
info: r?.probe_info || null,
|
package/plugins/upnp_scanner.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// Set UPNP_INCLUDE_NON_MATCHED=1 to keep all discovered devices.
|
|
6
6
|
|
|
7
7
|
import upnp from 'node-upnp-utils';
|
|
8
|
-
import { isPrivateLike } from '../utils/net_validation.mjs';
|
|
8
|
+
import { isPrivateLike, canonicalIp } from '../utils/net_validation.mjs';
|
|
9
9
|
|
|
10
10
|
const DEBUG = /^(1|true|yes|on)$/i.test(String(process.env.DEBUG_MODE || process.env.UPNP_DEBUG || ""));
|
|
11
11
|
function dlog(...a) { if (DEBUG) console.log("[upnp-scanner]", ...a); }
|
|
@@ -54,17 +54,33 @@ export function perTargetMx(waitSec) {
|
|
|
54
54
|
// its search's result — what fails is the library's own description, which 028 does not depend on: it
|
|
55
55
|
// fetches the description of every device it reports itself. The original is called SYNCHRONOUSLY, so the
|
|
56
56
|
// device is listed exactly when it would have been; only its promise is caught.
|
|
57
|
+
//
|
|
58
|
+
// 1.3.0 lane 2 (R2): the same wrapper also refuses a NOTIFY whose LOCATION names a host other than the
|
|
59
|
+
// device that sent it. The library fetches the LOCATION of every NOTIFY it keeps with NO host check (its
|
|
60
|
+
// M-SEARCH-answer path does check), so any device on the segment could point it at loopback, cloud
|
|
61
|
+
// metadata or a third host. Such a NOTIFY is dropped before the library sees it, and COUNTED
|
|
62
|
+
// (refusedAnnouncements), never silently. The decision is taken on the LIBRARY'S OWN PARSE of the packet
|
|
63
|
+
// (`_parseSdpResponseHeader`, the same start-line test) — a parser of our own disagreed with it on a
|
|
64
|
+
// repeated LOCATION (it keeps the last), a space before the colon and a `NOTIFYX` start line. Should that
|
|
65
|
+
// parser ever be missing, every NOTIFY is refused: the check fails closed.
|
|
57
66
|
const RECEIVE_GUARD = Symbol.for('nsauditor.upnp.receivePacketGuard');
|
|
58
67
|
const DROPPED = Symbol.for('nsauditor.upnp.droppedResponses');
|
|
68
|
+
const REFUSED = Symbol.for('nsauditor.upnp.refusedAnnouncements');
|
|
59
69
|
export function guardReceivePacket(upnp) {
|
|
60
70
|
if (!upnp || typeof upnp._receivePacket !== 'function' || upnp[RECEIVE_GUARD]) return upnp;
|
|
61
71
|
const original = upnp._receivePacket;
|
|
62
72
|
upnp[DROPPED] = {};
|
|
73
|
+
upnp[REFUSED] = 0;
|
|
63
74
|
const count = (err) => {
|
|
64
75
|
const name = err?.name || 'Error';
|
|
65
76
|
upnp[DROPPED][name] = (upnp[DROPPED][name] ?? 0) + 1;
|
|
66
77
|
};
|
|
67
78
|
upnp._receivePacket = function guardedReceivePacket(...args) {
|
|
79
|
+
const [buffer, rinfo] = args;
|
|
80
|
+
if (refuseAnnouncement(upnp, buffer, rinfo)) {
|
|
81
|
+
upnp[REFUSED] += 1;
|
|
82
|
+
return Promise.resolve();
|
|
83
|
+
}
|
|
68
84
|
let p;
|
|
69
85
|
try { p = original.apply(this, args); } catch (err) { count(err); return Promise.resolve(); }
|
|
70
86
|
return Promise.resolve(p).catch(count);
|
|
@@ -72,10 +88,44 @@ export function guardReceivePacket(upnp) {
|
|
|
72
88
|
upnp[RECEIVE_GUARD] = true;
|
|
73
89
|
return upnp;
|
|
74
90
|
}
|
|
91
|
+
/** Whether the library would fetch this packet's LOCATION as a NOTIFY, from a host other than the sender. */
|
|
92
|
+
function refuseAnnouncement(upnp, buffer, rinfo) {
|
|
93
|
+
const text = Buffer.isBuffer(buffer) ? buffer.toString('utf8') : String(buffer ?? '');
|
|
94
|
+
if (/^M-SEARCH/.test(text)) return false; // the library ignores these
|
|
95
|
+
if (typeof upnp._parseSdpResponseHeader !== 'function') return /^NOTIFY/.test(text);
|
|
96
|
+
let headers;
|
|
97
|
+
try { headers = upnp._parseSdpResponseHeader(text); } catch { return /^NOTIFY/.test(text); }
|
|
98
|
+
if (!headers || !headers.USN || !/^NOTIFY/.test(String(headers['$']))) return false;
|
|
99
|
+
const loc = headers.LOCATION;
|
|
100
|
+
return loc !== undefined && !descriptionUrlAllowed(loc, rinfo?.address);
|
|
101
|
+
}
|
|
102
|
+
|
|
75
103
|
/** Library errors caught so far on this instance, by error name. */
|
|
76
104
|
export function droppedResponses(upnp) {
|
|
77
105
|
return { ...(upnp?.[DROPPED] ?? {}) };
|
|
78
106
|
}
|
|
107
|
+
/** NOTIFY announcements refused so far on this instance: their LOCATION named a host other than the sender. */
|
|
108
|
+
export function refusedAnnouncements(upnp) {
|
|
109
|
+
return upnp?.[REFUSED] ?? 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Whether a description URL a UPnP device advertised may be fetched: http or https, on the address of the
|
|
114
|
+
* device that answered — never another host, a name, or another scheme. The device chooses this URL, not the
|
|
115
|
+
* operator, so a LOCATION aimed at loopback, cloud metadata or a third host is not followed (1.3.0 lane 2, R2).
|
|
116
|
+
* @param {string} location the advertised LOCATION
|
|
117
|
+
* @param {string} answeringAddress the source address of the SSDP answer
|
|
118
|
+
*/
|
|
119
|
+
export function descriptionUrlAllowed(location, answeringAddress) {
|
|
120
|
+
let url;
|
|
121
|
+
try { url = new URL(String(location ?? '')); } catch { return false; }
|
|
122
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
|
|
123
|
+
const host = canonicalIp(url.hostname);
|
|
124
|
+
let from = canonicalIp(answeringAddress);
|
|
125
|
+
if (!host || !from) return false;
|
|
126
|
+
from = from.replace(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i, '$1');
|
|
127
|
+
return host.toLowerCase() === from.toLowerCase();
|
|
128
|
+
}
|
|
79
129
|
const droppedSince = (upnp, before) => {
|
|
80
130
|
const now = droppedResponses(upnp); const out = {};
|
|
81
131
|
for (const [k, v] of Object.entries(now)) if (v - (before[k] ?? 0) > 0) out[k] = v - (before[k] ?? 0);
|
|
@@ -197,23 +247,31 @@ function extractDeviceInfo(device, deviceXml) {
|
|
|
197
247
|
return info;
|
|
198
248
|
}
|
|
199
249
|
|
|
200
|
-
|
|
250
|
+
// The description is fetched only from the answering device's own address, with the platform fetch and
|
|
251
|
+
// `redirect: 'manual'` — a device that answers with a redirect is not followed elsewhere. (It used
|
|
252
|
+
// node-fetch, which CE never declared, following up to 20 redirects to any host.)
|
|
253
|
+
export const DESCRIPTION_REFUSED = Symbol('description refused');
|
|
254
|
+
async function fetchDeviceDescription(location, answeringAddress, timeout = 5000) {
|
|
201
255
|
if (!location) return null;
|
|
202
|
-
|
|
256
|
+
if (!descriptionUrlAllowed(location, answeringAddress)) {
|
|
257
|
+
dlog(`Refused device description URL ${location}: not on the answering device ${answeringAddress}`);
|
|
258
|
+
return DESCRIPTION_REFUSED;
|
|
259
|
+
}
|
|
260
|
+
|
|
203
261
|
try {
|
|
204
|
-
const { default: fetch } = await import('node-fetch');
|
|
205
262
|
const controller = new AbortController();
|
|
206
263
|
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
207
|
-
|
|
208
|
-
const response = await fetch(location, {
|
|
264
|
+
|
|
265
|
+
const response = await fetch(location, {
|
|
209
266
|
signal: controller.signal,
|
|
267
|
+
redirect: 'manual',
|
|
210
268
|
headers: {
|
|
211
269
|
'User-Agent': 'UPnP-Scanner/1.0'
|
|
212
270
|
}
|
|
213
271
|
});
|
|
214
|
-
|
|
272
|
+
|
|
215
273
|
clearTimeout(timeoutId);
|
|
216
|
-
|
|
274
|
+
|
|
217
275
|
if (response.ok) {
|
|
218
276
|
return await response.text();
|
|
219
277
|
} else {
|
|
@@ -280,6 +338,7 @@ async function runWithUpnp(targetHost, timeoutMs, opts) {
|
|
|
280
338
|
upnp = guardReceivePacket(upnpModule);
|
|
281
339
|
}
|
|
282
340
|
const droppedBefore = droppedResponses(upnp);
|
|
341
|
+
const refusedBefore = refusedAnnouncements(upnp);
|
|
283
342
|
|
|
284
343
|
const allDevices = [];
|
|
285
344
|
let matched = false;
|
|
@@ -326,8 +385,10 @@ async function runWithUpnp(targetHost, timeoutMs, opts) {
|
|
|
326
385
|
|
|
327
386
|
// Fetch and parse device description if available
|
|
328
387
|
let deviceXml = null;
|
|
388
|
+
let descriptionRefused = false;
|
|
329
389
|
if (location) {
|
|
330
|
-
deviceXml = await fetchDeviceDescription(location, 3000);
|
|
390
|
+
deviceXml = await fetchDeviceDescription(location, address, 3000);
|
|
391
|
+
if (deviceXml === DESCRIPTION_REFUSED) { deviceXml = null; descriptionRefused = true; }
|
|
331
392
|
}
|
|
332
393
|
|
|
333
394
|
// Extract detailed device information
|
|
@@ -360,6 +421,9 @@ async function runWithUpnp(targetHost, timeoutMs, opts) {
|
|
|
360
421
|
if (location) {
|
|
361
422
|
infoParts.push(`location=${location}`);
|
|
362
423
|
}
|
|
424
|
+
if (descriptionRefused) {
|
|
425
|
+
infoParts.push('description refused (LOCATION is not on the answering device)');
|
|
426
|
+
}
|
|
363
427
|
|
|
364
428
|
// Enhanced banner with comprehensive data
|
|
365
429
|
const bannerObj = {
|
|
@@ -376,7 +440,8 @@ async function runWithUpnp(targetHost, timeoutMs, opts) {
|
|
|
376
440
|
ssdpAnalysis,
|
|
377
441
|
deviceInfo,
|
|
378
442
|
descriptionXML: deviceXml ? deviceXml.substring(0, 2000) : null, // Limit XML size
|
|
379
|
-
xmlTruncated: deviceXml && deviceXml.length > 2000
|
|
443
|
+
xmlTruncated: deviceXml && deviceXml.length > 2000,
|
|
444
|
+
...(descriptionRefused ? { descriptionRefused: true } : {})
|
|
380
445
|
};
|
|
381
446
|
|
|
382
447
|
const row = {
|
|
@@ -398,11 +463,11 @@ async function runWithUpnp(targetHost, timeoutMs, opts) {
|
|
|
398
463
|
}
|
|
399
464
|
}
|
|
400
465
|
|
|
401
|
-
return { rows, matched, dropped: droppedSince(upnp, droppedBefore) };
|
|
466
|
+
return { rows, matched, dropped: droppedSince(upnp, droppedBefore), refused: refusedAnnouncements(upnp) - refusedBefore };
|
|
402
467
|
|
|
403
468
|
} catch (e) {
|
|
404
469
|
dlog("UPnP discovery error:", e?.message || e);
|
|
405
|
-
return { rows: [], matched: false, dropped: droppedSince(upnp, droppedBefore) };
|
|
470
|
+
return { rows: [], matched: false, dropped: droppedSince(upnp, droppedBefore), refused: refusedAnnouncements(upnp) - refusedBefore };
|
|
406
471
|
}
|
|
407
472
|
}
|
|
408
473
|
|
|
@@ -448,7 +513,10 @@ export default {
|
|
|
448
513
|
};
|
|
449
514
|
}
|
|
450
515
|
|
|
451
|
-
const { rows, matched, dropped } = await runWithUpnp(host, timeoutMs, opts);
|
|
516
|
+
const { rows, matched, dropped, refused = 0 } = await runWithUpnp(host, timeoutMs, opts);
|
|
517
|
+
if (refused > 0) {
|
|
518
|
+
console.warn(`[upnp-scanner] ${refused} ssdp:alive announcement(s) named a description URL on a host other than the announcing device and were dropped before the UPnP library could fetch it`);
|
|
519
|
+
}
|
|
452
520
|
dlog(`Discovery complete: matched=${matched}, rows.length=${rows.length}`);
|
|
453
521
|
const upnpLibraryErrors = Object.values(dropped).reduce((a, b) => a + b, 0);
|
|
454
522
|
if (upnpLibraryErrors > 0) {
|
|
@@ -514,6 +582,7 @@ export default {
|
|
|
514
582
|
waitPerTargetSec: perTargetWaitSec(timeoutMs),
|
|
515
583
|
upnpLibraryErrors,
|
|
516
584
|
upnpLibraryErrorsByName: dropped,
|
|
585
|
+
upnpRefusedAnnouncements: refused,
|
|
517
586
|
data
|
|
518
587
|
};
|
|
519
588
|
}
|