nsauditor-ai 0.2.50 → 0.2.52

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,637 @@
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
+ // ⚠️ TWO REASONS, TWO SENTENCES — and conflating them put a FALSE QUANTITY in front of a
354
+ // client. A container nobody reads is genuinely absent from this document. A carve-out whose
355
+ // PREMISE BROKE is different: its objects ARE rendered above except for the break, so saying
356
+ // they "are NOT included above" contradicts the body of the same report. Plugin 1150 emits
357
+ // `data: findings`, so one orphan among sixteen mirrors used to announce that seventeen
358
+ // objects were missing from a report containing sixteen of them.
359
+ const broken = unread.some((u) => Object.keys(u.premiseFailures ?? {}).length);
360
+ if (broken) {
361
+ const why = unread.flatMap((u) => Object.values(u.premiseFailures ?? {}));
362
+ lines.push(`<p class="caveat">⚠️ ${total} recorded finding-like object(s) are NOT accounted `
363
+ + `for by this report — ${parts.join(' · ')}. Some containers are not read at all; others `
364
+ + 'are summaries whose link to a rendered finding could not be re-derived on this run, so '
365
+ + 'their objects may appear above under a different heading. '
366
+ + `${escapeHtml(why.join(' · '))}</p>`);
367
+ } else {
368
+ lines.push(`<p class="caveat">⚠️ ${total} recorded finding-like object(s) live in containers `
369
+ + `this report does not read, and are NOT included above — ${parts.join(' · ')}. `
370
+ + 'Treat this report as incomplete for those surfaces.</p>');
371
+ }
372
+ }
373
+ if (!model.findings.length) {
374
+ lines.push(`<p>No findings were recorded for the ${c.written} hosts scanned in this run.</p>`);
375
+ }
376
+ return lines.join('\n');
377
+ }
378
+
379
+ function renderCover(model, brand, renderedAt) {
380
+ const preparedClause = brand.preparedBy ? `Prepared by ${escapeHtml(brand.preparedBy)} · ` : '';
381
+ const intro = 'This report lists the findings recorded by NSAuditor AI for the hosts and '
382
+ + 'plugins named below. It is a print-ready HTML report.';
383
+ return `<section id="cover">
384
+ <h1>${escapeHtml(brand.title || 'Network Scan Report')}</h1>
385
+ ${renderBrandBlock(brand)}
386
+ <p class="run-dates">${preparedClause}Scan of ${model.coverage.requested} hosts started `
387
+ + `${fmtDate(model.startedAt)} · Report rendered ${fmtDate(renderedAt)}.</p>
388
+ <p>${intro}</p>
389
+ ${renderCoverageSummary(model)}
390
+ ${renderKevEpss(model)}
391
+ </section>`;
392
+ }
393
+
394
+ function renderTopRisks(findings) {
395
+ // A passing check is not a risk. PASS only stayed out of this section by ranking below
396
+ // 105 real findings; on a CLEAN scan the section titled "Top Risks" would have listed
397
+ // the passing checks themselves.
398
+ const sorted = findings.filter((f) => chartSev(f.severity) !== 'PASS')
399
+ .slice().sort(compareRisk).slice(0, TOP_RISKS_LIMIT);
400
+ const rule = 'Findings below are ordered by known-exploited first, then EPSS probability, '
401
+ + 'then severity.';
402
+ if (!sorted.length) {
403
+ return `<section id="top-risks">
404
+ <h2>Top Risks</h2>
405
+ <p>${rule}</p>
406
+ <p>No findings were recorded in this run.</p>
407
+ </section>`;
408
+ }
409
+ // ⚠️ Deliberately NO data-row-severity attribute here. The chart/table agreement invariant
410
+ // is proven by having exactly ONE place in the document that carries that attribute per
411
+ // finding (the appendix, which lists every finding exactly once) — a second place that also
412
+ // carried it, over an overlapping subset, would inflate the count the invariant checks.
413
+ const rows = sorted.map((f) => `<tr>
414
+ <td class="${sevClass(f.severity)}">${escapeHtml(f.severity)}</td>
415
+ <td>${escapeHtml(f.host)}${f.port != null ? `:${escapeHtml(String(f.port))}` : ''}</td>
416
+ <td>${escapeHtml(f.title ?? untitledLabel(f))}</td>
417
+ <td>${f.kev ? 'Yes' : 'No'}</td>
418
+ <td>${escapeHtml(fmtPct(f.epss))}</td>
419
+ </tr>`).join('\n');
420
+ return `<section id="top-risks">
421
+ <h2>Top Risks</h2>
422
+ <p>${rule}</p>
423
+ <table>
424
+ <thead><tr><th>Severity</th><th>Host</th><th>Finding</th><th>Known-exploited</th><th>EPSS</th></tr></thead>
425
+ <tbody>
426
+ ${rows}
427
+ </tbody>
428
+ </table>
429
+ </section>`;
430
+ }
431
+
432
+ function renderChart(findings) {
433
+ const buckets = [...SEV_ORDER, 'OTHER'];
434
+ const counts = buckets.map((sev) => [sev, findings.filter((f) => chartSev(f.severity) === sev).length]);
435
+ const max = Math.max(1, ...counts.map(([, n]) => n));
436
+ const barHeight = 22;
437
+ const gap = 8;
438
+ const chartWidth = 320;
439
+ const leftPad = 90;
440
+ const rowsSvg = counts.map(([sev, n], i) => {
441
+ const y = i * (barHeight + gap);
442
+ const w = Math.max(1, Math.round((n / max) * chartWidth));
443
+ return `<g>
444
+ <text x="0" y="${y + barHeight - 6}" font-size="12" fill="#111111">${sev}</text>
445
+ <rect x="${leftPad}" y="${y}" width="${w}" height="${barHeight}" fill="${SEV_COLORS[sev]}" `
446
+ + `data-severity="${sev}" data-count="${n}"></rect>
447
+ <text x="${leftPad + w + 6}" y="${y + barHeight - 6}" font-size="12" fill="#111111">${n}</text>
448
+ </g>`;
449
+ }).join('\n');
450
+ const totalHeight = counts.length * (barHeight + gap);
451
+ return `<svg viewBox="0 0 ${leftPad + chartWidth + 40} ${totalHeight}" width="100%" `
452
+ + `height="${totalHeight}" role="img" aria-label="Severity distribution">
453
+ ${rowsSvg}
454
+ </svg>`;
455
+ }
456
+
457
+ function renderPluginStatus(statusList) {
458
+ if (!Array.isArray(statusList) || !statusList.length) {
459
+ return '<p>No plugin status was recorded for this host.</p>';
460
+ }
461
+ const rows = statusList.map((s) => `<tr>
462
+ <td>${escapeHtml(s?.name ?? s?.id ?? 'unknown plugin')}</td>
463
+ <td>${escapeHtml(s?.status ?? 'unknown')}</td>
464
+ <td>${escapeHtml(s?.reason ?? '')}</td>
465
+ </tr>`).join('\n');
466
+ return `<table class="plugin-status">
467
+ <thead><tr><th>Plugin</th><th>Status</th><th>Reason</th></tr></thead>
468
+ <tbody>
469
+ ${rows}
470
+ </tbody>
471
+ </table>`;
472
+ }
473
+
474
+ function renderFindingsTable(findings) {
475
+ if (!findings.length) return '<p>No findings recorded for this host.</p>';
476
+ const rows = findings.slice().sort(comparePresentation).map((f) => `<tr data-row-severity="${chartSev(f.severity)}">
477
+ <td class="${sevClass(f.severity)}">${escapeHtml(f.severity)}</td>
478
+ <td>${f.port != null ? escapeHtml(String(f.port)) : '—'}</td>
479
+ <td>${escapeHtml(f.title ?? untitledLabel(f))}</td>
480
+ <td>${renderCves(f.cves)}</td>
481
+ <td>${f.kev ? 'Yes' : 'No'}</td>
482
+ <td>${escapeHtml(fmtPct(f.epss))}</td>
483
+ <td>${escapeHtml(f.detail ?? '')}</td>
484
+ <td>${escapeHtml(f.remediation ?? '')}</td>
485
+ </tr>`).join('\n');
486
+ return `<table class="findings">
487
+ <thead><tr><th>Severity</th><th>Port</th><th>Finding</th><th>CVE</th><th>Known-exploited</th>`
488
+ + `<th>EPSS</th><th>Detail</th><th>Remediation</th></tr></thead>
489
+ <tbody>
490
+ ${rows}
491
+ </tbody>
492
+ </table>`;
493
+ }
494
+
495
+ // CC-2: `model.hosts` is one entry per scan DIRECTORY (each carrying its own already-scoped
496
+ // `findings` array — see report_inputs.mjs's buildModel), and two directories can share a host
497
+ // NAME (`--host 10.0.0.7,10.0.0.7`, a repeated --host-file line, an overlapping range — none of
498
+ // which utils/host_iterator.mjs de-duplicates). The previous version rebuilt a `byHost` map
499
+ // keyed by NAME from the flattened `model.findings`, so every same-named directory's section
500
+ // rendered ALL of that name's findings (doubling them across sections), and `pluginByHost`
501
+ // — also name-keyed — was last-write-wins, silently dropping every same-named host's plugin
502
+ // table but the final one's. The fix uses `h.findings` directly (already correctly scoped per
503
+ // directory, never re-derived) and keys the plugin-status lookup by `dir`, which is unique per
504
+ // scan attempt by construction (`path.basename(outDir)`, timestamped — cli.mjs).
505
+ function renderAppendix(model) {
506
+ const pluginByDir = new Map((model.plugins?.byHost ?? []).map((h) => [h.dir, h.status]));
507
+
508
+ const sections = (model.hosts ?? []).map((h) => {
509
+ const findings = h.findings ?? [];
510
+ const reach = h.up ? 'reachable' : 'not reachable';
511
+ return `<section class="host">
512
+ <h3>${escapeHtml(h.host)} — ${reach}</h3>
513
+ <p class="dir">Evidence directory: ${escapeHtml(h.dir ?? '')}</p>
514
+ ${renderPluginStatus(pluginByDir.get(h.dir))}
515
+ ${renderFindingsTable(findings)}
516
+ </section>`;
517
+ }).join('\n');
518
+
519
+ return `<section id="appendix">
520
+ <h2>Appendix — per-host detail</h2>
521
+ ${sections}
522
+ </section>`;
523
+ }
524
+
525
+ const CSS = `
526
+ :root { color-scheme: light; }
527
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
528
+ color: #111111; background: #ffffff; margin: 0; padding: 24px 32px 48px; }
529
+ h1 { font-size: 26px; margin: 0 0 12px; }
530
+ h2 { font-size: 20px; margin: 32px 0 12px; border-bottom: 2px solid #333333; padding-bottom: 6px; }
531
+ h3 { font-size: 16px; margin: 24px 0 6px; }
532
+ p { line-height: 1.5; margin: 6px 0; }
533
+ .caveat { color: #7a1220; font-weight: bold; }
534
+ .brand-logo { max-height: 64px; margin: 8px 0; }
535
+ table { border-collapse: collapse; width: 100%; margin: 8px 0 16px; font-size: 13px; }
536
+ th, td { border: 1px solid #cccccc; padding: 6px 8px; text-align: left; vertical-align: top; }
537
+ th { background: #f0f0f0; }
538
+ .sev-critical { color: #7a1220; font-weight: bold; }
539
+ .sev-high { color: #b3261e; font-weight: bold; }
540
+ .sev-medium { color: #b3690a; font-weight: bold; }
541
+ .sev-low { color: #8a7500; }
542
+ .sev-info { color: #3b5568; }
543
+ .sev-other { color: #5a5a5a; }
544
+ footer { margin-top: 32px; padding-top: 12px; border-top: 1px solid #cccccc; font-size: 12px; color: #555555; }
545
+ @media print { body { padding: 0; } }
546
+ `;
547
+
548
+ /**
549
+ * Render one self-contained executive HTML report.
550
+ * @param {object} model Task 4's normalised run model (loadRun()'s .model).
551
+ * @param {object} brand Task 5's brand ({title, companyName, preparedBy, contact, logoDataUri}).
552
+ * @param {{renderedAt?: Date}} [opts]
553
+ * @returns {string} one complete HTML document.
554
+ */
555
+ // The footer names the CE version ALWAYS — CE rendered this document, regardless of tier — and
556
+ // APPENDS the EE version when the record carries one, because EE's enrichment (KEV/EPSS) shaped
557
+ // the findings above. Both come from the RECORD (`ceVersion` / `eeVersion`), never from the
558
+ // renderer's own environment, and never from `model.tier`: a version claim derived from a tier
559
+ // is derived from a proxy (the same ruling that put KEV/EPSS coverage on `.loaded`, never on
560
+ // tier, in R32/R33 of this cycle) — the record is the fact, the tier is not.
561
+ function renderFooter(model) {
562
+ const tier = model.tier ?? 'ce';
563
+ const ceVersion = model.ceVersion ?? '0.0.0';
564
+ const eeSuffix = model.eeVersion ? ` + EE ${escapeHtml(String(model.eeVersion))}` : '';
565
+ return `<footer>Generated by NSAuditor AI ${escapeHtml(tier)} ${escapeHtml(String(ceVersion))}${eeSuffix}</footer>`;
566
+ }
567
+
568
+ // Names what `brand` actually was, for the refusal below — never called on a plain object.
569
+ // Same voice and job as brand.mjs's own `describeLogoPathType`: a caller told only "must be an
570
+ // object" over a value it cannot see has to open this module to learn what it did wrong.
571
+ function describeBrand(v) {
572
+ if (v === null) return 'null';
573
+ if (v === undefined) return 'undefined';
574
+ if (Array.isArray(v)) return 'an array';
575
+ return `a ${typeof v}`;
576
+ }
577
+
578
+ // ⚠️ REFUSES rather than defaulting, and the choice is the opposite of what "make it total"
579
+ // usually means: this renders a CLIENT-FACING deliverable, so silently normalising an absent
580
+ // brand into an unbranded report is the WORSE failure — it hands the caller a complete,
581
+ // plausible document at exit 0 with the consultant's identity missing, and that is the copy
582
+ // that reaches the client.
583
+ //
584
+ // That is the CC-4 ruling one layer down. `tests/report_command.test.mjs` already pins "a
585
+ // value-less --brand is FATAL, not a silent unbranded default", recorded there because a shell
586
+ // glob that swallowed the path "produced the IDENTICAL unbranded default report, at exit 0, as
587
+ // never asking for a brand at all — silently". An absent argument here is indistinguishable
588
+ // from a lost one for exactly the same reason, and refusing costs the honest caller nothing:
589
+ // "no branding wanted" stays expressible as `{}`, which renders precisely what it rendered
590
+ // before (measured: byte-identical to loadBrand(null)'s defaults, 4579 bytes on the fixture).
591
+ //
592
+ // WHAT WAS MEASURED, over eight brand shapes, before this guard existed: null / undefined / an
593
+ // omitted second argument all threw `TypeError: Cannot read properties of null (reading
594
+ // 'title')` — the loud half. A string, a number, a boolean and an array all RENDERED, 4579
595
+ // bytes, titled "Network Scan Report", with no company line — the quiet half, and the one a
596
+ // caller passing its company NAME where the brand OBJECT belongs would have shipped.
597
+ //
598
+ // A DEFAULTED PARAMETER (`brand = {}`) was rejected: it fires only on `undefined`, so it fixes
599
+ // the omitted-argument call and leaves an explicit `null` — the shape a caller that lost its
600
+ // brand actually produces — crashing exactly as before. The guard is a normalisation check at
601
+ // the single public entry point instead. `renderCover` and `renderBrandBlock` dereference
602
+ // `brand` freely — DERIVED, not counted by hand: 11 dereferences in this module, 7 in
603
+ // renderBrandBlock + 3 in renderCover + this one — and are reachable ONLY through here; that
604
+ // premise is not
605
+ // left to a reading — `tests/executive_report_brand_absent.test.mjs` pins this module's export
606
+ // surface, so exporting either helper fails the build rather than silently opening an
607
+ // unguarded path.
608
+ export function renderExecutiveReport(model, brand, { renderedAt = new Date() } = {}) {
609
+ if (brand === null || typeof brand !== 'object' || Array.isArray(brand)) {
610
+ throw new TypeError(
611
+ 'renderExecutiveReport: `brand` must be an object — pass the `brand` field of '
612
+ + "loadBrand()'s result, or `{}` for a deliberately unbranded report. It is "
613
+ + `${describeBrand(brand)}. Refusing rather than rendering a client-facing report with `
614
+ + 'the branding silently missing.');
615
+ }
616
+ const title = escapeHtml(brand.title || 'Network Scan Report');
617
+
618
+ return `<!DOCTYPE html>
619
+ <html lang="en">
620
+ <head>
621
+ <meta charset="utf-8">
622
+ <meta name="viewport" content="width=device-width, initial-scale=1">
623
+ <title>${title}</title>
624
+ <style>${CSS}</style>
625
+ </head>
626
+ <body>
627
+ ${renderCover(model, brand, renderedAt)}
628
+ ${renderTopRisks(model.findings)}
629
+ <section id="chart">
630
+ <h2>Severity Distribution</h2>
631
+ ${renderChart(model.findings)}
632
+ </section>
633
+ ${renderAppendix(model)}
634
+ ${renderFooter(model)}
635
+ </body>
636
+ </html>`;
637
+ }