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.
- package/README.md +112 -4
- package/cli.mjs +443 -7
- package/package.json +1 -1
- package/utils/brand.mjs +252 -0
- package/utils/capabilities.mjs +1 -0
- package/utils/executive_report.mjs +637 -0
- package/utils/jira_export.mjs +174 -0
- package/utils/report_finding_census.mjs +438 -0
- package/utils/report_inputs.mjs +544 -0
- package/utils/run_record.mjs +241 -0
- package/utils/scan_history.mjs +1 -1
|
@@ -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
|
+
}
|