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/utils/net_validation.mjs
CHANGED
|
@@ -1,43 +1,93 @@
|
|
|
1
1
|
// utils/net_validation.mjs
|
|
2
2
|
// Shared IP/host validation utilities for SSRF prevention.
|
|
3
|
+
//
|
|
4
|
+
// 1.3.0 lane 1 A: the classifier was a set of string tests — an exact `addr === '::1'`, mapped
|
|
5
|
+
// IPv4 only in dotted notation, `/^fe80:/` for fe80::/10, IPv4 only as four decimal parts — and the
|
|
6
|
+
// resolver read the FIRST answer only. So `0:0:0:0:0:0:0:1`, `::ffff:7f00:1` (127.0.0.1),
|
|
7
|
+
// `::ffff:a9fe:a9fe` (169.254.169.254), `febf::1`, `0x7f000001` and a name answering
|
|
8
|
+
// [public, 127.0.0.1] all passed. It now classifies on net.BlockList — which compares by address
|
|
9
|
+
// value, so every IPv6 spelling of one address agrees and IPv4 rules also match the IPv4-mapped
|
|
10
|
+
// form — after canonicalising the input, and the resolver checks EVERY answer.
|
|
11
|
+
//
|
|
12
|
+
// Stated limit: NAT64 (64:ff9b::/96) addresses that embed a blocked IPv4 are not classified.
|
|
3
13
|
|
|
4
14
|
import dns from 'node:dns/promises';
|
|
15
|
+
import net from 'node:net';
|
|
16
|
+
|
|
17
|
+
// Refused in every configuration, NSA_ALLOW_ALL_HOSTS included: loopback, unspecified,
|
|
18
|
+
// link-local and the cloud metadata endpoints.
|
|
19
|
+
const ALWAYS = new net.BlockList();
|
|
20
|
+
ALWAYS.addSubnet('127.0.0.0', 8, 'ipv4'); // loopback
|
|
21
|
+
ALWAYS.addSubnet('0.0.0.0', 8, 'ipv4'); // "this network" / unspecified
|
|
22
|
+
ALWAYS.addSubnet('169.254.0.0', 16, 'ipv4'); // link-local, incl. 169.254.169.254 (AWS/GCP/Azure metadata)
|
|
23
|
+
ALWAYS.addAddress('100.100.100.200', 'ipv4'); // Alibaba Cloud metadata (inside 100.64/10)
|
|
24
|
+
ALWAYS.addAddress('::1', 'ipv6'); // loopback
|
|
25
|
+
ALWAYS.addAddress('::', 'ipv6'); // unspecified
|
|
26
|
+
ALWAYS.addSubnet('::', 96, 'ipv6'); // IPv4-compatible (::a.b.c.d), deprecated — BlockList misses ::127.0.0.1 without it
|
|
27
|
+
ALWAYS.addSubnet('fe80::', 10, 'ipv6'); // link-local, the whole /10
|
|
28
|
+
ALWAYS.addAddress('fd00:ec2::254', 'ipv6'); // AWS IMDS over IPv6 (inside fc00::/7)
|
|
29
|
+
|
|
30
|
+
// Refused unless the operator allows all hosts: private and shared address space.
|
|
31
|
+
const PRIVATE = new net.BlockList();
|
|
32
|
+
PRIVATE.addSubnet('10.0.0.0', 8, 'ipv4'); // RFC 1918
|
|
33
|
+
PRIVATE.addSubnet('172.16.0.0', 12, 'ipv4'); // RFC 1918
|
|
34
|
+
PRIVATE.addSubnet('192.168.0.0', 16, 'ipv4'); // RFC 1918
|
|
35
|
+
PRIVATE.addSubnet('100.64.0.0', 10, 'ipv4'); // RFC 6598 CGNAT
|
|
36
|
+
PRIVATE.addSubnet('fc00::', 7, 'ipv6'); // unique local
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Canonical IP literal for `input`, or null when it is not an IP literal (a name).
|
|
40
|
+
* Strips brackets and an IPv6 zone id, then accepts what net.isIP accepts; anything else is run
|
|
41
|
+
* through the WHATWG URL parser — but only when it is shaped like a legacy IPv4 spelling (digits,
|
|
42
|
+
* hex, x, dots) — which reads those spellings (0x7f000001, 0177.0.0.1, 2130706433, 127.1) as the
|
|
43
|
+
* dotted quad an OS resolver would.
|
|
44
|
+
* @param {string} input
|
|
45
|
+
* @returns {string|null}
|
|
46
|
+
*/
|
|
47
|
+
export function canonicalIp(input) {
|
|
48
|
+
const s = String(input ?? '').trim().replace(/^\[|\]$/g, '').replace(/%.*$/, '');
|
|
49
|
+
if (!s) return null;
|
|
50
|
+
if (net.isIP(s)) return s;
|
|
51
|
+
// Only a legacy-IPv4-SHAPED string may reach the URL parser. Fed anything else it reads
|
|
52
|
+
// userinfo, a port or a path: '127.0.0.1@8.8.8.8' parses to host 8.8.8.8 and '126.0.0.0/7' to
|
|
53
|
+
// host 126.0.0.0 — a public literal and a public network whose /7 spans 127/8. Those are NAMES
|
|
54
|
+
// here, routed to resolution, which refuses them.
|
|
55
|
+
if (!/^[0-9a-fx.]+$/i.test(s)) return null;
|
|
56
|
+
try {
|
|
57
|
+
const host = new URL(`http://${s}`).hostname.replace(/^\[|\]$/g, '');
|
|
58
|
+
return net.isIP(host) ? host : null;
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* How the SSRF guards treat an address.
|
|
66
|
+
* 'always' — refused in every configuration (loopback, unspecified, link-local, metadata)
|
|
67
|
+
* 'private' — refused unless the operator allows all hosts (RFC 1918, CGNAT, unique local)
|
|
68
|
+
* null — a public address, or not an IP literal at all (a name: resolve it, then classify
|
|
69
|
+
* every answer)
|
|
70
|
+
* @param {string} ip
|
|
71
|
+
* @returns {'always'|'private'|null}
|
|
72
|
+
*/
|
|
73
|
+
export function classifyAddress(ip) {
|
|
74
|
+
const addr = canonicalIp(ip);
|
|
75
|
+
if (!addr) return null;
|
|
76
|
+
const type = net.isIP(addr) === 6 ? 'ipv6' : 'ipv4';
|
|
77
|
+
if (ALWAYS.check(addr, type)) return 'always';
|
|
78
|
+
if (PRIVATE.check(addr, type)) return 'private';
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
5
81
|
|
|
6
82
|
/**
|
|
7
83
|
* Check whether an IP address belongs to a blocked (internal/private) range.
|
|
8
|
-
* Covers loopback, RFC 1918, RFC 6598, link-local, unspecified, and IPv6
|
|
84
|
+
* Covers loopback, RFC 1918, RFC 6598, link-local, unspecified, metadata and their IPv6 and
|
|
85
|
+
* IPv4-mapped equivalents, in any spelling.
|
|
9
86
|
* @param {string} ip
|
|
10
87
|
* @returns {boolean}
|
|
11
88
|
*/
|
|
12
89
|
export function isBlockedIp(ip) {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
// IPv6-mapped IPv4 — extract the IPv4 part and check it
|
|
16
|
-
const mappedMatch = addr.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i);
|
|
17
|
-
if (mappedMatch) return isBlockedIp(mappedMatch[1]);
|
|
18
|
-
|
|
19
|
-
// IPv6 blocked addresses
|
|
20
|
-
if (addr === '::1' || addr === '::') return true;
|
|
21
|
-
if (/^fe80:/i.test(addr)) return true; // link-local (fe80::/10)
|
|
22
|
-
if (/^f[cd]/i.test(addr.slice(0, 2))) return true; // fc00::/7 unique local (fc__ and fd__)
|
|
23
|
-
// IPv4-compatible loopback: ::127.0.0.1 maps to 127.0.0.1/8
|
|
24
|
-
const compatMatch = addr.match(/^::(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i);
|
|
25
|
-
if (compatMatch) return isBlockedIp(compatMatch[1]);
|
|
26
|
-
|
|
27
|
-
// IPv4 checks
|
|
28
|
-
const parts = addr.split('.');
|
|
29
|
-
if (parts.length === 4 && parts.every((p) => /^\d{1,3}$/.test(p))) {
|
|
30
|
-
const [a, b] = parts.map(Number);
|
|
31
|
-
if (a === 127) return true; // 127.0.0.0/8 (loopback)
|
|
32
|
-
if (a === 10) return true; // 10.0.0.0/8 (RFC 1918)
|
|
33
|
-
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 (RFC 1918)
|
|
34
|
-
if (a === 192 && b === 168) return true; // 192.168.0.0/16 (RFC 1918)
|
|
35
|
-
if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 (RFC 6598 CGNAT)
|
|
36
|
-
if (a === 169 && b === 254) return true; // 169.254.0.0/16 (link-local)
|
|
37
|
-
if (a === 0) return true; // 0.0.0.0/8
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
return false;
|
|
90
|
+
return classifyAddress(ip) !== null;
|
|
41
91
|
}
|
|
42
92
|
|
|
43
93
|
/**
|
|
@@ -52,15 +102,35 @@ export function isPrivateLike(ip) {
|
|
|
52
102
|
}
|
|
53
103
|
|
|
54
104
|
/**
|
|
55
|
-
*
|
|
105
|
+
* Whether the operator has lifted the private-range check (NSA_ALLOW_ALL_HOSTS). Only an explicit
|
|
106
|
+
* truthy word lifts it — "0", "false", "no", "off" and "" do NOT (before 1.3.0 any non-empty value
|
|
107
|
+
* did, so "=0" and "=false" silently lifted the guard). Same convention as host_up_check.mjs.
|
|
108
|
+
* @param {Record<string,string|undefined>} [env]
|
|
109
|
+
* @returns {boolean}
|
|
110
|
+
*/
|
|
111
|
+
export function allowAllHosts(env = process.env) {
|
|
112
|
+
return /^(1|true|yes|on)$/i.test(String(env.NSA_ALLOW_ALL_HOSTS ?? '').trim());
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Resolve a hostname and verify that NO resolved address is in a blocked range — a name whose
|
|
117
|
+
* answers include a single blocked address is refused, because a connecting client may try every
|
|
118
|
+
* answer (Node's net.connect does, with autoSelectFamily).
|
|
56
119
|
* @param {string} hostname
|
|
57
|
-
* @
|
|
58
|
-
*
|
|
120
|
+
* @param {{ allowPrivate?: boolean }} [opts] allowPrivate: admit 'private' answers (the operator's
|
|
121
|
+
* allow-all); 'always' answers — loopback, link-local, metadata — are refused regardless
|
|
122
|
+
* @returns {Promise<string>} the first resolved address (every answer having passed)
|
|
123
|
+
* @throws {Error} if any answer is in a blocked range, or DNS fails
|
|
59
124
|
*/
|
|
60
|
-
export async function resolveAndValidate(hostname) {
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
125
|
+
export async function resolveAndValidate(hostname, { allowPrivate = false } = {}) {
|
|
126
|
+
const answers = await dns.lookup(hostname, { all: true, verbatim: true });
|
|
127
|
+
const list = Array.isArray(answers) ? answers : [answers];
|
|
128
|
+
if (list.length === 0) throw new Error(`Host did not resolve`);
|
|
129
|
+
for (const { address } of list) {
|
|
130
|
+
const cls = classifyAddress(address);
|
|
131
|
+
if (cls === 'always' || (cls === 'private' && !allowPrivate)) {
|
|
132
|
+
throw new Error(`Host resolves to blocked IP range`);
|
|
133
|
+
}
|
|
64
134
|
}
|
|
65
|
-
return address;
|
|
135
|
+
return list[0].address;
|
|
66
136
|
}
|
package/utils/output_dir.mjs
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// can't recur in a new format writer (Task N.17).
|
|
13
13
|
// - `OPENAI_OUT_PATH` is honored as a legacy fallback.
|
|
14
14
|
|
|
15
|
+
import fsp from 'node:fs/promises';
|
|
15
16
|
import path from 'node:path';
|
|
16
17
|
import { toCleanPath } from './path_helpers.mjs';
|
|
17
18
|
|
|
@@ -57,3 +58,72 @@ export function resolveBaseOutDir() {
|
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
// (toCleanPath moved to utils/path_helpers.mjs in v0.1.20 — no _internals export needed.)
|
|
61
|
+
|
|
62
|
+
// A per-host directory's timestamp: YYYYMMDD_HHMMSS in LOCAL time, second granularity. Moved here from cli.mjs
|
|
63
|
+
// unchanged, beside the helper that uses it, so the directory name has ONE definition.
|
|
64
|
+
export function nowStamp(d = new Date()) {
|
|
65
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
66
|
+
return (
|
|
67
|
+
d.getFullYear().toString() +
|
|
68
|
+
pad(d.getMonth() + 1) +
|
|
69
|
+
pad(d.getDate()) + '_' +
|
|
70
|
+
pad(d.getHours()) +
|
|
71
|
+
pad(d.getMinutes()) +
|
|
72
|
+
pad(d.getSeconds())
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The host as a directory-name component. Moved here from cli.mjs unchanged — the per-host report filenames
|
|
77
|
+
// (`scan_<host>.sarif.json` / `.csv` / `.md`) use it too, so both import it from here.
|
|
78
|
+
export const safeHost = (h) => String(h ?? 'unknown').replace(/[\/\\?%*:|"<>]/g, '_');
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Create a FRESH per-host output directory and return its path.
|
|
82
|
+
*
|
|
83
|
+
* ⚠️ AN EXCLUSIVE CREATE, NEVER `recursive: true` ON THE LEAF (1.1.1 board — Gate 3-B on build 12). The name is
|
|
84
|
+
* `${safeHost(host)}_${nowStamp()}`, second-granular, so two scans of one host reaching this line in the same
|
|
85
|
+
* second computed the SAME name, and a recursive mkdir said yes to both: measured, three localhost scans wrote TWO
|
|
86
|
+
* directories and run 2's `scan_conclusion_raw.json` was overwritten by run 3's, exit 0, no warning. It is worse when
|
|
87
|
+
* the later run's finding queue is EMPTY — Enterprise writes `scan_finding_queue.json` only when there is something
|
|
88
|
+
* to write, so the earlier run's queue stayed in the shared directory and the later run's seal covered it as its own.
|
|
89
|
+
* The same second is reachable three ways: back-to-back runs, one run naming a host twice (`--host X,X`), and the
|
|
90
|
+
* repeated hour when daylight saving ends (the stamp is local time).
|
|
91
|
+
*
|
|
92
|
+
* So the leaf is created with `recursive: false`, which fails with EEXIST instead of reusing a directory, and a
|
|
93
|
+
* collision takes the next free suffix (`_2`, `_3`, …) — a COUNTER, not the run id: the run id is shared by every
|
|
94
|
+
* host of one run (so it cannot separate `--host X,X`) and is unset in watch mode. A WARNING naming both directories
|
|
95
|
+
* goes to STDOUT, where a scan's own progress lines go.
|
|
96
|
+
*
|
|
97
|
+
* ⚠️ When the seconds differ nothing changes: the name is byte-identical to the one this code has always written,
|
|
98
|
+
* because every reader, every run record's `hostsWritten[].dir` and every manifest keys on it.
|
|
99
|
+
*
|
|
100
|
+
* @param {string} baseOutDir created if absent (recursively — the BASE may already exist; the leaf may not)
|
|
101
|
+
* @param {string} host
|
|
102
|
+
* @param {{ stamp?: () => string, log?: (line: string) => void, max?: number }} [opts]
|
|
103
|
+
* `stamp` and `log` are seams; every real invocation takes the defaults.
|
|
104
|
+
* @returns {Promise<string>} the directory created
|
|
105
|
+
*/
|
|
106
|
+
export async function createHostOutDir(baseOutDir, host, { stamp = nowStamp, log = console.log, max = 100 } = {}) {
|
|
107
|
+
await fsp.mkdir(baseOutDir, { recursive: true });
|
|
108
|
+
// The stamp is taken ONCE: a suffix always belongs to the second it collided with.
|
|
109
|
+
const first = `${safeHost(host)}_${stamp()}`;
|
|
110
|
+
for (let n = 1; n <= max; n += 1) {
|
|
111
|
+
const dir = path.join(baseOutDir, n === 1 ? first : `${first}_${n}`);
|
|
112
|
+
try {
|
|
113
|
+
await fsp.mkdir(dir, { recursive: false });
|
|
114
|
+
} catch (e) {
|
|
115
|
+
if (e?.code === 'EEXIST') continue;
|
|
116
|
+
throw e;
|
|
117
|
+
}
|
|
118
|
+
if (n > 1) {
|
|
119
|
+
// v77-8 (1.3.0): the directory is stamped after the plugin runs, so the collision is two runs that FINISHED in the same
|
|
120
|
+
// second — they may have started minutes apart (README: "whose plugin runs finished in the same second").
|
|
121
|
+
log(`[scan] WARNING: ${path.join(baseOutDir, first)} already exists — another scan of ${host} finished its plugin runs in `
|
|
122
|
+
+ `the same second — so this scan writes to ${dir} instead. Neither run's evidence overwrites the other's.`);
|
|
123
|
+
}
|
|
124
|
+
return dir;
|
|
125
|
+
}
|
|
126
|
+
// ⚠️ NEVER fall back to reusing an existing directory: that is the overwrite this function exists to prevent.
|
|
127
|
+
throw new Error(`could not create a fresh output directory for ${host} under ${baseOutDir}: `
|
|
128
|
+
+ `${first} and ${max - 1} suffixed names already exist`);
|
|
129
|
+
}
|
|
@@ -33,9 +33,11 @@ async function loadPluginsFromDir(dir, source) {
|
|
|
33
33
|
const mod = await import(join(dir, file));
|
|
34
34
|
const plugin = mod.default;
|
|
35
35
|
if (plugin?.id && plugin?.name && typeof plugin?.run === 'function') {
|
|
36
|
-
// Attach conclude from named export or plugin default
|
|
36
|
+
// Attach conclude (and its authoritativePorts) from the named export or the plugin default
|
|
37
37
|
const conclude = mod.conclude ?? plugin.conclude;
|
|
38
|
-
|
|
38
|
+
const authoritativePorts = mod.authoritativePorts ?? plugin.authoritativePorts;
|
|
39
|
+
plugins.push({ ...plugin, _source: source, ...(conclude ? { conclude } : {}),
|
|
40
|
+
...(authoritativePorts ? { authoritativePorts } : {}) });
|
|
39
41
|
}
|
|
40
42
|
} catch (e) {
|
|
41
43
|
if (process.env.NSA_VERBOSE) {
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// utils/ports_spec.mjs
|
|
2
|
+
// The --ports grammar, in one place (1.3.0 lane 4, item 12): the port scanner and the webapp detector both read it, so a
|
|
3
|
+
// form one accepts the other accepts too. Moved here from plugins/port_scanner.mjs, which re-exports parsePortsSpec.
|
|
4
|
+
|
|
5
|
+
export function uniqInts(arr = []) {
|
|
6
|
+
return [...new Set((arr || []).map((x) => Number(x)).filter(Number.isFinite))];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Parse a CLI-style ports spec string into TCP/UDP port arrays.
|
|
11
|
+
*
|
|
12
|
+
* Accepted formats (entries comma-separated, whitespace tolerated):
|
|
13
|
+
* "8090" → { tcp: [8090], udp: [] }
|
|
14
|
+
* "8090,9090" → { tcp: [8090, 9090], udp: [] }
|
|
15
|
+
* "8090/tcp" → { tcp: [8090], udp: [] }
|
|
16
|
+
* "8090/udp" → { tcp: [], udp: [8090] }
|
|
17
|
+
* "8090,9090/udp" → { tcp: [8090], udp: [9090] }
|
|
18
|
+
* "8090/tcp,9090/udp" → { tcp: [8090], udp: [9090] }
|
|
19
|
+
*
|
|
20
|
+
* Default protocol when not specified: TCP.
|
|
21
|
+
*
|
|
22
|
+
* Malformed entries (non-numeric, out-of-range 1–65535, empty, unknown
|
|
23
|
+
* protocol suffix) are silently skipped — defensive for sloppy CLI input.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} spec
|
|
26
|
+
* @returns {{ tcp: number[], udp: number[] }}
|
|
27
|
+
*/
|
|
28
|
+
export function parsePortsSpec(spec) {
|
|
29
|
+
const out = { tcp: [], udp: [] };
|
|
30
|
+
if (typeof spec !== 'string') return out;
|
|
31
|
+
const entries = spec.split(',').map(s => s.trim()).filter(Boolean);
|
|
32
|
+
for (const entry of entries) {
|
|
33
|
+
// Reject entries with more than one '/' separator (e.g. "8090/tcp/extra")
|
|
34
|
+
const parts = entry.split('/');
|
|
35
|
+
if (parts.length > 2) continue;
|
|
36
|
+
const portStr = parts[0];
|
|
37
|
+
const proto = (parts[1] || 'tcp').toLowerCase();
|
|
38
|
+
if (proto !== 'tcp' && proto !== 'udp') continue;
|
|
39
|
+
const port = Number(portStr);
|
|
40
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) continue;
|
|
41
|
+
out[proto].push(port);
|
|
42
|
+
}
|
|
43
|
+
out.tcp = uniqInts(out.tcp);
|
|
44
|
+
out.udp = uniqInts(out.udp);
|
|
45
|
+
return out;
|
|
46
|
+
}
|
package/utils/report_inputs.mjs
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
} from './run_record.mjs';
|
|
26
26
|
import { CE_RETENTION_MS } from './scan_history.mjs';
|
|
27
27
|
import { canonicalHost } from './cloud_providers.mjs';
|
|
28
|
+
import { isUdpTransport, TRANSPORT_OF_LABEL } from './scan_delta.mjs';
|
|
28
29
|
|
|
29
30
|
const refuse = (reason, message) => ({ ok: false, reason, message });
|
|
30
31
|
|
|
@@ -213,6 +214,12 @@ export function shapeFinding(host, f, plugin = null, pluginName = null) {
|
|
|
213
214
|
// FIELD and never a suffix: a suffix is a value change that fabricates churn across an
|
|
214
215
|
// upgrade, a field is recomputed identically on both sides of any comparison.
|
|
215
216
|
region,
|
|
217
|
+
// ⚠️ NO TRANSPORT ON THE PLUGIN PATH (1.1.1), and deliberately null rather than read from `details.protocol`:
|
|
218
|
+
// there it is a cloud RULE's protocol (an SG / NSG ingress rule, 1170 · 1221), not the transport of a service
|
|
219
|
+
// finding, and no shipped plugin emits a service finding over UDP (the UDP probes write `data[]` and the
|
|
220
|
+
// concluder's services, never `result.findings`). So the delta's UDP rule does not reach a plugin row — the
|
|
221
|
+
// declared limit, pinned in tests/scan_delta_udp_port_not_measured.test.mjs.
|
|
222
|
+
protocol: null,
|
|
216
223
|
// ⚠️ AN EVIDENCE GAP IS SCOPE, NOT A FINDING, and the delta cannot tell them apart without
|
|
217
224
|
// this. A gap record says "the scanner could not read this surface" — so a finding that
|
|
218
225
|
// vanished behind one was not fixed, nobody looked. It is carried on the finding rather than
|
|
@@ -294,6 +301,10 @@ function shapeQueueEntry(host, q) {
|
|
|
294
301
|
return {
|
|
295
302
|
host,
|
|
296
303
|
port: q?.port ?? q?.target?.port ?? null,
|
|
304
|
+
// THE PRODUCER'S PROTOCOL LABEL (1.1.1), verbatim — `udp`, `upnp`, `llmnr`, `mdns`, `https`, … It is an
|
|
305
|
+
// application label, not a transport; the delta's `isUdpTransport` decides which labels are UDP. Before this the
|
|
306
|
+
// loader dropped it, so a vanished 161/udp row was judged by the TCP port rule and read RESOLVED.
|
|
307
|
+
protocol: typeof q?.target?.protocol === 'string' ? q.target.protocol : null,
|
|
297
308
|
severity: q?.severity != null ? String(q.severity).toUpperCase() : 'INFO',
|
|
298
309
|
title: q?.title ?? null,
|
|
299
310
|
detail: q?.description ?? q?.detail ?? null,
|
|
@@ -503,6 +514,55 @@ export function portScanOf(raw) {
|
|
|
503
514
|
return { tcpOpen: ports(res.tcpOpen), tcpClosed: ports(res.tcpClosed) };
|
|
504
515
|
}
|
|
505
516
|
|
|
517
|
+
// What the run recorded about each UDP port (1.1.1) — the delta's UDP oracle. Read from the concluder's service set,
|
|
518
|
+
// the SAME set the queue producers (the analysis agents and the intelligence engine) are pure over, so whether a UDP
|
|
519
|
+
// row's absence is a measurement depends on what this set says about its port: see `udpPortMeasurement` in
|
|
520
|
+
// scan_delta.mjs, which both the delta and Enterprise's MTTR call. Only UDP-transport labels (a `53/tcp` record never
|
|
521
|
+
// stands in for `53/udp`), port > 0. Measured over 27 real router raws the UDP statuses are `open` / `no response` /
|
|
522
|
+
// `unknown` — never `closed`, which the concluder writes only from ECONNREFUSED (an ICMP port-unreachable, a real
|
|
523
|
+
// negative). Null when the raw carries no service set: no oracle, never "nothing answered".
|
|
524
|
+
export function udpServicesOf(raw) {
|
|
525
|
+
const services = raw?.conclusion?.result?.services;
|
|
526
|
+
if (!Array.isArray(services)) return null;
|
|
527
|
+
const out = [];
|
|
528
|
+
for (const s of services) {
|
|
529
|
+
const port = Number(s?.port);
|
|
530
|
+
if (!Number.isInteger(port) || port <= 0 || !isUdpTransport(s?.protocol)) continue;
|
|
531
|
+
out.push({ port, protocol: String(s.protocol).toLowerCase(), service: s?.service ?? null, status: s?.status ?? null,
|
|
532
|
+
program: s?.program ?? null, version: s?.version ?? null });
|
|
533
|
+
}
|
|
534
|
+
return out.sort((a, b) => a.port - b.port);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Every transport's service identity (1.2.0 build 2) — the oracle for `serviceIdentityAt` in scan_delta.mjs, which the
|
|
538
|
+
// delta and Enterprise's MTTR both call to decide whether a CVE row vanished while the SAME program and version still
|
|
539
|
+
// answered. Read from the concluder's service set, the set the CVE mapper attributes from; port > 0 and a TCP or UDP
|
|
540
|
+
// transport only. Null when the raw carries no service set: no oracle, never "nothing answered".
|
|
541
|
+
export function servicesOf(raw) {
|
|
542
|
+
const services = raw?.conclusion?.result?.services;
|
|
543
|
+
if (!Array.isArray(services)) return null;
|
|
544
|
+
const out = [];
|
|
545
|
+
for (const s of services) {
|
|
546
|
+
const port = Number(s?.port);
|
|
547
|
+
const t = typeof s?.protocol === 'string' ? TRANSPORT_OF_LABEL[s.protocol.toLowerCase()] : null;
|
|
548
|
+
if (!Number.isInteger(port) || port <= 0 || (t !== 'tcp' && t !== 'udp')) continue;
|
|
549
|
+
out.push({ port, protocol: String(s.protocol).toLowerCase(), service: s?.service ?? null, status: s?.status ?? null,
|
|
550
|
+
program: s?.program ?? null, version: s?.version ?? null });
|
|
551
|
+
}
|
|
552
|
+
return out.sort((a, b) => a.port - b.port || a.protocol.localeCompare(b.protocol));
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// WHETHER ENTERPRISE'S STAGE RAN ON THIS HOST (1.1.1 — the audit seat's T1-c ruling). The scan writes
|
|
556
|
+
// `conclusion.result.eeLoadError` when the installed Enterprise package failed to LOAD (utils/ee_load.mjs) and
|
|
557
|
+
// `conclusion.result.eeEnrichmentError` when its enrichment THREW; either way its analysis agents and CVE mapper produced
|
|
558
|
+
// nothing on the host. Null when neither was recorded — which is also what a Community-only scan reads.
|
|
559
|
+
export function eeStageOf(raw) {
|
|
560
|
+
const r = raw?.conclusion?.result ?? {};
|
|
561
|
+
const loadError = typeof r.eeLoadError === 'string' ? r.eeLoadError : null;
|
|
562
|
+
const enrichmentError = typeof r.eeEnrichmentError === 'string' ? r.eeEnrichmentError : null;
|
|
563
|
+
return loadError || enrichmentError ? { loadError, enrichmentError } : null;
|
|
564
|
+
}
|
|
565
|
+
|
|
506
566
|
function shapeHost(host, dir, raw) {
|
|
507
567
|
const envelopes = Array.isArray(raw.results) ? raw.results : [];
|
|
508
568
|
const up = envelopes.some((e) => e?.result?.up === true);
|
|
@@ -520,6 +580,9 @@ function shapeHost(host, dir, raw) {
|
|
|
520
580
|
// destroyed here, before the consumer ever saw it.
|
|
521
581
|
pluginStatusRecorded: Array.isArray(raw.pluginStatus),
|
|
522
582
|
portScan: portScanOf(raw),
|
|
583
|
+
udpServices: udpServicesOf(raw),
|
|
584
|
+
services: servicesOf(raw),
|
|
585
|
+
eeStage: eeStageOf(raw),
|
|
523
586
|
};
|
|
524
587
|
}
|
|
525
588
|
|
|
@@ -537,7 +600,7 @@ function buildModel(rec, hosts, counts) {
|
|
|
537
600
|
// which utils/host_iterator.mjs de-duplicates). A name-keyed Map is last-write-wins and
|
|
538
601
|
// silently drops every same-named host's own plugin table but the final one's.
|
|
539
602
|
plugins.byHost.push({ host: h.host, dir: h.dir, status: h.pluginStatus,
|
|
540
|
-
pluginStatusRecorded: h.pluginStatusRecorded, portScan: h.portScan });
|
|
603
|
+
pluginStatusRecorded: h.pluginStatusRecorded, portScan: h.portScan, udpServices: h.udpServices, services: h.services, eeStage: h.eeStage });
|
|
541
604
|
for (const ps of h.pluginStatus) {
|
|
542
605
|
if (ps?.status === 'ran') plugins.ran += 1;
|
|
543
606
|
else if (ps?.status === 'skipped') plugins.skipped += 1;
|
package/utils/report_md.mjs
CHANGED
|
@@ -9,18 +9,25 @@
|
|
|
9
9
|
// minimal report (header + "no services detected") rather than throwing, so callers
|
|
10
10
|
// don't need to guard before invocation.
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
import { conclusionFindings, conclusionNotTested, manifestNotTested, normalizeSeverity, SERVICE_FLAGS, SEVERITY_ORDER } from './service_flags.mjs';
|
|
13
|
+
|
|
14
|
+
const SEVERITIES = SEVERITY_ORDER;
|
|
13
15
|
|
|
14
16
|
/**
|
|
15
17
|
* Escape Markdown special characters that would break table cell rendering.
|
|
16
18
|
* Pipes break tables; backticks break inline code; newlines break row layout.
|
|
17
19
|
*/
|
|
20
|
+
// Every Markdown- or HTML-active character is backslash-escaped (CommonMark allows a backslash before any
|
|
21
|
+
// ASCII punctuation), so a value a NETWORK HOST chose — a hostname from mDNS or UPnP, an OS or program from a
|
|
22
|
+
// banner, the summary that embeds them — renders as text: no link, image, autolink, emphasis or raw tag.
|
|
23
|
+
// (1.3.0: it escaped only | and `, and the hostname and OS rows, once reachable, rendered live links,
|
|
24
|
+
// remote images and <img onerror> in any renderer that allows HTML.)
|
|
25
|
+
const MD_ACTIVE = /[\\`*_[\]<>!|~]/g;
|
|
18
26
|
function escapeCell(value) {
|
|
19
27
|
if (value == null) return '';
|
|
20
28
|
return String(value)
|
|
21
|
-
.replace(
|
|
22
|
-
.replace(
|
|
23
|
-
.replace(/[\r\n]+/g, ' ');
|
|
29
|
+
.replace(/[\r\n]+/g, ' ')
|
|
30
|
+
.replace(MD_ACTIVE, (c) => `\\${c}`);
|
|
24
31
|
}
|
|
25
32
|
|
|
26
33
|
/**
|
|
@@ -34,114 +41,19 @@ function cell(value) {
|
|
|
34
41
|
}
|
|
35
42
|
|
|
36
43
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
44
|
+
* The security findings a conclusion carries — every one graded by the shared service-flag table
|
|
45
|
+
* (utils/service_flags.mjs, 1.3.0 (s1)), so this report, SARIF, the CSV and --fail-on count the same units.
|
|
46
|
+
* `evidence` is the conclusion's evidence list: an adapter payload that landed there (a domain's DNS posture when the
|
|
47
|
+
* scan found no 53/udp service) is graded too, targeting the host.
|
|
41
48
|
*
|
|
42
49
|
* @param {object[]} services
|
|
43
50
|
* @param {string} host
|
|
51
|
+
* @param {object[]} [evidence]
|
|
44
52
|
* @returns {Array<{ severity: string, title: string, target: string, evidence: string|null }>}
|
|
45
53
|
*/
|
|
46
|
-
function extractFindings(services, host) {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
for (const svc of services) {
|
|
50
|
-
const target = `${host}:${svc.port}/${svc.protocol || 'tcp'}`;
|
|
51
|
-
|
|
52
|
-
if (svc.anonymousLogin === true) {
|
|
53
|
-
findings.push({
|
|
54
|
-
severity: 'Critical',
|
|
55
|
-
title: 'FTP anonymous login enabled',
|
|
56
|
-
target,
|
|
57
|
-
evidence: `${svc.service || 'ftp'} on ${target} accepts anonymous authentication.`,
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
if (svc.axfrAllowed === true) {
|
|
62
|
-
findings.push({
|
|
63
|
-
severity: 'Critical',
|
|
64
|
-
title: 'DNS zone transfer (AXFR) allowed',
|
|
65
|
-
target,
|
|
66
|
-
evidence: `Zone transfer permitted on ${target}; entire zone may be enumerated.`,
|
|
67
|
-
});
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
if (svc.community && (svc.community === 'public' || svc.community === 'private')) {
|
|
71
|
-
findings.push({
|
|
72
|
-
severity: 'High',
|
|
73
|
-
title: `SNMP default community string: ${svc.community}`,
|
|
74
|
-
target,
|
|
75
|
-
evidence: `SNMP responds to community "${svc.community}" on ${target}.`,
|
|
76
|
-
});
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
if (Array.isArray(svc.weakAlgorithms) && svc.weakAlgorithms.length > 0) {
|
|
80
|
-
const algos = svc.weakAlgorithms
|
|
81
|
-
.map((a) => (typeof a === 'string' ? a : a?.algorithm || a?.name || ''))
|
|
82
|
-
.filter(Boolean);
|
|
83
|
-
findings.push({
|
|
84
|
-
severity: 'Medium',
|
|
85
|
-
title: `Weak algorithm(s) supported: ${algos.join(', ')}`,
|
|
86
|
-
target,
|
|
87
|
-
evidence: null,
|
|
88
|
-
});
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
if (Array.isArray(svc.weakProtocols) && svc.weakProtocols.length > 0) {
|
|
92
|
-
findings.push({
|
|
93
|
-
severity: 'Medium',
|
|
94
|
-
title: `Weak protocol(s) enabled: ${svc.weakProtocols.join(', ')}`,
|
|
95
|
-
target,
|
|
96
|
-
evidence: null,
|
|
97
|
-
});
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
if (Array.isArray(svc.weakCiphers) && svc.weakCiphers.length > 0) {
|
|
101
|
-
findings.push({
|
|
102
|
-
severity: 'Medium',
|
|
103
|
-
title: `Weak cipher(s) supported: ${svc.weakCiphers.length} cipher(s)`,
|
|
104
|
-
target,
|
|
105
|
-
evidence: svc.weakCiphers.slice(0, 5).join(', '),
|
|
106
|
-
});
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
if (Array.isArray(svc.dangerousMethods) && svc.dangerousMethods.length > 0) {
|
|
110
|
-
findings.push({
|
|
111
|
-
severity: 'Medium',
|
|
112
|
-
title: `Dangerous HTTP method(s) allowed: ${svc.dangerousMethods.join(', ')}`,
|
|
113
|
-
target,
|
|
114
|
-
evidence: null,
|
|
115
|
-
});
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
const cves = svc.cves || svc.cve || [];
|
|
119
|
-
if (Array.isArray(cves)) {
|
|
120
|
-
for (const cve of cves) {
|
|
121
|
-
const cveId = typeof cve === 'string' ? cve : (cve?.id || cve?.cveId || '');
|
|
122
|
-
if (!cveId) continue;
|
|
123
|
-
const sev = (typeof cve === 'object' && cve?.severity) ? String(cve.severity) : 'High';
|
|
124
|
-
findings.push({
|
|
125
|
-
severity: normalizeSeverity(sev),
|
|
126
|
-
title: `${cveId} — ${svc.program || svc.service || 'service'}${svc.version && svc.version !== 'Unknown' ? ' ' + svc.version : ''}`,
|
|
127
|
-
target,
|
|
128
|
-
evidence: `See https://nvd.nist.gov/vuln/detail/${cveId}`,
|
|
129
|
-
});
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
return findings;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function normalizeSeverity(sev) {
|
|
138
|
-
if (!sev) return 'Info';
|
|
139
|
-
const s = String(sev).trim().toLowerCase();
|
|
140
|
-
if (s.startsWith('crit')) return 'Critical';
|
|
141
|
-
if (s.startsWith('hi')) return 'High';
|
|
142
|
-
if (s.startsWith('med')) return 'Medium';
|
|
143
|
-
if (s.startsWith('lo')) return 'Low';
|
|
144
|
-
return 'Info';
|
|
54
|
+
function extractFindings(services, host, evidence = []) {
|
|
55
|
+
return conclusionFindings({ services, evidence }, host)
|
|
56
|
+
.map((f) => ({ severity: f.severity, title: f.title, target: f.target, evidence: f.evidence }));
|
|
145
57
|
}
|
|
146
58
|
|
|
147
59
|
function severityRank(sev) {
|
|
@@ -191,8 +103,9 @@ export function buildMarkdownReport(scanData) {
|
|
|
191
103
|
const host = scanData.host ?? '(unknown host)';
|
|
192
104
|
const conclusion = scanData.conclusion ?? {};
|
|
193
105
|
const services = conclusion?.result?.services ?? [];
|
|
194
|
-
|
|
195
|
-
const
|
|
106
|
+
// runConcluder's callers receive {id, name, result: <conclusion>}; a legacy flat shape is still read.
|
|
107
|
+
const hostInfo = conclusion?.result?.host ?? conclusion?.host ?? {};
|
|
108
|
+
const summaryText = conclusion?.result?.summary ?? conclusion?.summary ?? '';
|
|
196
109
|
const toolVersion = scanData.toolVersion ?? '';
|
|
197
110
|
const scanTime = scanData.scanTime instanceof Date
|
|
198
111
|
? scanData.scanTime.toISOString()
|
|
@@ -224,7 +137,7 @@ export function buildMarkdownReport(scanData) {
|
|
|
224
137
|
}
|
|
225
138
|
lines.push(`- **Services detected:** ${services.length}`);
|
|
226
139
|
|
|
227
|
-
const findings = extractFindings(services, host);
|
|
140
|
+
const findings = extractFindings(services, host, conclusion?.result?.evidence ?? conclusion?.evidence ?? []);
|
|
228
141
|
if (findings.length > 0) {
|
|
229
142
|
const counts = {};
|
|
230
143
|
for (const sev of SEVERITIES) counts[sev] = 0;
|
|
@@ -237,6 +150,28 @@ export function buildMarkdownReport(scanData) {
|
|
|
237
150
|
} else {
|
|
238
151
|
lines.push(`- **Security findings:** 0`);
|
|
239
152
|
}
|
|
153
|
+
// 1.3.0 (a4) + (s3): a check that did not run, or ran and could not complete, is said to be NOT TESTED with its reason —
|
|
154
|
+
// never "none" and never "refused". One line per check and reason, from the shared table, with every target it covers.
|
|
155
|
+
// Build 4 (Gate 3-A F-1): a host-level audit that did not run — the DNS-security audit (060) declines an IP-address
|
|
156
|
+
// target — is read from the run's MANIFEST, which both callers hand in; without one nothing is inferred from absence.
|
|
157
|
+
const notTested = new Map();
|
|
158
|
+
for (const n of [...conclusionNotTested(conclusion), ...manifestNotTested(scanData.manifest, host)]) {
|
|
159
|
+
const k = `${n.check}\u0000${n.reason}`;
|
|
160
|
+
if (!notTested.has(k)) notTested.set(k, { ...n, targets: [] });
|
|
161
|
+
notTested.get(k).targets.push(n.target);
|
|
162
|
+
}
|
|
163
|
+
for (const n of notTested.values()) {
|
|
164
|
+
lines.push(`- **${n.check} not tested:** ${n.targets.map((t) => escapeCell(t)).join(', ')} — ${escapeCell(n.reason)}`);
|
|
165
|
+
}
|
|
166
|
+
// 1.2.0 build 3: without this line "Security findings: 0" read as a clean verdict over a host whose CLI run carried 16
|
|
167
|
+
// CVEs (the Gate 3-A preparation's P8). Since 1.3.0 (s1) the counted list is DERIVED from the shared service-flag table's
|
|
168
|
+
// labels, so it names exactly what is graded — it cannot run ahead of the table or fall behind it.
|
|
169
|
+
const counted = [...new Set(SERVICE_FLAGS.map((r) => r.label).filter(Boolean))];
|
|
170
|
+
lines.push(`- **Scope:** counts only these service-check findings: ${counted.join(', ')}. `
|
|
171
|
+
+ 'The opt-in checks are off by default. '
|
|
172
|
+
+ 'It does not look up CVEs, and does not include Enterprise analysis-agent findings or exploit intelligence (a CLI scan '
|
|
173
|
+
+ 'with the Enterprise package and a Pro or Enterprise licence records CVE and agent findings in scan_finding_queue.json, '
|
|
174
|
+
+ 'when there are any).');
|
|
240
175
|
lines.push('');
|
|
241
176
|
|
|
242
177
|
// ---- Services table ----
|
|
@@ -267,12 +202,16 @@ export function buildMarkdownReport(scanData) {
|
|
|
267
202
|
lines.push(`## Findings`);
|
|
268
203
|
lines.push('');
|
|
269
204
|
if (findings.length === 0) {
|
|
270
|
-
lines.push('
|
|
205
|
+
lines.push('_None of the counted service checks found anything. This is not a statement that the host has no known '
|
|
206
|
+
+ 'vulnerabilities — CVE lookups and analysis-agent findings are not part of this count (see Scope above), and '
|
|
207
|
+
+ 'anonymous FTP login, zone transfer and the SMB null session are tested only when enabled._');
|
|
271
208
|
lines.push('');
|
|
272
209
|
} else {
|
|
273
210
|
findings.sort((a, b) => severityRank(a.severity) - severityRank(b.severity));
|
|
274
211
|
for (const f of findings) {
|
|
275
|
-
|
|
212
|
+
// The title is escaped as a WHOLE: a CVE title interpolates the service's program and version, which a host
|
|
213
|
+
// chooses (the HTTP probe's program is the raw Server header).
|
|
214
|
+
lines.push(`### [${f.severity}] ${escapeCell(f.title)}`);
|
|
276
215
|
lines.push('');
|
|
277
216
|
lines.push(`- **Target:** ${escapeCell(f.target)}`);
|
|
278
217
|
if (f.evidence) {
|