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.
Files changed (40) hide show
  1. package/README.md +41 -40
  2. package/cli.mjs +171 -147
  3. package/mcp_server.mjs +64 -37
  4. package/package.json +1 -1
  5. package/plugin_manager.mjs +49 -8
  6. package/plugins/040_tls_cert_auditor.mjs +176 -56
  7. package/plugins/050_tribe_health.mjs +13 -34
  8. package/plugins/060_dns_sec_auditor.mjs +24 -30
  9. package/plugins/dns_scanner.mjs +16 -8
  10. package/plugins/ftp_banner_check.mjs +21 -13
  11. package/plugins/host_up_check.mjs +31 -72
  12. package/plugins/http_probe.mjs +93 -25
  13. package/plugins/mcp_scanner.mjs +15 -2
  14. package/plugins/netbios_scanner.mjs +32 -13
  15. package/plugins/opensearch_scanner.mjs +4 -0
  16. package/plugins/port_scanner.mjs +29 -49
  17. package/plugins/result_concluder.mjs +122 -31
  18. package/plugins/snmp_scanner.mjs +20 -8
  19. package/plugins/syn_scanner.mjs +2 -1
  20. package/plugins/upnp_scanner.mjs +82 -13
  21. package/plugins/webapp_detector.mjs +82 -15
  22. package/utils/conclusion_utils.mjs +13 -1
  23. package/utils/cookie_redaction.mjs +50 -0
  24. package/utils/delta_reporter.mjs +70 -25
  25. package/utils/executive_report.mjs +34 -7
  26. package/utils/export_csv.mjs +14 -22
  27. package/utils/mcp_call_digest.mjs +31 -0
  28. package/utils/net_validation.mjs +107 -37
  29. package/utils/output_dir.mjs +4 -2
  30. package/utils/plugin_discovery.mjs +4 -2
  31. package/utils/ports_spec.mjs +46 -0
  32. package/utils/report_md.mjs +49 -125
  33. package/utils/sarif.mjs +33 -93
  34. package/utils/scan_delta.mjs +173 -18
  35. package/utils/scan_delta_view.mjs +3 -3
  36. package/utils/scan_history.mjs +149 -0
  37. package/utils/scheduler.mjs +17 -5
  38. package/utils/service_flags.mjs +363 -0
  39. package/utils/watch_cycle.mjs +37 -0
  40. package/utils/webhook.mjs +2 -1
@@ -1,6 +1,8 @@
1
1
  // utils/export_csv.mjs
2
2
  // Export scan results as CSV.
3
3
 
4
+ import { csvTokens, PAYLOAD_KEYS } from './service_flags.mjs';
5
+
4
6
  /**
5
7
  * Escape a CSV field value.
6
8
  * - Wrap in double quotes if contains comma, newline, or double quote
@@ -24,30 +26,13 @@ export function escapeCsvField(value) {
24
26
  const COLUMNS = ['host', 'port', 'protocol', 'service', 'program', 'version', 'status', 'cpe', 'security_findings'];
25
27
 
26
28
  /**
27
- * Build a security_findings string from a service record.
28
- * @param {object} svc
29
+ * The security_findings cell for a record: the shared service-flag table's tokens (utils/service_flags.mjs, 1.3.0 (s1)),
30
+ * one per key that fired with its items joined by ';'. A custom SNMP community is never a finding and never printed.
31
+ * @param {object} record
29
32
  * @returns {string}
30
33
  */
31
- function buildFindings(svc) {
32
- const parts = [];
33
-
34
- if (Array.isArray(svc.weakAlgorithms) && svc.weakAlgorithms.length) {
35
- parts.push(`weak_algorithms:${svc.weakAlgorithms.length}`);
36
- }
37
- if (svc.anonymousLogin) {
38
- parts.push('anonymous_login');
39
- }
40
- if (svc.axfrAllowed) {
41
- parts.push('axfr_allowed');
42
- }
43
- if (Array.isArray(svc.dangerousMethods) && svc.dangerousMethods.length) {
44
- parts.push(`dangerous_methods:${svc.dangerousMethods.join(';')}`);
45
- }
46
- if (svc.community) {
47
- parts.push(`default_community:${svc.community}`);
48
- }
49
-
50
- return parts.join(',');
34
+ function buildFindings(record) {
35
+ return csvTokens(record).join(',');
51
36
  }
52
37
 
53
38
  /**
@@ -77,6 +62,13 @@ export function buildCsv(scanData) {
77
62
  ];
78
63
  return fields.map(escapeCsvField).join(',');
79
64
  });
65
+ // An adapter payload that landed in evidence (no port — a domain's DNS posture when no 53/udp service was found) gets
66
+ // its own row, with no port: its findings must not depend on whether an unrelated port answered.
67
+ for (const e of conclusion?.result?.evidence ?? []) {
68
+ if (!PAYLOAD_KEYS.some((k) => e?.[k] != null)) continue;
69
+ const payload = Object.fromEntries(PAYLOAD_KEYS.filter((k) => e[k] != null).map((k) => [k, e[k]]));
70
+ rows.push([host, '', '', e.from ?? '', '', '', '', '', buildFindings(payload)].map(escapeCsvField).join(','));
71
+ }
80
72
 
81
73
  return [header, ...rows].join('\r\n') + '\r\n';
82
74
  }
@@ -0,0 +1,31 @@
1
+ // utils/mcp_call_digest.mjs
2
+ // What binds an MCP tool response to its call_id (1.3.0 (s6)). The server logs a digest of the response BODY — the text
3
+ // the receipt footer is appended to — and `nsauditor-ai mcp verify-call <id> --response <file>` recomputes it from the
4
+ // text the user saved. The digest is never printed: a printed digest would replay together with the id.
5
+ //
6
+ // Canonicalisation is WHITESPACE-INSENSITIVE and WORD-EXACT (ruled): every whitespace run, newlines included, becomes
7
+ // one space and the ends are trimmed. A copy that a client soft-wrapped or re-flowed still verifies; a changed word
8
+ // does not. Stated limits: a RENDERED copy (Markdown turned into bullets and bold) is a different text and refutes —
9
+ // copy the raw tool output; and differences inside code blocks that are whitespace only (indentation) are not seen.
10
+
11
+ import { createHash } from 'node:crypto';
12
+
13
+ /** The first line of the footer every tool response carries. */
14
+ export const RECEIPT_MARKER = '── MCP call receipt ──';
15
+
16
+ /** The response reduced to its words: every whitespace run becomes one space, the ends trimmed. */
17
+ export function canonicalResponse(text) {
18
+ return String(text ?? '').replace(/\s+/g, ' ').trim();
19
+ }
20
+
21
+ /** SHA-256 (hex) of the canonical response body. */
22
+ export function responseDigest(body) {
23
+ return createHash('sha256').update(canonicalResponse(body), 'utf8').digest('hex');
24
+ }
25
+
26
+ /** The body of a saved response: everything before the receipt footer, which is not part of what the tool produced. */
27
+ export function bodyOf(saved) {
28
+ const text = String(saved ?? '');
29
+ const at = text.lastIndexOf(RECEIPT_MARKER);
30
+ return at < 0 ? text : text.slice(0, at);
31
+ }
@@ -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 equivalents.
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
- const addr = ip.replace(/^\[|\]$/g, '').trim();
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
- * Resolve a hostname and verify the resolved IP is not in a blocked range.
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
- * @returns {Promise<string>} resolved IP address
58
- * @throws {Error} if hostname resolves to a blocked IP or DNS fails
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 { address } = await dns.lookup(hostname);
62
- if (isBlockedIp(address)) {
63
- throw new Error(`Host resolves to blocked IP range`);
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
  }
@@ -116,8 +116,10 @@ export async function createHostOutDir(baseOutDir, host, { stamp = nowStamp, log
116
116
  throw e;
117
117
  }
118
118
  if (n > 1) {
119
- log(`[scan] WARNING: ${path.join(baseOutDir, first)} already exists — another scan of ${host} started in the same `
120
- + `second — so this scan writes to ${dir} instead. Neither run's evidence overwrites the other's.`);
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.`);
121
123
  }
122
124
  return dir;
123
125
  }
@@ -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
- plugins.push({ ...plugin, _source: source, ...(conclude ? { conclude } : {}) });
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
+ }
@@ -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
- const SEVERITIES = ['Critical', 'High', 'Medium', 'Low', 'Info'];
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(/\|/g, '\\|')
22
- .replace(/`/g, '\\`')
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
- * Extract security findings from per-service fields.
38
- * Mirrors the per-service finding extraction used by sarif.mjs and export_csv.mjs:
39
- * findings are derived from service flags (anonymousLogin, weakAlgorithms, cves, etc.),
40
- * not from a separate findings array on the conclusion (which doesn't exist today).
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
- const findings = [];
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
- const hostInfo = conclusion?.host ?? {};
195
- const summaryText = conclusion?.summary ?? '';
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,17 +150,26 @@ export function buildMarkdownReport(scanData) {
237
150
  } else {
238
151
  lines.push(`- **Security findings:** 0`);
239
152
  }
240
- // 1.2.0 build 3: this renderer's findings are the service-check FLAGS only — nothing on the scan path fills a service's
241
- // CVEs, and Enterprise's analysis agents and exploit intelligence write elsewhere. Without this line "Security findings:
242
- // 0" read as a clean verdict over a host whose CLI run carried 16 CVEs (the Gate 3-A preparation's P8).
243
- // The counted list names only flags a conclusion can carry: dangerousMethods is read by extractFindings but no adapter puts
244
- // it on a service record (tests/concluder_drops_honesty.test.mjs pins that), so it is named among what is NOT counted.
245
- lines.push('- **Scope:** counts only these service-check flags: weak SSH algorithms, SNMP default community, weak TLS '
246
- + 'protocols / ciphers, and anonymous FTP login and DNS zone transfer when the scan\'s environment enables those two '
247
- + 'checks (FTP_CHECK_ANON; DNS_CHECK_AXFR with DNS_AXFR_DOMAIN — both off by default). It does not count a self-signed '
248
- + 'certificate or the MCP server checks (both on the service records), nor dangerous HTTP methods, SMB null sessions or '
249
- + 'the TLS-certificate, DNS-security and debug-endpoint auditors\' results (the scan\'s conclusion does not carry them); '
250
- + 'it does not look up CVEs, and does not include Enterprise analysis-agent findings or exploit intelligence (a CLI scan '
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 '
251
173
  + 'with the Enterprise package and a Pro or Enterprise licence records CVE and agent findings in scan_finding_queue.json, '
252
174
  + 'when there are any).');
253
175
  lines.push('');
@@ -280,14 +202,16 @@ export function buildMarkdownReport(scanData) {
280
202
  lines.push(`## Findings`);
281
203
  lines.push('');
282
204
  if (findings.length === 0) {
283
- lines.push('_None of the counted service-check flags fired. This is not a statement that the host has no known '
284
- + 'vulnerabilities — CVE lookups, analysis-agent findings, the MCP server checks and several checks\' results are not '
285
- + 'part of this count (see Scope above), and anonymous FTP login and zone transfer are tested only when enabled._');
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._');
286
208
  lines.push('');
287
209
  } else {
288
210
  findings.sort((a, b) => severityRank(a.severity) - severityRank(b.severity));
289
211
  for (const f of findings) {
290
- lines.push(`### [${f.severity}] ${f.title}`);
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)}`);
291
215
  lines.push('');
292
216
  lines.push(`- **Target:** ${escapeCell(f.target)}`);
293
217
  if (f.evidence) {