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,174 @@
1
+ // utils/jira_export.mjs — the Jira-importer CSV export.
2
+ //
3
+ // CSV using the column names of Jira's CSV importer (Summary, Description, Priority, Labels,
4
+ // External ID); the import mapping is done in Jira and has not been verified against a live
5
+ // Jira instance.
6
+ //
7
+ // ⚠️ NOTHING HERE MAY CLAIM A WORKING OR VERIFIED JIRA INTEGRATION. `External ID` is stable per
8
+ // finding so that an importer WHICH KEYS ON IT can update rather than duplicate — that is a
9
+ // statement of intent, unverified against Jira. Earn-back is a Gate-3-shaped operator check:
10
+ // import the sample CSV into a free Jira Cloud project once and record PASS with evidence.
11
+ //
12
+ // ⚠️ The formula-injection apostrophe below is visible in the RAW file, not only in a
13
+ // spreadsheet: a title/detail/remediation beginning with `=`, `+`, `-` or `@` carries a
14
+ // prefixed `'` so the cell is inert when opened as a spreadsheet, and that same literal leading
15
+ // apostrophe is exactly what a consultant sees reading the CSV directly, e.g. in a text editor.
16
+ //
17
+ // Consumes Task 4's normalised model (utils/report_inputs.mjs's `loadRun()` — the same object
18
+ // the executive HTML report renders), specifically `model.startedAt` (never a render-time
19
+ // timestamp — the row is a record of what the SCAN found, not of when this file was produced)
20
+ // and `model.findings` (the flat array; each entry's shape is `shapeFinding()`'s: host, port,
21
+ // severity, title, detail, remediation, cves, kev, epss — every one of which can originate in a
22
+ // scanned target's banner text or a hostile `--host-file` and must be treated as untrusted).
23
+ //
24
+ // A CSV is opened in a spreadsheet or an importer, not a browser, so the risk shape here is
25
+ // RFC4180 structural integrity (a stray `,`/`"`/CR/LF must never let one finding's cell bleed
26
+ // into the next row or column) and spreadsheet FORMULA INJECTION (a cell beginning with
27
+ // `=`/`+`/`-`/`@` is executed as a formula by Excel/Sheets/LibreOffice the moment the file is
28
+ // opened) — never HTML/script injection, which does not apply to this output format.
29
+
30
+ import crypto from 'node:crypto';
31
+
32
+ export const JIRA_COLUMNS = ['Summary', 'Description', 'Priority', 'Labels', 'External ID'];
33
+
34
+ // Jira priorities are site-configurable — this mapping is a CLAIM, and an unstated one is a
35
+ // claim nobody can check. Pinned by test; do not change without updating the pinned test.
36
+ export const PRIORITY_BY_SEVERITY = {
37
+ CRITICAL: 'Highest', HIGH: 'High', MEDIUM: 'Medium', LOW: 'Low', INFO: 'Lowest',
38
+ };
39
+
40
+ // A severity this repo's own `shapeFinding()` never emits (it always uppercases and defaults to
41
+ // 'INFO') should not be reachable from a well-formed model — but this module does not control
42
+ // its caller, so a null/undefined/unrecognised severity falls back to a stated safe default
43
+ // rather than writing the literal string "undefined" into a Jira Priority cell.
44
+ const DEFAULT_PRIORITY = 'Medium';
45
+
46
+ // ── RFC4180 quoting + formula-injection neutralisation ──────────────────────────────────────
47
+ //
48
+ // A field is wrapped in `"` (doubling any embedded `"`) when it contains a comma, a double
49
+ // quote, or either newline convention (`\n` alone, `\r` alone, or `\r\n`) — a LONE `\r` must be
50
+ // quoted too, precisely because it is not the row's own separator (rows are joined with `\n`
51
+ // only, below) and an unquoted one would otherwise sit mid-row indistinguishable from ordinary
52
+ // content only by luck.
53
+ //
54
+ // Before that: a cell whose first character is `=`, `+`, `-` or `@` is treated as a formula by
55
+ // Excel, Google Sheets and LibreOffice when the file is opened directly — a well-known CSV
56
+ // hazard, and a real one here, because a finding's title/detail/remediation is scan output from
57
+ // someone else's infrastructure, not text this product authored. A leading `'` forces text
58
+ // interpretation in all three without altering the visible value once opened.
59
+ function csvField(value) {
60
+ let str = value == null ? '' : String(value);
61
+ if (/^[=+\-@]/.test(str)) str = `'${str}`;
62
+ if (/[",\n\r]/.test(str)) str = `"${str.replace(/"/g, '""')}"`;
63
+ return str;
64
+ }
65
+
66
+ function csvRow(fields) {
67
+ return fields.map(csvField).join(',');
68
+ }
69
+
70
+ // ── External ID ──────────────────────────────────────────────────────────────────────────────
71
+ //
72
+ // Stable (never a counter, never Date.now(), never Math.random()) and unique within one CSV.
73
+ // `host + port + title` alone COLLIDES for two findings reported under one title on one port —
74
+ // e.g. two distinct weak-cipher findings on the same host:port — and an importer keyed on
75
+ // External ID would then treat the second as an UPDATE of the first, silently dropping one
76
+ // finding from the client's board. `detail` is folded into the key for exactly that reason.
77
+ //
78
+ // Two findings can still be identical in all four fields (a genuine duplicate, or a scan that
79
+ // legitimately reports the same condition twice). Those are disambiguated by their ORDINAL
80
+ // among equal keys, not by anything that varies between renders of the SAME model — the
81
+ // ordinal is a pure function of the (model-determined) order of `model.findings`, so re-running
82
+ // this over the same model reproduces the same ids in the same order every time.
83
+ function externalIds(findings) {
84
+ const seen = new Map();
85
+ return findings.map((f) => {
86
+ const key = JSON.stringify([keyPart(f.host), keyPart(f.port), keyPart(f.title), keyPart(f.detail)]);
87
+ const ordinal = seen.get(key) ?? 0;
88
+ seen.set(key, ordinal + 1);
89
+ const material = ordinal === 0 ? key : `${key} ${ordinal}`;
90
+ return crypto.createHash('sha256').update(material).digest('hex').slice(0, 16);
91
+ });
92
+ }
93
+
94
+ function keyPart(v) {
95
+ return v == null ? '' : String(v);
96
+ }
97
+
98
+ // ── Labels ───────────────────────────────────────────────────────────────────────────────────
99
+ //
100
+ // Jira's CSV importer takes multiple label VALUES in one cell space-separated (there is only
101
+ // one `Labels` column in JIRA_COLUMNS, never one column per label) — so each token here is
102
+ // sanitised to contain no whitespace of its own, or hostile data (e.g. a CVE-shaped string that
103
+ // happens to contain a space) would silently fragment into more Jira labels than the scan
104
+ // actually reported.
105
+ function labelToken(raw) {
106
+ return String(raw).trim().toLowerCase().replace(/\s+/g, '-');
107
+ }
108
+
109
+ function buildLabels(f) {
110
+ const labels = ['nsauditor'];
111
+ const severity = keyPart(f.severity);
112
+ if (severity) labels.push(labelToken(`severity-${severity}`));
113
+ if (f.kev) labels.push('kev');
114
+ for (const cve of Array.isArray(f.cves) ? f.cves : []) {
115
+ const token = labelToken(cve);
116
+ if (token) labels.push(token);
117
+ }
118
+ return labels.join(' ');
119
+ }
120
+
121
+ // ── Description ──────────────────────────────────────────────────────────────────────────────
122
+ //
123
+ // Carries the SCAN's own timestamp (`model.startedAt`) and the finding's host — never a render
124
+ // date, so re-running this exporter next week over an old run does not make the ticket look
125
+ // like it was just found.
126
+ //
127
+ // Sections are joined with ` | `, never `\n` — a raw newline is what `csvField` quotes FOR, not
128
+ // something this module should manufacture on its own out of otherwise single-line content. The
129
+ // only way this field carries an embedded newline is a genuine one already present in the
130
+ // finding's own `detail`/`remediation` (untrusted scan output), preserved as-is and quoted per
131
+ // RFC4180 — never introduced by this module's own formatting.
132
+ function buildDescription(model, f) {
133
+ const where = f.port != null ? `${keyPart(f.host)}:${f.port}` : keyPart(f.host);
134
+ const parts = [`Host: ${where}`, `Scan started: ${keyPart(model?.startedAt)}`];
135
+ if (f.detail) parts.push(String(f.detail));
136
+ if (f.remediation) parts.push(`Remediation: ${f.remediation}`);
137
+ const cves = Array.isArray(f.cves) ? f.cves.filter(Boolean) : [];
138
+ if (cves.length) parts.push(`CVEs: ${cves.join(', ')}`);
139
+ if (f.kev) parts.push('Listed in the CISA Known Exploited Vulnerabilities (KEV) catalog.');
140
+ if (typeof f.epss === 'number' && Number.isFinite(f.epss)) parts.push(`EPSS: ${f.epss}`);
141
+ return parts.join(' | ');
142
+ }
143
+
144
+ function priorityFor(severity) {
145
+ const key = keyPart(severity).toUpperCase();
146
+ return PRIORITY_BY_SEVERITY[key] ?? DEFAULT_PRIORITY;
147
+ }
148
+
149
+ /**
150
+ * Render Task 4's normalised run model as a Jira-CSV-importer-shaped CSV.
151
+ * @param {object} model - the object `utils/report_inputs.mjs`'s `loadRun()` resolves to.
152
+ * @returns {string}
153
+ */
154
+ export function renderJiraCsv(model) {
155
+ // A Jira issue is a WORK ITEM, and a passing check is not work. Before this filter a
156
+ // real AWS run emitted all 18 PASS records as issues — 17 of them carrying substrate
157
+ // prose a consultant would have had to close by hand, and one contentless clean-user
158
+ // record that imported as an issue with no Summary at all.
159
+ //
160
+ // ⚠️ THE FILTER IS DELIBERATELY NARROW — `pass` ONLY. Every other tier, INFO included,
161
+ // stays: INFO carries the evidence gaps and the scope boundaries, which are exactly the
162
+ // material a reader needs to tell an incomplete scan from a clean one.
163
+ const all = Array.isArray(model?.findings) ? model.findings : [];
164
+ const findings = all.filter((f) => String(f?.severity ?? '').toUpperCase() !== 'PASS');
165
+ const ids = externalIds(findings);
166
+ const rows = findings.map((f, i) => csvRow([
167
+ f.title ?? '',
168
+ buildDescription(model, f),
169
+ priorityFor(f.severity),
170
+ buildLabels(f),
171
+ ids[i],
172
+ ]));
173
+ return [JIRA_COLUMNS.join(','), ...rows].join('\n') + '\n';
174
+ }
@@ -0,0 +1,438 @@
1
+ // utils/report_finding_census.mjs
2
+ //
3
+ // WHICH CONTAINERS IN A SCAN RECORD CAN HOLD A FINDING, AND DOES THE REPORT READ THEM?
4
+ //
5
+ // Built after Gate 3-B drove the `report` subcommand over a real 192.168.1.1 run and the
6
+ // client deliverable said, over sixteen known CVEs:
7
+ //
8
+ // "No findings were recorded in this run."
9
+ // CRITICAL 0 · HIGH 0 · MEDIUM 0 · LOW 0 · INFO 0 · PASS 0 · OTHER 0
10
+ //
11
+ // `shapeHost` read exactly one container — `result.findings[]` as an ARRAY — and on a
12
+ // network host that container is EMPTY. Measured on that run: 47 severity-bearing rows
13
+ // live in four other places, and none of them was opened.
14
+ //
15
+ // ⚠️ WHY THIS IS A CENSUS AND NOT FIVE MORE READERS. The defect was never "we forgot the
16
+ // queue". It was that nothing in the product could answer "is there a finding we are not
17
+ // rendering?" — so each door had to be found by a human reading raw JSON. A census keyed
18
+ // on CONTAINERS answers that question by construction: every container carrying a
19
+ // severity-bearing object is either READ or explicitly ALLOWLISTED with a written reason,
20
+ // and one that is NEITHER fails loudly. Incompleteness costs NOISE, never SILENCE — the
21
+ // inverse of the shape that produced the false clean.
22
+ //
23
+ // ⚠️ AND THE FIXTURE LESSON THAT MADE IT NECESSARY: the fleet fixture written one round
24
+ // earlier proves `shapeFinding` over every finding SHAPE, and it could not see this,
25
+ // because the network path's failure is a SOURCE the consumer never opens. A fixture
26
+ // drawn from a producer's field names cannot catch a producer whose output lives in a
27
+ // different artifact.
28
+ //
29
+ // ⚠️ AND THEN THE ALLOWLIST BECAME THE NEXT INSTANCE OF THE SAME CLASS. Its entries were
30
+ // PROSE, and two of them asserted a FACT about one run rather than a property of a shape —
31
+ // the cisAlarmCoverage entry closed with its own instruction, "re-derive, do not re-assert",
32
+ // and NOTHING RE-DERIVED IT. A carve-out that outlives the fact justifying it excludes real
33
+ // findings from a client deliverable while the census still prints `unread: {}`: a false
34
+ // clean wearing the exit code of a measurement. So an entry is now `{reason, verify}`, and a
35
+ // premise that FAILS or THROWS withdraws the carve-out — the container is counted UNREAD and
36
+ // reported in `premiseFailures` with the detail, because "nobody wrote a reader" and "the
37
+ // reason we wrote no reader stopped being true" need different repairs.
38
+
39
+ /** Containers the report READS. Adding a reader means adding its key here. */
40
+ export const READ_CONTAINERS = Object.freeze([
41
+ 'result.findings[]', // array-shaped plugin findings (the cloud shape)
42
+ 'result.findings.<category>[]', // dict-of-categories (060 DNS Security Auditor)
43
+ 'result.zeroTrust.<dim>.findings[]', // 1023 Zero Trust Assessment
44
+ 'result.portResults[].issues[]', // 040 TLS Certificate & Cipher Auditor
45
+ 'findingQueue[]', // the EE finding queue (intelligence_engine, crypto_agent)
46
+ ]);
47
+
48
+ /**
49
+ * THE CENSUS'S SUBJECT, in one place, because a verifier that vouches for a different set of
50
+ * objects than the census counted is vouching for nothing. Both the walk below and every
51
+ * `verify` use this predicate: it is the DEFINITION of what is being carved out, not a
52
+ * second opinion about it.
53
+ */
54
+ export function isCensusSubject(node) {
55
+ return !!node && typeof node === 'object' && !Array.isArray(node) && typeof node.severity === 'string';
56
+ }
57
+
58
+ const resultsOf = (raw) => (Array.isArray(raw?.results) ? raw.results : []);
59
+ const arrayAt = (o, k) => (Array.isArray(o?.[k]) ? o[k] : []);
60
+
61
+ /** Key-sorted serialisation, so two rows compare by CONTENT and not by key order. */
62
+ function canonical(v) {
63
+ if (Array.isArray(v)) return `[${v.map(canonical).join(',')}]`;
64
+ if (v && typeof v === 'object') {
65
+ return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canonical(v[k])}`).join(',')}}`;
66
+ }
67
+ return JSON.stringify(v ?? null);
68
+ }
69
+
70
+ // ── The premises, one per carve-out ──────────────────────────────────────────
71
+
72
+ /**
73
+ * (i) `result.summary.cisAlarmCoverage.uncovered[]` — the mirror is still 1:1.
74
+ *
75
+ * Derived from the real records, never invented: an uncovered entry is
76
+ * `{id:'cis-3.1', title, severity, coverageMode}` and its paired rendered finding carries
77
+ * BOTH `details.cisId` (with `details.category` = 'cis-alarm-coverage-no-filter') AND an
78
+ * `issues[]` string reading "CloudWatch alarm missing: … (CIS AWS Foundations Benchmark
79
+ * cis-3.1)". Measured over the 152 sample records: 48 plugin results carry an alarm-coverage
80
+ * summary, 624 uncovered entries in total, and BOTH channels independently pair 100% of them.
81
+ * Either channel is therefore accepted — a class the reader receives is not a lost finding,
82
+ * whichever field carried it — and the details channel is scoped to alarm-coverage findings
83
+ * so a `cisId` on an unrelated finding cannot stand in for the mirror.
84
+ */
85
+ function verifyAlarmCoverageStillMirrorsItsFindings(raw) {
86
+ const broken = [];
87
+ for (const e of resultsOf(raw)) {
88
+ const res = e?.result ?? {};
89
+ const uncovered = arrayAt(res?.summary?.cisAlarmCoverage, 'uncovered').filter(isCensusSubject);
90
+ if (!uncovered.length) continue;
91
+
92
+ const rendered = new Set();
93
+ for (const f of arrayAt(res, 'findings')) {
94
+ if (!f || typeof f !== 'object') continue;
95
+ for (const issue of arrayAt(f, 'issues')) {
96
+ if (typeof issue !== 'string' || !/alarms?\s+missing/i.test(issue)) continue;
97
+ for (const m of issue.matchAll(/\bcis-\d+(?:\.\d+)*\b/gi)) rendered.add(m[0].toLowerCase());
98
+ }
99
+ const d = f.details;
100
+ if (d && typeof d.cisId === 'string' && typeof d.category === 'string' && /alarm/i.test(d.category)) {
101
+ rendered.add(d.cisId.toLowerCase());
102
+ }
103
+ }
104
+ for (const u of uncovered) {
105
+ if (typeof u.id !== 'string' || !u.id) {
106
+ broken.push(`plugin ${e?.id ?? '?'}: an uncovered entry with no \`id\` — nothing can pair it`);
107
+ } else if (!rendered.has(u.id.toLowerCase())) {
108
+ broken.push(`plugin ${e?.id ?? '?'}: ${u.id}`);
109
+ }
110
+ }
111
+ }
112
+ return broken.length
113
+ ? { ok: false,
114
+ unreadCount: broken.length,
115
+ detail: `the 1:1 mirror has BROKEN — ${broken.length} uncovered alarm class(es) have no rendered `
116
+ + `"alarm missing" finding, so they reach no reader: ${broken.join(' · ')}` }
117
+ : { ok: true, detail: 'every uncovered alarm class pairs with a rendered finding' };
118
+ }
119
+
120
+ /**
121
+ * (ii) `result.findings[].policyAnalyses[].issues[]` — the parent really is rendered.
122
+ *
123
+ * The carve-out calls the nested issues EVIDENCE FOR a finding that IS rendered. That is
124
+ * true only while the parent carries content of its own: `shapeFinding` titles a finding
125
+ * from its `issues[]` (falling back to `detail`/`description`), and with none of those it
126
+ * renders "(untitled finding)" — utils/report_inputs.mjs:139-151. A parent with no issue of
127
+ * its own is an empty row whose only real content is the nested detail nobody reads, which
128
+ * is the carve-out's own negation. Measured: 104 parents across the sample corpus carry
129
+ * counted policy-analysis issues, and 0 of them lack their own.
130
+ */
131
+ function verifyPolicyAnalysisBacksARenderedFinding(raw) {
132
+ const orphans = [];
133
+ for (const e of resultsOf(raw)) {
134
+ const findings = arrayAt(e?.result, 'findings');
135
+ for (let i = 0; i < findings.length; i += 1) {
136
+ const f = findings[i];
137
+ if (!f || typeof f !== 'object') continue;
138
+ const nested = arrayAt(f, 'policyAnalyses')
139
+ .flatMap((pa) => arrayAt(pa, 'issues'))
140
+ .filter(isCensusSubject);
141
+ if (!nested.length) continue;
142
+ // Presence is not content: an `issues: ['']` renders as no text at all.
143
+ const own = arrayAt(f, 'issues')
144
+ .filter((x) => (typeof x === 'string' ? x.trim().length > 0 : !!x));
145
+ if (!own.length) {
146
+ orphans.push(`plugin ${e?.id ?? '?'} findings[${i}] carries ${nested.length} policy-analysis `
147
+ + 'issue(s) and NO issue of its own');
148
+ }
149
+ }
150
+ }
151
+ return orphans.length
152
+ ? { ok: false,
153
+ unreadCount: orphans.length,
154
+ detail: 'the nested detail is no longer EVIDENCE FOR a rendered finding — the parent renders as '
155
+ + `"(untitled finding)" with nothing in it: ${orphans.join(' · ')}` }
156
+ : { ok: true, detail: 'every policy-analysis parent carries its own issue text' };
157
+ }
158
+
159
+ /**
160
+ * (iii) `result.data[]` — on the cloud path it still MIRRORS `findings[]`.
161
+ *
162
+ * ⚠️ THE PREDICATE IS DELIBERATELY SCOPED TO THE CENSUS'S SUBJECT, AND THAT IS WHAT MAKES
163
+ * THE NETWORK SHAPE PASS WITHOUT AN EXEMPTION. A network host legitimately has
164
+ * `findings[] = 0` and `data[] = 85` — measured on the 192.168.1.1 run, 84 of them
165
+ * `{probe_protocol, probe_port, probe_info}`. Not one carries a `severity`, so not one is a
166
+ * census subject and there is nothing to mirror: the acceptance is STRUCTURAL, not a list of
167
+ * telemetry field names that a producer could quietly step outside. The corollary is
168
+ * intended: a telemetry row that GAINS a severity is a severity-bearing object in a container
169
+ * nobody reads, and it fails.
170
+ *
171
+ * Matching is CONTENT-DEEP within one plugin result. Measured across the sample corpus: 86
172
+ * plugin results carry severity-bearing data rows (1150 and 1170 only, 1075 rows), and every
173
+ * row is deep-equal to a row in the same result's findings[]. A laxer key of
174
+ * severity+resource+issues was measured too — also 0 unmatched — and REJECTED: it collapses
175
+ * to a constant on rows that carry neither `resource` nor `issues`, which is a false-clean
176
+ * generator on exactly the network-ish shape this entry has to keep accepting.
177
+ *
178
+ * One stated limit: the mirror is looked for in `findings[]` in its ARRAY form only. A
179
+ * producer that emitted the 060 DICT-of-categories shape AND severity-bearing data[] would
180
+ * FAIL this premise rather than be excused by it. Nothing does today; failing is the
181
+ * noise-costing direction, and a lax read here would be a carve-out granted on a shape
182
+ * nobody has looked at.
183
+ */
184
+ function verifyDataMirrorsFindings(raw) {
185
+ const unmirrored = [];
186
+ for (const e of resultsOf(raw)) {
187
+ const res = e?.result ?? {};
188
+ const rows = arrayAt(res, 'data').filter(isCensusSubject);
189
+ if (!rows.length) continue;
190
+ const rendered = new Set(arrayAt(res, 'findings')
191
+ .filter((f) => f && typeof f === 'object').map(canonical));
192
+ for (const r of rows) {
193
+ if (!rendered.has(canonical(r))) {
194
+ // Locate it well enough to act on: `resource` alone is not distinguishing — on some
195
+ // producers it holds the REGION, so a dozen rows share it.
196
+ const excerpt = arrayAt(r, 'issues').map((x) => (typeof x === 'string' ? x : x?.detail ?? ''))
197
+ .find((t) => t && t.trim()) ?? '';
198
+ unmirrored.push(`plugin ${e?.id ?? '?'}: ${r.severity.toUpperCase()} `
199
+ + `${r.resource ?? '(no resource)'}${excerpt ? ` — "${excerpt.slice(0, 80)}"` : ''}`);
200
+ }
201
+ }
202
+ }
203
+ return unmirrored.length
204
+ ? { ok: false,
205
+ unreadCount: unmirrored.length,
206
+ detail: `result.data[] is no longer a mirror — ${unmirrored.length} severity-bearing row(s) have no `
207
+ + `content-equal finding in the same plugin result, so nothing renders them: ${unmirrored.join(' · ')}` }
208
+ : { ok: true, detail: 'every severity-bearing data row is mirrored by a rendered finding' };
209
+ }
210
+
211
+ /**
212
+ * (iv) `result.portResults[]` — the roll-up really is a roll-up.
213
+ *
214
+ * The reason claims the entry's severity "summarises the port's own issues[], which ARE
215
+ * read". That is checkable in both directions, and both directions matter:
216
+ * · a severity over an EMPTY issues[] summarises nothing — it is a finding nothing renders,
217
+ * which is the exact hazard the carve-out denies;
218
+ * · a severity ABSENT from its own issues' severities is a roll-up of something the port
219
+ * does not hold.
220
+ * The empty case is not simply forbidden, because 040 seeds the roll-up at SEVERITY.PASS and
221
+ * raises it to the max of the port's issues (plugins/040_tls_cert_auditor.mjs:491-496), so a
222
+ * clean port emits exactly `{severity:'pass', issues:[]}`. PASS is the identity element of
223
+ * this roll-up; failing it would flag every healthy HTTPS port and teach an operator to read
224
+ * past the warning. Measured over 232 records (samples + both out/ trees): 7 portResults
225
+ * entries carry a severity, all 7 hold it among their own issue severities, and none has an
226
+ * empty issues[].
227
+ */
228
+ const ROLLUP_IDENTITY_SEVERITY = 'pass';
229
+
230
+ function verifyPortRollUpSummarisesItsOwnIssues(raw) {
231
+ const bad = [];
232
+ for (const e of resultsOf(raw)) {
233
+ const ports = arrayAt(e?.result, 'portResults');
234
+ for (let i = 0; i < ports.length; i += 1) {
235
+ const pr = ports[i];
236
+ if (!isCensusSubject(pr)) continue;
237
+ const where = `plugin ${e?.id ?? '?'} port ${pr.port ?? `[${i}]`}`;
238
+ const issueSeverities = arrayAt(pr, 'issues').filter(isCensusSubject).map((x) => x.severity);
239
+ if (!issueSeverities.length) {
240
+ if (pr.severity !== ROLLUP_IDENTITY_SEVERITY) {
241
+ bad.push(`${where}: severity "${pr.severity}" over an EMPTY issues[] — it summarises nothing, `
242
+ + 'so nothing renders it');
243
+ }
244
+ continue;
245
+ }
246
+ if (!issueSeverities.includes(pr.severity)) {
247
+ bad.push(`${where}: roll-up severity "${pr.severity}" is not among its own issue severities `
248
+ + `(${[...new Set(issueSeverities)].join(', ')})`);
249
+ }
250
+ }
251
+ }
252
+ return bad.length
253
+ ? { ok: false, unreadCount: bad.length, detail: `the roll-up no longer summarises its own issues[]: ${bad.join(' · ')}` }
254
+ : { ok: true, detail: 'every port roll-up is carried by its own issues' };
255
+ }
256
+
257
+ /**
258
+ * Containers that carry a `severity` but are NOT findings. Each needs a written reason AND,
259
+ * where the reason rests on a checkable fact, a `verify(raw, queue) => {ok, detail}` that
260
+ * re-derives it on every run — an allowlist entry with no reason is how a real door gets
261
+ * closed by accident, and one whose reason nothing re-derives is how a closed door stays
262
+ * shut after the reason has gone.
263
+ */
264
+ export const ALLOWLISTED_CONTAINERS = Object.freeze({
265
+ 'result.portResults[]': Object.freeze({
266
+ reason:
267
+ 'A per-port ROLL-UP, not a finding: its severity summarises the port\'s own '
268
+ + '`issues[]`, which ARE read. Rendering both would double-count every TLS issue.',
269
+ verify: verifyPortRollUpSummarisesItsOwnIssues,
270
+ }),
271
+ 'result.summary.cisAlarmCoverage.uncovered[]': Object.freeze({
272
+ reason:
273
+ 'A 1:1 SUMMARY MIRROR of 1040\'s own alarm-coverage findings, not a second source. '
274
+ + 'Adjudicated on the 0.44.0 cloud run: 13 uncovered classes, 13 "alarm missing" '
275
+ + 'findings, and the set difference of their cis-N.N ids is EMPTY. That was a fact '
276
+ + 'about one run, so `verify` re-derives it on every run rather than re-asserting it.',
277
+ verify: verifyAlarmCoverageStillMirrorsItsFindings,
278
+ }),
279
+ 'result.findings[].policyAnalyses[].issues[]': Object.freeze({
280
+ reason:
281
+ 'Per-statement EVIDENCE nested inside a finding that IS rendered — e.g. "Full admin '
282
+ + 'grant: Action \'*\' in policy X" backing a parent whose own issues[] already say '
283
+ + '"SHADOW ADMIN: User has full wildcard (*) permissions". Not a missed finding. '
284
+ + 'RESIDUAL, stated rather than hidden: the per-statement detail is not surfaced in the '
285
+ + 'report at all, which is a DEPTH choice nobody has ruled on, not a coverage gap.',
286
+ verify: verifyPolicyAnalysisBacksARenderedFinding,
287
+ }),
288
+ 'result.data[]': Object.freeze({
289
+ reason:
290
+ 'NOT a findings container, and the report must never read it. On a CLOUD host it '
291
+ + 'MIRRORS result.findings (verified 0.44.0: 1150 16/16, 1170 9/9). On a NETWORK host '
292
+ + 'it is PROBE TELEMETRY — measured on the 192.168.1.1 run, findings[]=0 while '
293
+ + 'data[]=85, 84 of them {probe_protocol, probe_port, probe_info} ("Connect refused '
294
+ + '(ECONNREFUSED)", "No UDP response"). ⚠️ A `findings ?? data` fallback was written '
295
+ + 'here and REVERTED: it put scan telemetry into a client deliverable, hidden because '
296
+ + 'the telemetry rows collided on one content hash and collapsed to two. '
297
+ + 'cloud_finding_summary\'s findingsOf may use that precedence — it only ever sees '
298
+ + 'cloud producers; this consumer sees both paths.',
299
+ verify: verifyDataMirrorsFindings,
300
+ }),
301
+ });
302
+
303
+ const QUEUE_CONTAINER = 'findingQueue[]';
304
+
305
+ /**
306
+ * Adjudicate ONE allowlist entry against a record.
307
+ *
308
+ * ⚠️ FAILS CLOSED IN EVERY DIRECTION THAT IS NOT AN EXPLICIT `ok: true`. A verifier that
309
+ * throws has not established the premise; neither has one that returns a shape nobody can
310
+ * read. Both are treated as a broken premise, because the alternative — a thrown TypeError
311
+ * silently restoring the carve-out — is the false clean this whole mechanism exists to
312
+ * close, and it would arrive precisely when a producer changed shape.
313
+ *
314
+ * @returns {string|null} null when the carve-out stands (premise held, or none declared);
315
+ * otherwise the operator-facing detail of why it does not.
316
+ */
317
+ export function adjudicateAllowlistEntry(entry, raw, queue) {
318
+ // An entry that declares no premise keeps its carve-out: a shape nobody can check yet is
319
+ // better carved out loudly, with its reason readable, than checked wrongly. The ratchet
320
+ // that stops "undeclared" becoming the default lives in the tests, not here.
321
+ if (typeof entry?.verify !== 'function') return null;
322
+ let verdict;
323
+ try {
324
+ verdict = entry.verify(raw, queue);
325
+ } catch (err) {
326
+ return { detail: `premise check THREW (${err?.message ?? String(err)}) — a check that cannot `
327
+ + 'run has established nothing, so the carve-out is withdrawn' };
328
+ }
329
+ if (!verdict || typeof verdict !== 'object' || typeof verdict.ok !== 'boolean') {
330
+ return { detail: 'premise check returned no verdict — treated as FAILED, because a check that '
331
+ + 'cannot answer has not established anything' };
332
+ }
333
+ if (verdict.ok) return null;
334
+ // ⚠️ THE COUNT TRAVELS WITH THE DETAIL. This used to return the detail STRING alone, so the
335
+ // caller had a sentence saying "1 row has no mirror" and no number to act on — and fell back to
336
+ // the container's whole population, which is exactly the false quantity the consumer renders.
337
+ // A failure is `{detail, unreadCount}`; `unreadCount` is whatever the verifier claimed and is
338
+ // VALIDATED downstream by resolveUnreadCount, never trusted here.
339
+ return {
340
+ detail: typeof verdict.detail === 'string' && verdict.detail.trim()
341
+ ? verdict.detail
342
+ : 'premise FAILED, with no detail given',
343
+ unreadCount: verdict.unreadCount,
344
+ };
345
+ }
346
+
347
+ /**
348
+ * Classify where a severity-bearing object lives. Path is the dotted walk from `result`.
349
+ * @returns {string} a container key
350
+ */
351
+ function containerOf(path) {
352
+ if (/^\.findings\[\d+\]$/.test(path)) return 'result.findings[]';
353
+ if (/^\.findings\.[A-Za-z0-9_]+\[\d+\]$/.test(path)) return 'result.findings.<category>[]';
354
+ if (/zeroTrust\..*\.findings\[\d+\]$/.test(path)) return 'result.zeroTrust.<dim>.findings[]';
355
+ if (/^\.portResults\[\d+\]\.issues\[\d+\]$/.test(path)) return 'result.portResults[].issues[]';
356
+ if (/^\.portResults\[\d+\]$/.test(path)) return 'result.portResults[]';
357
+ if (/^\.summary\.cisAlarmCoverage\.uncovered\[\d+\]$/.test(path)) return 'result.summary.cisAlarmCoverage.uncovered[]';
358
+ if (/^\.findings\[\d+\]\.policyAnalyses\[\d+\]\.issues\[\d+\]$/.test(path)) return 'result.findings[].policyAnalyses[].issues[]';
359
+ if (/^\.data\[\d+\]$/.test(path)) return 'result.data[]';
360
+ // An UNRECOGNISED container is reported under its own generalised path rather than
361
+ // bucketed into "other" — a census that collapses the unknown into one bucket tells you
362
+ // that something is missing without telling you what.
363
+ return `UNCLASSIFIED:${path.replace(/\[\d+\]/g, '[]')}`;
364
+ }
365
+
366
+ function* severityObjects(node, path = '') {
367
+ if (Array.isArray(node)) {
368
+ for (let i = 0; i < node.length; i += 1) yield* severityObjects(node[i], `${path}[${i}]`);
369
+ } else if (node && typeof node === 'object') {
370
+ if (isCensusSubject(node)) yield { path, obj: node };
371
+ for (const [k, v] of Object.entries(node)) yield* severityObjects(v, `${path}.${k}`);
372
+ }
373
+ }
374
+
375
+ /**
376
+ * Census one scan record.
377
+ * @param {object} raw the parsed scan_conclusion_raw.json
378
+ * @param {Array} queue the host's finding queue (scan_finding_queue.json, or the
379
+ * eeEnrichment fallback) — passed in because it is a SIBLING
380
+ * artifact, not part of `raw`, which is the whole reason it was missed.
381
+ * @returns {{byContainer: Object<string, number>, unread: Object<string, number>,
382
+ * premiseFailures: Object<string, string>, total: number}}
383
+ * `unread` is the operator's answer to "what does this report not read?" and a container
384
+ * lands there for one of TWO reasons; `premiseFailures` says which, keyed on the same
385
+ * container, because the repairs are opposite — an unknown container needs a reader
386
+ * written, a withdrawn carve-out needs its justification re-argued or removed.
387
+ */
388
+ /**
389
+ * HOW MANY OBJECTS A BROKEN PREMISE ACTUALLY LEAVES UNREAD.
390
+ *
391
+ * ⚠️ THE REPAIR FOR ONE FALSE CLEAN INTRODUCED A FALSE QUANTITY, and the number reaches a
392
+ * CUSTOMER. `unread[k]` used to be the container's WHOLE population, and `executive_report.mjs`
393
+ * renders it as "N recorded finding-like object(s) … are NOT included above". For a MIRROR
394
+ * carve-out that breaks PARTIALLY, most of those objects ARE rendered above — plugin 1150 emits
395
+ * `data: findings`, so 16 mirrored rows and one orphan told a client that 17 objects were missing
396
+ * from a report containing 16 of them. **A caveat that contradicts the body of its own document.**
397
+ *
398
+ * ⚠️ AND `unreadCount ?? population` IS NOT ENOUGH — that only catches null/undefined. A verifier
399
+ * returning `0` on a real break, or `"3"`, or a count ABOVE the population, would put a garbage
400
+ * number into a client-facing sentence. Every one of those degrades to the population instead:
401
+ * **over-report, never emit a silent or impossible number.** `0` is deliberately refused rather
402
+ * than trusted — a failure claiming nothing is unread is a verifier bug silencing itself.
403
+ */
404
+ export function resolveUnreadCount(failure, population) {
405
+ const n = failure?.unreadCount;
406
+ if (typeof n !== 'number' || !Number.isInteger(n) || n <= 0 || n > population) return population;
407
+ return n;
408
+ }
409
+
410
+ export function censusFindingContainers(raw, queue = []) {
411
+ const byContainer = {};
412
+ const bump = (k) => { byContainer[k] = (byContainer[k] || 0) + 1; };
413
+
414
+ for (const e of resultsOf(raw)) {
415
+ for (const { path } of severityObjects(e?.result ?? {}, '')) bump(containerOf(path));
416
+ }
417
+ if (Array.isArray(queue) && queue.length) byContainer[QUEUE_CONTAINER] = queue.length;
418
+
419
+ const unread = {};
420
+ const premiseFailures = {};
421
+ for (const [k, n] of Object.entries(byContainer)) {
422
+ if (READ_CONTAINERS.includes(k)) continue;
423
+ if (!Object.prototype.hasOwnProperty.call(ALLOWLISTED_CONTAINERS, k)) { unread[k] = n; continue; }
424
+ // The premise is only adjudicated where something is actually being carved out: running
425
+ // it over a container this record does not carry would report on nothing.
426
+ const failure = adjudicateAllowlistEntry(ALLOWLISTED_CONTAINERS[k], raw, queue);
427
+ if (failure === null) continue;
428
+ // The BREAK, not the container — see resolveUnreadCount for why the count is validated
429
+ // rather than trusted, and why the population stays in `byContainer` where it belongs.
430
+ unread[k] = resolveUnreadCount(failure, n);
431
+ // `premiseFailures` stays a map of container -> DETAIL STRING: it is the operator- and
432
+ // reader-facing half, and the count already lives in `unread`. Putting the verdict OBJECT
433
+ // here would change a published shape for no gain and hand two numbers to a consumer that
434
+ // needs one.
435
+ premiseFailures[k] = failure.detail;
436
+ }
437
+ return { byContainer, unread, premiseFailures, total: Object.values(byContainer).reduce((a, b) => a + b, 0) };
438
+ }