nsauditor-ai 0.2.56 → 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 +41 -40
- package/cli.mjs +171 -147
- package/mcp_server.mjs +64 -37
- 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 -2
- 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 -31
- 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/executive_report.mjs +34 -7
- 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 +4 -2
- package/utils/plugin_discovery.mjs +4 -2
- package/utils/ports_spec.mjs +46 -0
- package/utils/report_md.mjs +49 -125
- package/utils/sarif.mjs +33 -93
- package/utils/scan_delta.mjs +173 -18
- package/utils/scan_delta_view.mjs +3 -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,15 +11,79 @@ function pickResultsFromArgs(args) {
|
|
|
8
11
|
return [];
|
|
9
12
|
}
|
|
10
13
|
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
if (
|
|
16
|
-
|
|
17
|
-
return
|
|
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 {};
|
|
18
21
|
}
|
|
19
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
|
+
|
|
20
87
|
function scoreOsLabel(label) {
|
|
21
88
|
const s = String(label||'').toLowerCase();
|
|
22
89
|
if (!s || s === 'unknown') return 0;
|
|
@@ -134,6 +201,9 @@ export default {
|
|
|
134
201
|
|
|
135
202
|
async run(...args) {
|
|
136
203
|
const results = pickResultsFromArgs(args);
|
|
204
|
+
const supplied = toRegistry(pickOptsFromArgs(args).adapters);
|
|
205
|
+
const own = await ownDirectoryAdapters();
|
|
206
|
+
const pendingAttach = [];
|
|
137
207
|
const services = [];
|
|
138
208
|
const evidence = [];
|
|
139
209
|
let os = null;
|
|
@@ -163,8 +233,8 @@ export default {
|
|
|
163
233
|
|
|
164
234
|
for (const r of results) {
|
|
165
235
|
const name = r?.name || r?.id || 'plugin';
|
|
166
|
-
const
|
|
167
|
-
const
|
|
236
|
+
const id = String(r?.id ?? '');
|
|
237
|
+
const slug = nameSlug(name);
|
|
168
238
|
|
|
169
239
|
// Prefer OS and osVersion provided by plugins, but pick the most specific; OS Detector wins ties
|
|
170
240
|
if (r?.result?.os) {
|
|
@@ -174,33 +244,43 @@ export default {
|
|
|
174
244
|
osSource = picked.source;
|
|
175
245
|
}
|
|
176
246
|
|
|
177
|
-
//
|
|
178
|
-
|
|
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') {
|
|
179
250
|
hostName = extractHostNameFromUpnp(r?.result) || hostName;
|
|
180
251
|
}
|
|
181
|
-
|
|
182
|
-
// Extract host name from mDNS Scanner if available
|
|
183
|
-
if (slug === 'mdns_scanner') {
|
|
252
|
+
if (id === '027') {
|
|
184
253
|
hostName = extractHostNameFromMdns(r?.result) || hostName;
|
|
185
254
|
}
|
|
186
255
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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 });
|
|
198
280
|
}
|
|
199
281
|
if (r?.result?.data) pushEvidence(name, r.result.data);
|
|
200
282
|
continue;
|
|
201
283
|
}
|
|
202
|
-
} catch {
|
|
203
|
-
// no adapter -> fall through
|
|
204
284
|
}
|
|
205
285
|
for (const item of fallbackRecord(name, r?.result)) {
|
|
206
286
|
upsertService(services, normalizeService(item), { authoritative: false });
|
|
@@ -208,6 +288,12 @@ export default {
|
|
|
208
288
|
if (r?.result?.data) pushEvidence(name, r.result.data);
|
|
209
289
|
}
|
|
210
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
|
+
|
|
211
297
|
for (const svc of services) delete svc.__authoritative;
|
|
212
298
|
|
|
213
299
|
services.sort((a,b)=> (a.port - b.port) || String(a.protocol).localeCompare(String(b.protocol)) );
|
|
@@ -216,24 +302,28 @@ export default {
|
|
|
216
302
|
const META_PROTOCOLS = new Set(['assessment', 'icmp', 'os-detector', 'arp']);
|
|
217
303
|
const PORT_ZERO_META_PROTOCOLS = new Set(['api', 'tcp', 'udp']);
|
|
218
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;
|
|
219
308
|
if (META_PROTOCOLS.has(s.protocol)) return true;
|
|
220
309
|
if (s.port === 0 && PORT_ZERO_META_PROTOCOLS.has(s.protocol)) return true;
|
|
221
310
|
if (s.info && /Skipped:/i.test(String(s.info))) return true;
|
|
222
311
|
return false;
|
|
223
312
|
};
|
|
224
313
|
|
|
225
|
-
const metaEntries = services.filter(isMetaEntry);
|
|
314
|
+
const metaEntries = services.filter(isMetaEntry).concat(unattached);
|
|
226
315
|
const realServices = services.filter(s => !isMetaEntry(s));
|
|
227
316
|
|
|
228
|
-
// 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
|
|
229
318
|
for (const m of metaEntries) {
|
|
230
319
|
evidence.push({
|
|
231
320
|
from: m.source || 'meta',
|
|
232
321
|
protocol: m.protocol,
|
|
233
|
-
port: m.port,
|
|
322
|
+
port: Number.isFinite(m.port) ? m.port : null,
|
|
234
323
|
status: m.status,
|
|
235
324
|
info: m.info,
|
|
236
325
|
...(m.banner ? { banner: m.banner } : {}),
|
|
326
|
+
...Object.fromEntries(ADAPTER_PAYLOAD_KEYS.filter((k) => m[k] != null).map((k) => [k, m[k]])),
|
|
237
327
|
});
|
|
238
328
|
}
|
|
239
329
|
|
|
@@ -252,7 +342,8 @@ export default {
|
|
|
252
342
|
const hostUp = results.some(r => r?.result?.up === true) || services.some(s => s.status === 'open');
|
|
253
343
|
const open = services.filter(s => s.status === 'open');
|
|
254
344
|
const parts = [];
|
|
255
|
-
|
|
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');
|
|
256
347
|
if (os) parts.push(`OS: ${os}`);
|
|
257
348
|
if (osVersion) parts.push(`Version: ${osVersion}`);
|
|
258
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,
|