nsauditor-ai 0.2.49 → 0.2.51

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.
@@ -0,0 +1,574 @@
1
+ // utils/executive_report.mjs — the Pro `report --format executive` HTML deliverable.
2
+ //
3
+ // Renders Task 4's normalised run model and Task 5's brand into ONE self-contained HTML
4
+ // document. This file is the LAST UNGATED LAYER in the product: every gate upstream of it
5
+ // checks source and published pages; nothing opens the file a consultant sends to their
6
+ // customer. What it says here is what the customer believes.
7
+ //
8
+ // ⚠️ THE INVARIANT (copied verbatim from the design — do not paraphrase):
9
+ // "The only external reference the file may carry is an HTML <a href> whose scheme is http,
10
+ // https or mailto. Every other attribute or CSS value containing a URL scheme must begin with
11
+ // data: or #. No <script> element and no on* attribute exists anywhere in the file."
12
+ //
13
+ // Stated as a rule rather than a deny-list: an enumerated list misses <iframe src>,
14
+ // <object data>, srcset, <base href>, <meta http-equiv="refresh">, <form action>,
15
+ // <video poster>. A hyperlink is not egress — <a href> fetches nothing until a human clicks
16
+ // it — so a CVE citation to nvd.nist.gov is permitted; a test that grepped output for
17
+ // http(s):// would fire on every legitimate citation and be switched off in its first week.
18
+ // `egressViolations` below is that rule made executable, and is the single implementation
19
+ // this module's own render, its own test file, and any later gate all share.
20
+ //
21
+ // Everything reaching this renderer is untrusted text: operator brand fields (Task 5 loads
22
+ // them unescaped, by design, and documents that Task 6 must escape at render time), and the
23
+ // scan model itself — a finding's title/detail/remediation, a host name, a plugin name or
24
+ // error reason can all originate from a scanned target's banner text or from a hostile
25
+ // `--host-file`. Every one of those is escaped at the point it is interpolated, whether into
26
+ // a text node or into an HTML attribute — never assumed to already be one of a fixed
27
+ // vocabulary, even where the producer (report_inputs.mjs) is expected to constrain it.
28
+
29
+ import { escapeHtml } from './brand.mjs';
30
+
31
+ /* ------------------------------------------------------------------------------------------
32
+ * egressViolations — deny-by-default. Two levels: every TAG, then EVERY attribute of it
33
+ * (double-, single- and UNQUOTED). Entities are decoded and tab/newline/NUL stripped BEFORE
34
+ * testing, because the browser decodes them before acting on a value. Values are refused
35
+ * unless they begin with `data:` or `#`; the sole exemption is an <a href> whose scheme is
36
+ * http, https or mailto. Some elements are refused BY NAME — no value of theirs is ever
37
+ * acceptable here.
38
+ *
39
+ * ⚠️ STATED LIMIT, restored here because this function is EXPORTED and Task 9 reuses it as the
40
+ * shared predicate over the REAL rendered output: this is a regex over SERIALISED HTML, not a
41
+ * parser. A renderer that emitted an unbalanced quote could still hide an attribute from
42
+ * TAG_RE — the quoted-run fix below closes one such gap, found in this task's own adversarial
43
+ * pass, but it does not make this a tokenizer with a real parse tree. The renderer in this file
44
+ * is ours, and every interpolated value passes `escapeHtml` before it ever reaches here; Task 9
45
+ * Step 1 drives the actual rendered document through this predicate, and THAT leg — not this
46
+ * function's own cleverness — is what covers a renderer bug this regex cannot see. Do not read
47
+ * an empty result from this function as "this HTML is safe" in general; read it as "this
48
+ * document did not match any of the shapes this predicate looks for."
49
+ * ------------------------------------------------------------------------------------------ */
50
+ const ENT = { lt: '<', gt: '>', amp: '&', quot: '"', colon: ':', tab: '\t', newline: '\n' };
51
+ const decode = (s) => s
52
+ .replace(/&#x([0-9a-f]+);?/gi, (_, h) => String.fromCodePoint(parseInt(h, 16)))
53
+ .replace(/&#(\d+);?/g, (_, d) => String.fromCodePoint(Number(d)))
54
+ .replace(/&(lt|gt|amp|quot|colon|tab|newline);/gi, (_, e) => ENT[e.toLowerCase()])
55
+ .replace(/[\t\n\r\0]/g, '');
56
+ const FORBIDDEN_TAGS = /^(?:script|iframe|frame|object|embed|base|form|link|applet|template)$/i;
57
+ // ⚠️ FOUND IN THIS IMPLEMENTER'S OWN ADVERSARIAL PASS, NOT IN THE BRIEF'S BATTERY.
58
+ // The brief's own TAG_RE was `/<([a-zA-Z][\w:-]*)\b([^>]*)>/g` — `[^>]*` stops at the FIRST
59
+ // `>` no matter where it sits, including inside a QUOTED attribute value. A real HTML
60
+ // tokenizer does not treat `<`/`>` specially while inside a quoted attribute value — only the
61
+ // matching closing quote ends it — so `<a href="data:text/html,<b>x</b>">c</a>` is one real
62
+ // `<a>` tag whose href is the whole `data:text/html,<b>x</b>` string, but the brief's regex
63
+ // truncates the match at the first `>` (inside the nested `<b>`), leaving the actual href
64
+ // value NEVER examined and the fixture the brief's own step 4b lists for this exact shape
65
+ // (`'data:text/html document'`) silently GREEN. Measured directly: `egressViolations` on
66
+ // that fixture returned `[]` before this fix. The repair makes the tag-attrs capture
67
+ // consume a fully-quoted run (either quote style) as one unit before a bare `>` is allowed to
68
+ // close the tag, matching how attribute-value tokenizing actually works.
69
+ const TAG_RE = /<([a-zA-Z][\w:-]*)\b((?:[^>"']|"[^"]*"|'[^']*')*)>/g;
70
+ const ATTR_RE = /([^\s"'<>\/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
71
+ // ⚠️ TWO TIERS, AND THE SECOND TIER IS AN EXEMPTION THAT WAS ITSELF A WALK-THROUGH.
72
+ // A bare `[a-z][a-z0-9+.-]*:` would read ordinary attribute prose as a scheme —
73
+ // alt="Note: the logo", title="SSL: weak cipher suite" — and a finding title routinely
74
+ // carries a colon, so a naive rule would fire on nearly every real report and get switched
75
+ // off in its first week. The obvious repair, `:(?!\s)` ("a scheme is never followed by
76
+ // whitespace"), is FALSE for the schemes that matter: `javascript` is not a special scheme,
77
+ // so everything after its colon is the script body, and `href="javascript: alert(1)"`
78
+ // EXECUTES — leading space included. So: known-dangerous schemes are URL-ish REGARDLESS of
79
+ // what follows; only an UNKNOWN `word:` gets the prose exemption (and only when not followed
80
+ // by whitespace).
81
+ const URLISH = /^\s*(?:(?:javascript|vbscript|data|blob|file|ftp|https?|wss?|about):|[a-z][a-z0-9+.-]*:(?!\s)|\/\/)/i;
82
+ const CSS_BAD = /@import|expression\s*\(|url\s*\(\s*(?:["']\s*)?(?!data:|#)/i;
83
+
84
+ export function egressViolations(html) {
85
+ const out = [];
86
+ let t;
87
+ TAG_RE.lastIndex = 0;
88
+ while ((t = TAG_RE.exec(html))) {
89
+ const tag = t[1].toLowerCase();
90
+ if (FORBIDDEN_TAGS.test(tag)) { out.push(`<${tag}> is not permitted`); continue; }
91
+ if (tag === 'meta' && !/^\s*(?:charset|name)\s*=/i.test(t[2])) {
92
+ out.push('<meta> other than charset/name is not permitted'); continue;
93
+ }
94
+ let a;
95
+ ATTR_RE.lastIndex = 0;
96
+ while ((a = ATTR_RE.exec(t[2]))) {
97
+ const attr = a[1].toLowerCase();
98
+ const val = decode(a[2] ?? a[3] ?? a[4] ?? '');
99
+ if (attr.startsWith('on')) { out.push(`${tag} carries an event attribute ${attr}`); continue; }
100
+ if (attr === 'srcdoc') { out.push(`${tag}[srcdoc] embeds a document`); continue; }
101
+ if (attr === 'style') { if (CSS_BAD.test(val)) out.push(`${tag}[style] carries a CSS reference`); continue; }
102
+ if (attr === 'srcset') { if (/[a-z]+:|\/\//i.test(val) && !/^\s*data:/i.test(val)) out.push(`${tag}[srcset] names an external URL`); continue; }
103
+ if (!URLISH.test(val)) continue;
104
+ // ⚠️ NARROWED TO THE SHAPE WE ACTUALLY EMIT. A blanket `data:` exemption also clears
105
+ // `<a href="data:text/html,<script>…">`, a document that can run script. The only
106
+ // data: URI this renderer produces is the brand logo, so the exemption is exactly
107
+ // that.
108
+ if (/^\s*(?:data:image\/(?:png|jpeg);base64,|#)/i.test(val)) continue;
109
+ if (tag === 'a' && attr === 'href' && /^\s*(?:https?:|mailto:)/i.test(val)) continue;
110
+ out.push(`${tag}[${attr}] names an external URL: ${val.slice(0, 60)}`);
111
+ }
112
+ }
113
+ for (const s of html.match(/<style\b[^>]*>[\s\S]*?<\/style>/gi) ?? []) {
114
+ if (CSS_BAD.test(decode(s))) out.push('a <style> block carries a CSS reference');
115
+ }
116
+ return out;
117
+ }
118
+
119
+ /* ------------------------------------------------------------------------------------------
120
+ * Rendering helpers
121
+ * ------------------------------------------------------------------------------------------ */
122
+
123
+ // PASS is LAST and is its own tier. Without an entry here (and in SEV_COLORS) `chartSev`
124
+ // collapses it to the literal 'OTHER', so a client-facing report labelled every passing
125
+ // check "OTHER" — measured at 18 records on a real AWS run.
126
+ const SEV_ORDER = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO', 'PASS'];
127
+ const SEV_COLORS = {
128
+ CRITICAL: '#7a1220',
129
+ HIGH: '#b3261e',
130
+ MEDIUM: '#b3690a',
131
+ LOW: '#8a7500',
132
+ INFO: '#3b5568',
133
+ PASS: '#2f6f4f',
134
+ OTHER: '#5a5a5a',
135
+ };
136
+ const SEV_RANK = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3, INFO: 4, PASS: 5 };
137
+ const TOP_RISKS_LIMIT = 10;
138
+ const CVE_RE = /^CVE-\d{4}-\d{4,7}$/i;
139
+
140
+ // A finding's severity reaches this renderer as free text from report_inputs.mjs, which
141
+ // itself only uppercases whatever a plugin supplied — it is not a closed vocabulary this
142
+ // module can rely on. `chartSev` collapses ANY value outside the five known severities to a
143
+ // fixed literal 'OTHER' so (a) no raw value is ever placed in a data-row-severity attribute
144
+ // without going through escapeHtml first regardless, keeping this a belt-and-braces measure
145
+ // rather than the only defense, and (b) an out-of-vocabulary severity still counts toward the
146
+ // chart total instead of silently vanishing from it — the same "a count must not silently
147
+ // drop members" discipline this project applies to its compliance matrices.
148
+ function chartSev(sev) {
149
+ return SEV_COLORS[sev] ? sev : 'OTHER';
150
+ }
151
+
152
+ function sevRank(sev) {
153
+ return SEV_RANK[sev] ?? 99;
154
+ }
155
+
156
+ function sevClass(sev) {
157
+ return SEV_COLORS[sev] ? `sev-${sev.toLowerCase()}` : 'sev-other';
158
+ }
159
+
160
+ // TOP RISKS ordering, stated verbatim on the page: known-exploited first, then EPSS
161
+ // probability (descending; a finding with no EPSS score ranks behind one that has any score
162
+ // at all), then severity.
163
+ function compareRisk(a, b) {
164
+ if (Boolean(a.kev) !== Boolean(b.kev)) return a.kev ? -1 : 1;
165
+ const ae = typeof a.epss === 'number' ? a.epss : -1;
166
+ const be = typeof b.epss === 'number' ? b.epss : -1;
167
+ if (ae !== be) return be - ae;
168
+ return sevRank(a.severity) - sevRank(b.severity);
169
+ }
170
+
171
+ // "(untitled finding)" is the ALARM for a non-pass finding whose producer emitted no
172
+ // content — it must stay reserved for that, or a real producer defect reads as normal
173
+ // output. A PASS record legitimately has nothing to report.
174
+ function untitledLabel(f) {
175
+ return chartSev(f?.severity) === 'PASS' ? 'clean — no issues recorded' : '(untitled finding)';
176
+ }
177
+
178
+ function comparePresentation(a, b) {
179
+ const r = sevRank(a.severity) - sevRank(b.severity);
180
+ if (r !== 0) return r;
181
+ return (a.port ?? -1) - (b.port ?? -1);
182
+ }
183
+
184
+ function fmtDate(value) {
185
+ const d = value instanceof Date ? value : new Date(value);
186
+ if (Number.isNaN(d.getTime())) return 'unknown';
187
+ const pad = (n) => String(n).padStart(2, '0');
188
+ return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} `
189
+ + `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())} UTC`;
190
+ }
191
+
192
+ function fmtPct(epss) {
193
+ return typeof epss === 'number' && Number.isFinite(epss) ? `${(epss * 100).toFixed(1)}%` : '—';
194
+ }
195
+
196
+ // A CVE id is validated by SHAPE before it is ever used to build a link — the model's own
197
+ // data reaches this function, and a malformed or hostile "CVE" string (a compromised target's
198
+ // banner text, say) must never be trusted enough to become a URL. Anything that does not match
199
+ // renders as escaped plain text instead, same as any other untrusted string.
200
+ function renderCves(cves) {
201
+ if (!Array.isArray(cves) || cves.length === 0) return '—';
202
+ return cves.map((c) => {
203
+ const s = String(c);
204
+ if (!CVE_RE.test(s)) return escapeHtml(s);
205
+ const safe = s.toUpperCase();
206
+ return `<a href="https://nvd.nist.gov/vuln/detail/${escapeHtml(safe)}" `
207
+ + `rel="noopener noreferrer">${escapeHtml(safe)}</a>`;
208
+ }).join(', ');
209
+ }
210
+
211
+ // `p.ran`/`p.skipped`/`p.errored`/`p.timedOut` interpolate WITHOUT escapeHtml, deliberately —
212
+ // they are computed integers (report_inputs.mjs's own plugin-status tally), never a string a
213
+ // scanned target or an operator's --host-file could shape. Escaping an integer would be a rule
214
+ // with no defect behind it (the standard this cycle held Task 5 to for a parallel non-issue);
215
+ // the file's escaping discipline is universal for STRINGS reaching this renderer, not for
216
+ // values that were never strings to begin with.
217
+ function pluginSummary(plugins) {
218
+ const p = plugins ?? { ran: 0, skipped: 0, errored: 0, timedOut: 0 };
219
+ const parts = [`${p.ran} plugin${p.ran === 1 ? '' : 's'} ran`];
220
+ if (p.skipped) parts.push(`${p.skipped} skipped`);
221
+ if (p.errored) parts.push(`${p.errored} errored`);
222
+ if (p.timedOut) parts.push(`${p.timedOut} timed out`);
223
+ const sentence = parts.length === 1
224
+ ? `${parts[0]}.`
225
+ : `${parts.slice(0, -1).join(', ')} and ${parts[parts.length - 1]}.`;
226
+ return `${sentence} Reasons per host in the appendix.`;
227
+ }
228
+
229
+ // The named-missing list (`coverage.missing`) and the arithmetic gap (`requested - written`)
230
+ // can disagree — report_inputs.mjs's model carries only NAMED misses; a host whose `--host`
231
+ // input could not even be parsed into a name is counted upstream but never named here. This
232
+ // renders what the model actually provides rather than assuming the two always match.
233
+ function partialCaveat(coverage) {
234
+ const gap = coverage.requested - coverage.written;
235
+ if (gap <= 0) return null;
236
+ const named = coverage.missing ?? [];
237
+ const list = named.map((h) => escapeHtml(String(h))).join(', ');
238
+ if (named.length && named.length === gap) {
239
+ return `${gap} of ${coverage.requested} requested hosts were not scanned: ${list}.`;
240
+ }
241
+ if (named.length) {
242
+ return `${gap} of ${coverage.requested} requested hosts were not scanned, including: ${list}.`;
243
+ }
244
+ return `${gap} of ${coverage.requested} requested hosts were not scanned.`;
245
+ }
246
+
247
+ // CC-1: `model.kev.loaded`/`model.epss.loaded` are WHOLE-RUN, all-or-nothing aggregates
248
+ // (aggregateStoreLoad in cli.mjs: true only if EVERY written host reported a snapshot date).
249
+ // A per-finding `f.kev`/`f.epss` is set independently, per host, by EE's enrichment — and the
250
+ // two can disagree for two real reasons, neither exotic: (1) an interrupted run reported with
251
+ // `--allow-partial`, where finalize never ran so `kevLoaded` still holds `writeRunStart`'s
252
+ // pessimistic `false` default while individual findings carry real enrichment from before the
253
+ // interruption; (2) a completed multi-host run where the aggregate is honestly `false` because
254
+ // ONE host's enrichment failed to report a snapshot date, while every OTHER host's findings were
255
+ // genuinely evaluated. Denying the check outright ("NOT evaluated") on this page is FALSE the
256
+ // moment any finding below it shows a real result — and Top Risks' own ordering rule ("ordered
257
+ // by known-exploited first…") would then be citing a signal this same cover just denied.
258
+ //
259
+ // The safe direction is never to deny a check whose results are ON THE PAGE. So the cover looks
260
+ // at the model's own findings before it is allowed to say NOT evaluated: if the aggregate says
261
+ // no but at least one finding carries a real result anyway, the cover states the truth — partial,
262
+ // uncertain coverage — rather than a flat denial that the page immediately contradicts. Only
263
+ // `kev === true` is a legible per-finding SIGNAL here — `shapeFinding()` collapses "evaluated
264
+ // clear" and "never evaluated" to the same `false`, so a false/null value proves nothing and is
265
+ // not used to override the aggregate; only an unambiguous `true` (or a real EPSS number) can.
266
+ function kevEvaluatedSomewhere(findings) {
267
+ return findings.some((f) => f.kev === true);
268
+ }
269
+ function epssEvaluatedSomewhere(findings) {
270
+ return findings.some((f) => typeof f.epss === 'number' && Number.isFinite(f.epss));
271
+ }
272
+
273
+ function renderKevEpss(model) {
274
+ const findings = model.findings ?? [];
275
+ const kevSeen = kevEvaluatedSomewhere(findings);
276
+ const epssSeen = epssEvaluatedSomewhere(findings);
277
+ const kevLine = model.kev?.loaded
278
+ ? `Known-exploited status evaluated against the CISA KEV snapshot dated `
279
+ + `${escapeHtml(String(model.kev.snapshot ?? 'unknown'))}.`
280
+ : kevSeen
281
+ ? 'Known-exploited status evaluated for some but not all hosts in this scan — the CISA '
282
+ + 'KEV feed was not confirmed loaded for the whole run. A finding marked "Yes" below was '
283
+ + 'matched against KEV; one not marked "Yes" may be genuinely clear, or simply '
284
+ + 'unconfirmed.'
285
+ : 'Known-exploited status NOT evaluated — no CISA KEV feed was loaded for this scan.';
286
+ const epssLine = model.epss?.loaded
287
+ ? `Exploitation probability from the FIRST EPSS snapshot dated `
288
+ + `${escapeHtml(String(model.epss.snapshot ?? 'unknown'))}.`
289
+ : epssSeen
290
+ ? 'Exploitation probability available for some but not all hosts in this scan — the FIRST '
291
+ + 'EPSS feed was not confirmed loaded for the whole run. A percentage shown below is a '
292
+ + 'real score; a missing one may be genuinely absent, or simply unconfirmed.'
293
+ : 'Exploitation probability NOT evaluated — no FIRST EPSS feed was loaded for this scan.';
294
+ return `<p>${kevLine}</p>\n<p>${epssLine}</p>`;
295
+ }
296
+
297
+ function renderBrandBlock(brand) {
298
+ const parts = [];
299
+ if (brand.companyName) parts.push(`<p class="brand-company">Prepared for ${escapeHtml(brand.companyName)}</p>`);
300
+ // brand.contact renders as TEXT ONLY, never as an href — a `mailto:`/`javascript:` value in
301
+ // this field must never become a live link (Task 5's own module header carries this
302
+ // constraint; this is where it is enforced).
303
+ if (brand.contact) parts.push(`<p class="brand-contact">Contact: ${escapeHtml(brand.contact)}</p>`);
304
+ if (brand.logoDataUri) {
305
+ // brand.mjs only ever produces a data:image/png|jpeg;base64,... URI (magic-byte sniffed,
306
+ // size-capped) — the sole data: shape egressViolations' own exemption is narrowed to.
307
+ // Escaped anyway: a security property that holds only because an upstream module happens
308
+ // to guarantee it is not a property this file should assume without also enforcing it.
309
+ parts.push(`<img class="brand-logo" src="${escapeHtml(brand.logoDataUri)}" `
310
+ + `alt="${escapeHtml(brand.companyName || 'Company logo')}">`);
311
+ }
312
+ return parts.join('\n');
313
+ }
314
+
315
+ function renderCoverageSummary(model) {
316
+ const c = model.coverage;
317
+ // CC-3: derived from WRITTEN, never REQUESTED. "Not reachable" is an AFFIRMATIVE negative
318
+ // result — it tells the client a probe actually ran against that host and failed. A host that
319
+ // was requested but never even WRITTEN (report_inputs.mjs deliberately keeps `requested` /
320
+ // `written` / `reachable` apart — see its own "coverage separates unreachable from
321
+ // not-written" test) was never probed at all; the data does not support calling it "not
322
+ // reachable". That gap is already disclosed, honestly, by partialCaveat() below ("N of M
323
+ // requested hosts were not scanned") — collapsing it into this line as well double-counts the
324
+ // SAME hosts under two incompatible statuses on the same page.
325
+ const notReachable = Math.max(0, c.written - c.reachable);
326
+ const lines = [];
327
+ // c.requested/c.reachable/notReachable are computed integers (report_inputs.mjs's own
328
+ // coverage arithmetic), not scanner- or operator-supplied strings — deliberately unescaped,
329
+ // same reasoning as pluginSummary() above.
330
+ lines.push(`<p>${c.requested} hosts requested · ${c.reachable} reachable · `
331
+ + `${notReachable} not reachable.</p>`);
332
+ lines.push(`<p>${pluginSummary(model.plugins)}</p>`);
333
+ if (c.partial) {
334
+ const caveat = partialCaveat(c);
335
+ if (caveat) lines.push(`<p class="caveat">${caveat}</p>`);
336
+ }
337
+ if (c.incomplete) {
338
+ lines.push('<p class="caveat">This run did not record completion; it may be missing '
339
+ + 'results the scan had not yet written.</p>');
340
+ }
341
+ // ⚠️ THE REPORT DISCLOSES ITS OWN BLIND SPOT. If the container census found finding-like
342
+ // objects in a place no reader opens, the reader is told so HERE rather than receiving a
343
+ // report that silently omits them — which is exactly what a client received before this
344
+ // existed: "No findings were recorded in this run" over sixteen CVEs.
345
+ // When there is nothing unread this emits NOTHING, and the document is byte-identical.
346
+ const unread = Array.isArray(model.unreadContainers) ? model.unreadContainers : [];
347
+ if (unread.length) {
348
+ const parts = unread.map((u) => {
349
+ const names = Object.entries(u.unread).map(([k, n]) => `${escapeHtml(k)} (${n})`).join(', ');
350
+ return `${escapeHtml(String(u.host))}: ${names}`;
351
+ });
352
+ const total = unread.reduce((a, u) => a + Object.values(u.unread).reduce((x, y) => x + y, 0), 0);
353
+ lines.push(`<p class="caveat">⚠️ ${total} recorded finding-like object(s) live in containers `
354
+ + `this report does not read, and are NOT included above — ${parts.join(' · ')}. `
355
+ + 'Treat this report as incomplete for those surfaces.</p>');
356
+ }
357
+ if (!model.findings.length) {
358
+ lines.push(`<p>No findings were recorded for the ${c.written} hosts scanned in this run.</p>`);
359
+ }
360
+ return lines.join('\n');
361
+ }
362
+
363
+ function renderCover(model, brand, renderedAt) {
364
+ const preparedClause = brand.preparedBy ? `Prepared by ${escapeHtml(brand.preparedBy)} · ` : '';
365
+ const intro = 'This report lists the findings recorded by NSAuditor AI for the hosts and '
366
+ + 'plugins named below. It is a print-ready HTML report.';
367
+ return `<section id="cover">
368
+ <h1>${escapeHtml(brand.title || 'Network Scan Report')}</h1>
369
+ ${renderBrandBlock(brand)}
370
+ <p class="run-dates">${preparedClause}Scan of ${model.coverage.requested} hosts started `
371
+ + `${fmtDate(model.startedAt)} · Report rendered ${fmtDate(renderedAt)}.</p>
372
+ <p>${intro}</p>
373
+ ${renderCoverageSummary(model)}
374
+ ${renderKevEpss(model)}
375
+ </section>`;
376
+ }
377
+
378
+ function renderTopRisks(findings) {
379
+ // A passing check is not a risk. PASS only stayed out of this section by ranking below
380
+ // 105 real findings; on a CLEAN scan the section titled "Top Risks" would have listed
381
+ // the passing checks themselves.
382
+ const sorted = findings.filter((f) => chartSev(f.severity) !== 'PASS')
383
+ .slice().sort(compareRisk).slice(0, TOP_RISKS_LIMIT);
384
+ const rule = 'Findings below are ordered by known-exploited first, then EPSS probability, '
385
+ + 'then severity.';
386
+ if (!sorted.length) {
387
+ return `<section id="top-risks">
388
+ <h2>Top Risks</h2>
389
+ <p>${rule}</p>
390
+ <p>No findings were recorded in this run.</p>
391
+ </section>`;
392
+ }
393
+ // ⚠️ Deliberately NO data-row-severity attribute here. The chart/table agreement invariant
394
+ // is proven by having exactly ONE place in the document that carries that attribute per
395
+ // finding (the appendix, which lists every finding exactly once) — a second place that also
396
+ // carried it, over an overlapping subset, would inflate the count the invariant checks.
397
+ const rows = sorted.map((f) => `<tr>
398
+ <td class="${sevClass(f.severity)}">${escapeHtml(f.severity)}</td>
399
+ <td>${escapeHtml(f.host)}${f.port != null ? `:${escapeHtml(String(f.port))}` : ''}</td>
400
+ <td>${escapeHtml(f.title ?? untitledLabel(f))}</td>
401
+ <td>${f.kev ? 'Yes' : 'No'}</td>
402
+ <td>${escapeHtml(fmtPct(f.epss))}</td>
403
+ </tr>`).join('\n');
404
+ return `<section id="top-risks">
405
+ <h2>Top Risks</h2>
406
+ <p>${rule}</p>
407
+ <table>
408
+ <thead><tr><th>Severity</th><th>Host</th><th>Finding</th><th>Known-exploited</th><th>EPSS</th></tr></thead>
409
+ <tbody>
410
+ ${rows}
411
+ </tbody>
412
+ </table>
413
+ </section>`;
414
+ }
415
+
416
+ function renderChart(findings) {
417
+ const buckets = [...SEV_ORDER, 'OTHER'];
418
+ const counts = buckets.map((sev) => [sev, findings.filter((f) => chartSev(f.severity) === sev).length]);
419
+ const max = Math.max(1, ...counts.map(([, n]) => n));
420
+ const barHeight = 22;
421
+ const gap = 8;
422
+ const chartWidth = 320;
423
+ const leftPad = 90;
424
+ const rowsSvg = counts.map(([sev, n], i) => {
425
+ const y = i * (barHeight + gap);
426
+ const w = Math.max(1, Math.round((n / max) * chartWidth));
427
+ return `<g>
428
+ <text x="0" y="${y + barHeight - 6}" font-size="12" fill="#111111">${sev}</text>
429
+ <rect x="${leftPad}" y="${y}" width="${w}" height="${barHeight}" fill="${SEV_COLORS[sev]}" `
430
+ + `data-severity="${sev}" data-count="${n}"></rect>
431
+ <text x="${leftPad + w + 6}" y="${y + barHeight - 6}" font-size="12" fill="#111111">${n}</text>
432
+ </g>`;
433
+ }).join('\n');
434
+ const totalHeight = counts.length * (barHeight + gap);
435
+ return `<svg viewBox="0 0 ${leftPad + chartWidth + 40} ${totalHeight}" width="100%" `
436
+ + `height="${totalHeight}" role="img" aria-label="Severity distribution">
437
+ ${rowsSvg}
438
+ </svg>`;
439
+ }
440
+
441
+ function renderPluginStatus(statusList) {
442
+ if (!Array.isArray(statusList) || !statusList.length) {
443
+ return '<p>No plugin status was recorded for this host.</p>';
444
+ }
445
+ const rows = statusList.map((s) => `<tr>
446
+ <td>${escapeHtml(s?.name ?? s?.id ?? 'unknown plugin')}</td>
447
+ <td>${escapeHtml(s?.status ?? 'unknown')}</td>
448
+ <td>${escapeHtml(s?.reason ?? '')}</td>
449
+ </tr>`).join('\n');
450
+ return `<table class="plugin-status">
451
+ <thead><tr><th>Plugin</th><th>Status</th><th>Reason</th></tr></thead>
452
+ <tbody>
453
+ ${rows}
454
+ </tbody>
455
+ </table>`;
456
+ }
457
+
458
+ function renderFindingsTable(findings) {
459
+ if (!findings.length) return '<p>No findings recorded for this host.</p>';
460
+ const rows = findings.slice().sort(comparePresentation).map((f) => `<tr data-row-severity="${chartSev(f.severity)}">
461
+ <td class="${sevClass(f.severity)}">${escapeHtml(f.severity)}</td>
462
+ <td>${f.port != null ? escapeHtml(String(f.port)) : '—'}</td>
463
+ <td>${escapeHtml(f.title ?? untitledLabel(f))}</td>
464
+ <td>${renderCves(f.cves)}</td>
465
+ <td>${f.kev ? 'Yes' : 'No'}</td>
466
+ <td>${escapeHtml(fmtPct(f.epss))}</td>
467
+ <td>${escapeHtml(f.detail ?? '')}</td>
468
+ <td>${escapeHtml(f.remediation ?? '')}</td>
469
+ </tr>`).join('\n');
470
+ return `<table class="findings">
471
+ <thead><tr><th>Severity</th><th>Port</th><th>Finding</th><th>CVE</th><th>Known-exploited</th>`
472
+ + `<th>EPSS</th><th>Detail</th><th>Remediation</th></tr></thead>
473
+ <tbody>
474
+ ${rows}
475
+ </tbody>
476
+ </table>`;
477
+ }
478
+
479
+ // CC-2: `model.hosts` is one entry per scan DIRECTORY (each carrying its own already-scoped
480
+ // `findings` array — see report_inputs.mjs's buildModel), and two directories can share a host
481
+ // NAME (`--host 10.0.0.7,10.0.0.7`, a repeated --host-file line, an overlapping range — none of
482
+ // which utils/host_iterator.mjs de-duplicates). The previous version rebuilt a `byHost` map
483
+ // keyed by NAME from the flattened `model.findings`, so every same-named directory's section
484
+ // rendered ALL of that name's findings (doubling them across sections), and `pluginByHost`
485
+ // — also name-keyed — was last-write-wins, silently dropping every same-named host's plugin
486
+ // table but the final one's. The fix uses `h.findings` directly (already correctly scoped per
487
+ // directory, never re-derived) and keys the plugin-status lookup by `dir`, which is unique per
488
+ // scan attempt by construction (`path.basename(outDir)`, timestamped — cli.mjs).
489
+ function renderAppendix(model) {
490
+ const pluginByDir = new Map((model.plugins?.byHost ?? []).map((h) => [h.dir, h.status]));
491
+
492
+ const sections = (model.hosts ?? []).map((h) => {
493
+ const findings = h.findings ?? [];
494
+ const reach = h.up ? 'reachable' : 'not reachable';
495
+ return `<section class="host">
496
+ <h3>${escapeHtml(h.host)} — ${reach}</h3>
497
+ <p class="dir">Evidence directory: ${escapeHtml(h.dir ?? '')}</p>
498
+ ${renderPluginStatus(pluginByDir.get(h.dir))}
499
+ ${renderFindingsTable(findings)}
500
+ </section>`;
501
+ }).join('\n');
502
+
503
+ return `<section id="appendix">
504
+ <h2>Appendix — per-host detail</h2>
505
+ ${sections}
506
+ </section>`;
507
+ }
508
+
509
+ const CSS = `
510
+ :root { color-scheme: light; }
511
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
512
+ color: #111111; background: #ffffff; margin: 0; padding: 24px 32px 48px; }
513
+ h1 { font-size: 26px; margin: 0 0 12px; }
514
+ h2 { font-size: 20px; margin: 32px 0 12px; border-bottom: 2px solid #333333; padding-bottom: 6px; }
515
+ h3 { font-size: 16px; margin: 24px 0 6px; }
516
+ p { line-height: 1.5; margin: 6px 0; }
517
+ .caveat { color: #7a1220; font-weight: bold; }
518
+ .brand-logo { max-height: 64px; margin: 8px 0; }
519
+ table { border-collapse: collapse; width: 100%; margin: 8px 0 16px; font-size: 13px; }
520
+ th, td { border: 1px solid #cccccc; padding: 6px 8px; text-align: left; vertical-align: top; }
521
+ th { background: #f0f0f0; }
522
+ .sev-critical { color: #7a1220; font-weight: bold; }
523
+ .sev-high { color: #b3261e; font-weight: bold; }
524
+ .sev-medium { color: #b3690a; font-weight: bold; }
525
+ .sev-low { color: #8a7500; }
526
+ .sev-info { color: #3b5568; }
527
+ .sev-other { color: #5a5a5a; }
528
+ footer { margin-top: 32px; padding-top: 12px; border-top: 1px solid #cccccc; font-size: 12px; color: #555555; }
529
+ @media print { body { padding: 0; } }
530
+ `;
531
+
532
+ /**
533
+ * Render one self-contained executive HTML report.
534
+ * @param {object} model Task 4's normalised run model (loadRun()'s .model).
535
+ * @param {object} brand Task 5's brand ({title, companyName, preparedBy, contact, logoDataUri}).
536
+ * @param {{renderedAt?: Date}} [opts]
537
+ * @returns {string} one complete HTML document.
538
+ */
539
+ // The footer names the CE version ALWAYS — CE rendered this document, regardless of tier — and
540
+ // APPENDS the EE version when the record carries one, because EE's enrichment (KEV/EPSS) shaped
541
+ // the findings above. Both come from the RECORD (`ceVersion` / `eeVersion`), never from the
542
+ // renderer's own environment, and never from `model.tier`: a version claim derived from a tier
543
+ // is derived from a proxy (the same ruling that put KEV/EPSS coverage on `.loaded`, never on
544
+ // tier, in R32/R33 of this cycle) — the record is the fact, the tier is not.
545
+ function renderFooter(model) {
546
+ const tier = model.tier ?? 'ce';
547
+ const ceVersion = model.ceVersion ?? '0.0.0';
548
+ const eeSuffix = model.eeVersion ? ` + EE ${escapeHtml(String(model.eeVersion))}` : '';
549
+ return `<footer>Generated by NSAuditor AI ${escapeHtml(tier)} ${escapeHtml(String(ceVersion))}${eeSuffix}</footer>`;
550
+ }
551
+
552
+ export function renderExecutiveReport(model, brand, { renderedAt = new Date() } = {}) {
553
+ const title = escapeHtml(brand.title || 'Network Scan Report');
554
+
555
+ return `<!DOCTYPE html>
556
+ <html lang="en">
557
+ <head>
558
+ <meta charset="utf-8">
559
+ <meta name="viewport" content="width=device-width, initial-scale=1">
560
+ <title>${title}</title>
561
+ <style>${CSS}</style>
562
+ </head>
563
+ <body>
564
+ ${renderCover(model, brand, renderedAt)}
565
+ ${renderTopRisks(model.findings)}
566
+ <section id="chart">
567
+ <h2>Severity Distribution</h2>
568
+ ${renderChart(model.findings)}
569
+ </section>
570
+ ${renderAppendix(model)}
571
+ ${renderFooter(model)}
572
+ </body>
573
+ </html>`;
574
+ }