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,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
|
+
}
|