nsauditor-ai 0.2.49 → 0.2.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,537 @@
1
+ // utils/report_inputs.mjs — the only module that knows the filesystem layout of a scan run.
2
+ // Turns a run directory into ONE normalised model that the executive HTML report and the Jira
3
+ // CSV export both consume. Every refusal below is written before the happy path, because a
4
+ // report that renders confidently over a partial or mismatched run is the false clean this
5
+ // feature exists to prevent: a correct report about the wrong (or incomplete) run reads
6
+ // identically to a correct one without that caveat.
7
+ import fsp from 'node:fs/promises';
8
+ import path from 'node:path';
9
+ import crypto from 'node:crypto';
10
+ import { describeFinding } from './cloud_finding_summary.mjs';
11
+ import { censusFindingContainers } from './report_finding_census.mjs';
12
+ import {
13
+ RUN_RECORD_SCHEMA, UNPARSEABLE, runRecordPath, listRunRecords, readRunRecord,
14
+ } from './run_record.mjs';
15
+ import { CE_RETENTION_MS } from './scan_history.mjs';
16
+
17
+ const refuse = (reason, message) => ({ ok: false, reason, message });
18
+
19
+ // Mirrors run_record.mjs's private RUN_FILE_RE. Not exported from there, so it is re-declared
20
+ // here — deliberately, and only for the narrow purpose below: telling "no run-record file
21
+ // exists" apart from "one exists but listRunRecords()/readRunRecord() could not parse it"
22
+ // (which both currently swallow as an absence — EACCES, EISDIR, or invalid JSON all read as
23
+ // null/skipped). A directory LISTING sees a file's NAME regardless of whether its CONTENTS are
24
+ // readable (readdir needs only the directory's own permission bits), so comparing "a name that
25
+ // looks like a run record exists on disk" against "listRunRecords() parsed zero of them" is a
26
+ // portable way to catch this without guessing errno codes ourselves.
27
+ const RUN_FILE_RE = /^scan_run_(.+)\.json$/;
28
+
29
+ async function listEntryNames(outRoot) {
30
+ try { return await fsp.readdir(outRoot); } catch { return []; }
31
+ }
32
+
33
+ // Shared by both callers below: a directory LISTING sees a file's NAME regardless of whether
34
+ // its CONTENTS are readable, so this is how "present but unreadable" is told apart from
35
+ // "genuinely never written" without guessing errno codes. Pass `onlyName` to check one specific
36
+ // filename (the explicit `--run <id>` path); omit it to find any run-record-shaped name at all
37
+ // (the "pick the latest run" path, which does not know a candidate id yet).
38
+ async function findRunRecordFilenames(outRoot, onlyName = null) {
39
+ const names = await listEntryNames(outRoot);
40
+ if (onlyName) return names.includes(onlyName) ? [onlyName] : [];
41
+ return names.filter((n) => RUN_FILE_RE.test(n));
42
+ }
43
+
44
+ // Walk one level of `outRoot`, read each host directory's scan_conclusion_raw.json, and report
45
+ // what a run that never wrote a run record still left behind: which directories carry a raw at
46
+ // all, whatever runId (if any) is embedded in one of them, and the newest mtime among them (used
47
+ // to decide whether the run is inside or outside the retention window).
48
+ async function probeRawDirs(outRoot) {
49
+ let entries;
50
+ try { entries = await fsp.readdir(outRoot, { withFileTypes: true }); }
51
+ catch { return { dirs: [], anyRunId: null, newestMtime: 0 }; }
52
+
53
+ const dirs = [];
54
+ let anyRunId = null;
55
+ let newestMtime = 0;
56
+ for (const ent of entries) {
57
+ if (!ent.isDirectory()) continue;
58
+ const rawPath = path.join(outRoot, ent.name, 'scan_conclusion_raw.json');
59
+ let raw, stat;
60
+ try {
61
+ raw = JSON.parse(await fsp.readFile(rawPath, 'utf8'));
62
+ stat = await fsp.stat(rawPath);
63
+ } catch { continue; }
64
+ dirs.push(ent.name);
65
+ if (raw?.runId) anyRunId = raw.runId;
66
+ if (stat.mtimeMs > newestMtime) newestMtime = stat.mtimeMs;
67
+ }
68
+ return { dirs, anyRunId, newestMtime };
69
+ }
70
+
71
+ // ⚠️ UNPARSEABLE IS A SINGLE CONSTANT (utils/run_record.mjs), so two different refused `--host`
72
+ // inputs are INDISTINGUISHABLE once written into `hostsRequested`. A naive membership check
73
+ // (`hostsRequested.filter(h => !hostsWritten.some(w => w === h))`) does not CONSUME a match, so
74
+ // one written unparseable row reads as "covering" every unparseable row requested — a false
75
+ // "complete" over a host that was never scanned. This does a proper multiset diff instead: each
76
+ // written host can satisfy exactly one requested slot. A deficit on an ordinary host name is
77
+ // reported by name; a deficit on the UNPARSEABLE sentinel can never be named (we do not know
78
+ // which of the N original bad inputs it was), so it is counted and disclosed as "coverage cannot
79
+ // be established for it" rather than either silently ignored or falsely reported as a specific
80
+ // missing host.
81
+ function computeMissing(hostsRequested, writtenHosts) {
82
+ const pool = new Map();
83
+ for (const h of writtenHosts) pool.set(h, (pool.get(h) ?? 0) + 1);
84
+ const missingNamed = [];
85
+ let missingUnparseable = 0;
86
+ for (const h of hostsRequested) {
87
+ const remaining = pool.get(h) ?? 0;
88
+ if (remaining > 0) {
89
+ pool.set(h, remaining - 1);
90
+ } else if (h === UNPARSEABLE) {
91
+ missingUnparseable += 1;
92
+ } else {
93
+ missingNamed.push(h);
94
+ }
95
+ }
96
+ return { missingNamed, missingUnparseable };
97
+ }
98
+
99
+ function describeMissing(missingNamed, missingUnparseable) {
100
+ const parts = [];
101
+ if (missingNamed.length) parts.push(missingNamed.join(', '));
102
+ if (missingUnparseable) {
103
+ parts.push(`${missingUnparseable} host(s) whose input could not be parsed into a name — ` +
104
+ `coverage cannot be established for ${missingUnparseable === 1 ? 'it' : 'them'}`);
105
+ }
106
+ return parts.join('; ');
107
+ }
108
+
109
+ // An `issues[]` entry is a STRING in almost every producer, and an OBJECT
110
+ // `{severity, detail}` in 1030's five error paths. The object form carries `.detail`
111
+ // and NOT `.title` — `String(obj)` yields "[object Object]", which is what a naive
112
+ // reuse of `describeFinding` renders for that shape. Normalise before describing.
113
+ function issueText(i) {
114
+ if (i == null) return '';
115
+ if (typeof i === 'string') return i;
116
+ if (typeof i === 'object') return String(i.detail ?? i.title ?? i.message ?? i.reason ?? '');
117
+ return String(i);
118
+ }
119
+
120
+ export function shapeFinding(host, f) {
121
+ const severity = f?.severity != null ? String(f.severity).toUpperCase() : 'INFO';
122
+ const port = f?.port ?? null;
123
+ // ⚠️ THE REPORT USED TO READ `f.title` ALONE, AND NO SHIPPED PLUGIN EMITS IT.
124
+ // Measured at Gate 3-B on the installed 0.44.0 trio: 27 of 29 EE plugins carry no
125
+ // `title:` field, no CE plugin carries one, and exactly three `findings.push` sites
126
+ // product-wide set one — so a real 21-plugin run rendered 208 "(untitled finding)"
127
+ // and a Jira CSV with an empty Summary on every row. Cloud findings carry their
128
+ // content in `issues[]`.
129
+ //
130
+ // `describeFinding` is the SAME normaliser the MCP surface uses, so the two consumers
131
+ // cannot drift apart on what a finding is called.
132
+ //
133
+ // ⚠️ IT IS CALLED ONLY WHEN CONTENT EXISTS, and that gate is load-bearing:
134
+ // `describeFinding` never returns empty — with nothing to say it emits
135
+ // "INFO finding (no description)". Calling it unconditionally would hand a title to a
136
+ // finding that has none, which is fabrication, and is the one thing worse than the
137
+ // defect being fixed. A finding with neither a title nor any issue text stays null
138
+ // and renders "(untitled finding)".
139
+ const issueTexts = Array.isArray(f?.issues)
140
+ ? f.issues.map(issueText).filter((t) => t.trim().length > 0)
141
+ : [];
142
+ const explicitDetail = f?.detail ?? f?.description ?? null;
143
+ // When a producer carries its content in `detail`/`description` and no `issues[]` at all
144
+ // (1023's {severity, description}; 040's and 060's {severity, check, detail}), feed that
145
+ // text in AS an issue rather than widening describeFinding's shared REASON_KEYS — the
146
+ // MCP surface consumes the same helper, and a constant widened for this caller changes
147
+ // what every other caller titles. `description` in particular is NOT in REASON_KEYS, so
148
+ // without this a zero-trust finding titles itself "MEDIUM finding (no description)".
149
+ const contentIssues = issueTexts.length ? issueTexts : (explicitDetail ? [String(explicitDetail)] : []);
150
+ const title = f?.title
151
+ ?? (contentIssues.length ? describeFinding({ ...f, issues: contentIssues }) : null);
152
+ const cves = Array.isArray(f?.cves) ? f.cves.map(String)
153
+ : Array.isArray(f?.cve) ? f.cve.map(String) : [];
154
+ // CE ships no KEV/EPSS store of its own (utils/scan_history.mjs comment at cli.mjs:2780):
155
+ // never invent an enrichment value that was not actually carried on the finding.
156
+ const kev = f?.kev === true;
157
+ const epss = typeof f?.epss === 'number' && Number.isFinite(f.epss) ? f.epss : null;
158
+ const id = crypto.createHash('sha256')
159
+ .update(JSON.stringify([host, port, severity, title]))
160
+ .digest('hex').slice(0, 16);
161
+ return {
162
+ host, port, severity, title,
163
+ // ALL issues, not just the lead clause the title took: a report that shows one of a
164
+ // finding's four issues silently drops three the scan actually recorded.
165
+ detail: explicitDetail ?? (issueTexts.length ? issueTexts.join(' · ') : null),
166
+ remediation: f?.remediation ?? f?.details?.remediation ?? null,
167
+ cves, kev, epss, id,
168
+ };
169
+ }
170
+
171
+ // The envelope shape is `{ id, name, result }` (plugin_manager.mjs's manifest entries pair with
172
+ // `results[]` this way); reachability and findings both live INSIDE `result`, never at the raw's
173
+ // top level. A host counts as up if ANY plugin's envelope observed it up — a host with nothing
174
+ // reporting `up: true` (including a host with zero envelopes) is not reachable.
175
+ /**
176
+ * The EE finding queue is a SIBLING ARTIFACT with its own vocabulary, and the mismatch is
177
+ * Class P — not a rename. Measured on a real entry from a 192.168.1.1 run:
178
+ * `cves` ABSENT -> `evidence.cve[]` (a naive reader renders an empty CVE column)
179
+ * `epss` ABSENT -> `epssScore` (a naive reader nulls EPSS, and the report's
180
+ * own cover promises EPSS ordering)
181
+ * `remediation` -> an OBJECT `{summary}` (a naive reader prints "[object Object]")
182
+ * `port` ABSENT -> `target.port`
183
+ * `id` -> a stable `F-…`, better than any content hash we could compute
184
+ */
185
+ function shapeQueueEntry(host, q) {
186
+ const ev = (q && typeof q.evidence === 'object' && q.evidence) || {};
187
+ const rem = q?.remediation;
188
+ return {
189
+ host,
190
+ port: q?.port ?? q?.target?.port ?? null,
191
+ severity: q?.severity != null ? String(q.severity).toUpperCase() : 'INFO',
192
+ title: q?.title ?? null,
193
+ detail: q?.description ?? q?.detail ?? null,
194
+ remediation: typeof rem === 'string' ? rem : (rem?.summary ?? null),
195
+ cves: Array.isArray(ev.cve) ? ev.cve.map(String)
196
+ : Array.isArray(q?.cves) ? q.cves.map(String) : [],
197
+ kev: q?.kev === true,
198
+ epss: typeof q?.epssScore === 'number' && Number.isFinite(q.epssScore) ? q.epssScore
199
+ : (typeof q?.epss === 'number' && Number.isFinite(q.epss) ? q.epss : null),
200
+ exploitPriority: q?.exploitPriority ?? null,
201
+ id: q?.id ?? null,
202
+ };
203
+ }
204
+
205
+ /**
206
+ * EVERY container a finding can live in, merged and de-duplicated by IDENTICAL id only.
207
+ *
208
+ * ⚠️ NEVER heuristic merging across producers. crypto_agent's TLS queue entries and 040's
209
+ * per-check issues describe the same port at different granularities and BOTH belong in
210
+ * the report, each with its own source. Collapsing them on a similarity guess would drop
211
+ * real findings to make a tidier table — the defect this whole lane exists to remove.
212
+ *
213
+ * The container inventory is enforced by `utils/report_finding_census.mjs`: a container
214
+ * carrying severity-bearing objects that is neither read here nor allowlisted there fails
215
+ * loudly. Incompleteness costs NOISE, never SILENCE.
216
+ */
217
+ export function shapeHostFindings(host, raw, queue = []) {
218
+ const out = [];
219
+ const seen = new Set();
220
+ // ⚠️ DE-DUPLICATE ONLY ON A PRODUCER-SUPPLIED ID, NEVER ON A CONTENT HASH.
221
+ // `shapeFinding` derives its id from [host, port, severity, title] and the title is
222
+ // TRUNCATED at 160 chars, so distinct findings can share one. Measured on the 0.44.0
223
+ // cloud run: plugin 1170 emits THREE separate 0.0.0.0/0 ingress findings for
224
+ // sg-0def2fbb3db67eae5 whose titles truncate identically — a content-keyed dedup
225
+ // silently dropped two of them and took cloud CRITICAL from 10 to 8. A hash collision is
226
+ // evidence that the hash is coarse, never evidence that two findings are the same one.
227
+ // The finding queue DOES carry a stable producer id (`F-…`), which is what this is for.
228
+ const push = (f, dedupId = null) => {
229
+ if (dedupId != null) {
230
+ if (seen.has(dedupId)) return;
231
+ seen.add(dedupId);
232
+ }
233
+ out.push(f);
234
+ };
235
+
236
+ for (const e of (Array.isArray(raw?.results) ? raw.results : [])) {
237
+ const res = e?.result ?? {};
238
+ // ⚠️ `result.data` IS NOT A FINDINGS CONTAINER AND MUST NOT BE READ HERE. I added a
239
+ // `findings ?? data` fallback (mirroring cloud_finding_summary's findingsOf) on the
240
+ // reasoning that a data-only producer should not be invisible — and it was WRONG,
241
+ // measured on the 192.168.1.1 run: a network host has `findings[] = 0` and
242
+ // `data[] = 85`, of which 84 are PROBE TELEMETRY ({probe_protocol, probe_port,
243
+ // probe_info} — "Connect refused (ECONNREFUSED)", "No UDP response"). The fallback put
244
+ // scan telemetry into a client deliverable, and only an accidental id collision (null
245
+ // title, null port, INFO) collapsed 85 rows into two, which is what hid it.
246
+ // findingsOf can use that precedence because the MCP cloud path only ever sees cloud
247
+ // producers; the report sees BOTH paths.
248
+ const rf = res.findings;
249
+ if (Array.isArray(rf)) {
250
+ for (const f of rf) push(shapeFinding(host, f));
251
+ } else if (rf && typeof rf === 'object') {
252
+ // 060 DNS Security Auditor emits a DICT OF CATEGORIES ({spf:[…], dmarc:[…]}), which
253
+ // an `Array.isArray` guard skips in complete silence.
254
+ for (const arr of Object.values(rf)) {
255
+ if (Array.isArray(arr)) for (const f of arr) push(shapeFinding(host, f));
256
+ }
257
+ }
258
+ if (res.zeroTrust && typeof res.zeroTrust === 'object') {
259
+ for (const dim of Object.values(res.zeroTrust)) {
260
+ if (Array.isArray(dim?.findings)) for (const f of dim.findings) push(shapeFinding(host, f));
261
+ }
262
+ }
263
+ if (Array.isArray(res.portResults)) {
264
+ for (const pr of res.portResults) {
265
+ // The port result's own `severity` is a ROLL-UP of these issues, never a finding —
266
+ // rendering both would double-count every TLS issue.
267
+ if (Array.isArray(pr?.issues)) {
268
+ for (const i of pr.issues) push(shapeFinding(host, { ...i, port: i?.port ?? pr?.port ?? null }));
269
+ }
270
+ }
271
+ }
272
+ }
273
+ for (const q of (Array.isArray(queue) ? queue : [])) {
274
+ const e = shapeQueueEntry(host, q);
275
+ push(e, e.id);
276
+ }
277
+ return out;
278
+ }
279
+
280
+ function shapeHost(host, dir, raw) {
281
+ const envelopes = Array.isArray(raw.results) ? raw.results : [];
282
+ const up = envelopes.some((e) => e?.result?.up === true);
283
+ const findings = shapeHostFindings(host, raw, raw?.__findingQueue ?? []);
284
+ return {
285
+ host, dir, up, findings,
286
+ pluginStatus: Array.isArray(raw.pluginStatus) ? raw.pluginStatus : [],
287
+ };
288
+ }
289
+
290
+ function buildModel(rec, hosts, counts) {
291
+ const { requested, written, missingNamed, missingUnparseable, incomplete } = counts;
292
+ const reachable = hosts.filter((h) => h.up).length;
293
+ const findings = hosts.flatMap((h) => h.findings);
294
+
295
+ const plugins = { ran: 0, skipped: 0, errored: 0, timedOut: 0, byHost: [] };
296
+ for (const h of hosts) {
297
+ // CC-2: `dir` rides alongside `host` here so the renderer can key its per-host plugin
298
+ // status table by DIRECTORY, never by host name — `hosts` (and therefore `model.hosts`) is
299
+ // one entry per scan directory, and two directories can legitimately share a host name
300
+ // (`--host 10.0.0.7,10.0.0.7`, a repeated --host-file line, an overlapping range — none of
301
+ // which utils/host_iterator.mjs de-duplicates). A name-keyed Map is last-write-wins and
302
+ // silently drops every same-named host's own plugin table but the final one's.
303
+ plugins.byHost.push({ host: h.host, dir: h.dir, status: h.pluginStatus });
304
+ for (const ps of h.pluginStatus) {
305
+ if (ps?.status === 'ran') plugins.ran += 1;
306
+ else if (ps?.status === 'skipped') plugins.skipped += 1;
307
+ else if (ps?.status === 'error') plugins.errored += 1;
308
+ else if (ps?.status === 'timeout') plugins.timedOut += 1;
309
+ }
310
+ }
311
+
312
+ return {
313
+ runId: rec.runId,
314
+ startedAt: rec.startedAt,
315
+ finishedAt: rec.finishedAt,
316
+ tier: rec.tier,
317
+ ceVersion: rec.ceVersion,
318
+ eeVersion: rec.eeVersion,
319
+ coverage: {
320
+ requested,
321
+ written,
322
+ reachable,
323
+ missing: missingNamed,
324
+ partial: missingNamed.length > 0 || missingUnparseable > 0,
325
+ incomplete,
326
+ },
327
+ plugins,
328
+ kev: { loaded: Boolean(rec.kevLoaded), snapshot: rec.kevSnapshot ?? null },
329
+ epss: { loaded: Boolean(rec.epssLoaded), snapshot: rec.epssSnapshot ?? null },
330
+ hosts: hosts.map((h) => ({ host: h.host, dir: h.dir, up: h.up, findings: h.findings })),
331
+ findings,
332
+ };
333
+ }
334
+
335
+ // Schema, then binding, then completeness — in that order, so a malformed record can never be
336
+ // read as a partial one: a schema this build does not understand must refuse before any attempt
337
+ // is made to interpret its host lists, and a two-way binding mismatch must refuse before
338
+ // completeness arithmetic ever looks at what that (wrongly-bound) directory contains.
339
+ async function finishLoadingRecord(outRoot, rec, allowPartial) {
340
+ if (rec.schema !== RUN_RECORD_SCHEMA) {
341
+ return refuse('unknown-schema',
342
+ `The run record for \`${rec.runId}\` declares schema ${rec.schema}; this build understands ` +
343
+ `schema ${RUN_RECORD_SCHEMA}. Refusing rather than guessing what its fields mean.`);
344
+ }
345
+
346
+ const hosts = [];
347
+ const unreadByHost = [];
348
+ for (const { host, dir } of rec.hostsWritten ?? []) {
349
+ const rawPath = path.join(outRoot, dir, 'scan_conclusion_raw.json');
350
+ let raw;
351
+ try { raw = JSON.parse(await fsp.readFile(rawPath, 'utf8')); }
352
+ catch {
353
+ // Loose-but-honest wording matters here: this same catch fires for a MISSING file
354
+ // (ENOENT), an UNREADABLE one (EACCES/EISDIR), and a perfectly readable file that is not
355
+ // valid JSON — "has no readable scan_conclusion_raw.json" is false in that third case.
356
+ return refuse('binding-mismatch',
357
+ `The run record lists \`${dir}\`, whose scan_conclusion_raw.json could not be read or ` +
358
+ 'parsed as valid JSON.');
359
+ }
360
+ if (raw.runId !== rec.runId) {
361
+ return refuse('binding-mismatch',
362
+ `Directory \`${dir}\` names run \`${raw.runId}\`, but the record being reported is ` +
363
+ `\`${rec.runId}\`. Refusing: a directory from another engagement cannot be reported as ` +
364
+ 'this one.');
365
+ }
366
+ // ⚠️ THE FINDING QUEUE IS A SIBLING FILE, WHICH IS EXACTLY WHY IT WAS MISSED — nothing
367
+ // in scan_conclusion_raw.json points at it. `scan_finding_queue.json` is the documented
368
+ // artifact (EE writes it; `report` is Pro-gated so EE is present); the eeEnrichment
369
+ // fallback covers a record written before the file existed. A queue that cannot be read
370
+ // yields [] and the host simply renders its plugin findings — never an exception, and
371
+ // never a silent substitution of one source for another.
372
+ let findingQueue = [];
373
+ try {
374
+ const qRaw = JSON.parse(fs.readFileSync(path.join(outRoot, dir, 'scan_finding_queue.json'), 'utf8'));
375
+ findingQueue = Array.isArray(qRaw) ? qRaw : (qRaw?.queue ?? qRaw?.findings ?? []);
376
+ } catch {
377
+ const q = raw?.conclusion?.result?.eeEnrichment?.queue;
378
+ if (Array.isArray(q)) findingQueue = q;
379
+ }
380
+ // ⚠️ THE CENSUS RUNS ON EVERY REAL RUN, NOT ONLY IN TESTS — this is what makes it a
381
+ // guard rather than a fixture. Its reconcile leg's corpus is synthetic, and the two real
382
+ // runs were reconciled BY HAND; a new plugin inventing a new container would be silent
383
+ // again until somebody wrote its fixture. Running it here means the next unread
384
+ // container announces itself on the first render, which is how all three of the doors
385
+ // found tonight SHOULD have been found.
386
+ const census = censusFindingContainers(raw, findingQueue);
387
+ if (Object.keys(census.unread).length) unreadByHost.push({ host, unread: census.unread });
388
+ hosts.push(shapeHost(host, dir, { ...raw, __findingQueue: findingQueue }));
389
+ }
390
+
391
+ const requested = (rec.hostsRequested ?? []).length;
392
+ const written = hosts.length;
393
+ const { missingNamed, missingUnparseable } = computeMissing(rec.hostsRequested ?? [], hosts.map((h) => h.host));
394
+ const incomplete = !rec.finishedAt;
395
+ const anyMissing = missingNamed.length > 0 || missingUnparseable > 0;
396
+
397
+ // ORDER MATTERS (mutant-proven — see the task report): the incomplete case is checked FIRST.
398
+ // A crash after the LAST host directory is written leaves hostsWritten === hostsRequested with
399
+ // no finishedAt, so `anyMissing` is false and the partial-hosts message below would be FALSE —
400
+ // it would say nothing is missing on a run that never actually finished.
401
+ if (incomplete && !allowPartial) {
402
+ const completionClause = anyMissing
403
+ ? `${written} of ${requested} requested hosts were written (missing: ` +
404
+ `${describeMissing(missingNamed, missingUnparseable)})`
405
+ : `All ${requested} requested hosts were written`;
406
+ return refuse('incomplete-run',
407
+ `Refusing to report: this run never finished. ${completionClause}, but the scan did not ` +
408
+ 'record completion, so plugins may not have run on the last host. Re-run it, or pass ' +
409
+ '`--allow-partial` to report on what was recorded.');
410
+ }
411
+ if (anyMissing && !allowPartial) {
412
+ return refuse('partial-hosts',
413
+ `Refusing to report: this run requested ${requested} hosts and wrote ${written}. ` +
414
+ `Missing: ${describeMissing(missingNamed, missingUnparseable)}. Re-run those hosts, or pass ` +
415
+ '`--allow-partial` to report on what was recorded.');
416
+ }
417
+
418
+ const model = buildModel(rec, hosts, { requested, written, missingNamed, missingUnparseable, incomplete });
419
+ // Carried on the model so the RENDERER can disclose it to the reader. A warning on stderr
420
+ // is seen by whoever ran the command; the report is what reaches the customer, and the
421
+ // artifact is the surface that has to state its own blind spot.
422
+ if (unreadByHost.length) model.unreadContainers = unreadByHost;
423
+ return { ok: true, model };
424
+ }
425
+
426
+ /**
427
+ * Discover, validate and normalise a scan run into one model.
428
+ * @returns {Promise<{ ok: true, model: object } | { ok: false, reason: string, message: string }>}
429
+ */
430
+ export async function loadRun(outRoot, { runId = null, allowPartial = false } = {}, env = {}) {
431
+ if (runId) {
432
+ const rec = await readRunRecord(outRoot, runId);
433
+ if (!rec) {
434
+ // readRunRecord swallows EACCES/EISDIR as "absent" the same way listRunRecords does
435
+ // below — tell "genuinely never written" from "present but broken" apart.
436
+ const fname = path.basename(runRecordPath(outRoot, runId));
437
+ if ((await findRunRecordFilenames(outRoot, fname)).length > 0) {
438
+ return refuse('record-unreadable',
439
+ `The run record for \`${runId}\` is present but could not be read or parsed. Refusing ` +
440
+ 'rather than reporting this run as if no record existed.');
441
+ }
442
+ return refuse('no-run', `No run record named \`${runId}\` is present.`);
443
+ }
444
+ return finishLoadingRecord(outRoot, rec, allowPartial);
445
+ }
446
+
447
+ const records = await listRunRecords(outRoot);
448
+
449
+ // ⚠️ UNCONDITIONAL cross-check — NOT gated on `records.length === 0`. The single-record
450
+ // topology (nothing parsed at all) is not the dangerous one; a MULTI-record out-root with one
451
+ // corrupt file is. `records.length > 0` would otherwise short-circuit past this check,
452
+ // listRunRecords()'s own `catch { /* unreadable: not a run */ }` silently drops the bad file,
453
+ // and a DIFFERENT run — the one that happened to parse — would render under a subject line
454
+ // naming a run the operator may not have wanted at all. That is the same "unreadable read as
455
+ // absent" defect this module exists to close, surviving one topology over. Comparing "every
456
+ // scan_run_*.json name on disk" against "the filename each PARSED record implies" (writeRunStart
457
+ // always names a record after its own runId, via runRecordPath) finds a corrupt sibling
458
+ // regardless of how many other records parsed fine — and does not try to guess which run was
459
+ // WANTED, because it cannot know: refusing beats guessing.
460
+ //
461
+ // This does not apply to the explicit `--run <id>` path above: that path only ever inspects the
462
+ // ONE filename it was asked for, so a different, unrelated corrupt file is invisible to it by
463
+ // construction — a deliberate ruling, not an oversight (see the "explicit --run ignores an
464
+ // unrelated corrupt sibling" fixture in the test file for the pinned behaviour).
465
+ const onDiskRunFiles = await findRunRecordFilenames(outRoot);
466
+ const parsedFilenames = new Set(records.map((r) => `scan_run_${r.runId}.json`));
467
+ const unparsedRunFiles = onDiskRunFiles.filter((n) => !parsedFilenames.has(n));
468
+ if (unparsedRunFiles.length > 0) {
469
+ return refuse('record-unreadable',
470
+ `${unparsedRunFiles.length} run record file(s) are present under this output directory but ` +
471
+ `could not be read or parsed (${unparsedRunFiles.join(', ')})` +
472
+ (records.length > 0
473
+ ? `, though ${records.length} other run record(s) parsed successfully. Refusing rather ` +
474
+ 'than silently rendering a different run under this subject line — the intended run ' +
475
+ 'cannot be told apart from the one that happened to parse.'
476
+ : '. Refusing rather than reporting this scan as if no record ever existed.'));
477
+ }
478
+
479
+ if (records.length === 0) {
480
+ const probe = await probeRawDirs(outRoot);
481
+ if (probe.dirs.length === 0) {
482
+ return refuse('no-run', `No scan run was found under ${path.basename(outRoot)}.`);
483
+ }
484
+ if (!probe.anyRunId) {
485
+ // ⚠️ Do not assert a single cause. A raw with no runId can be a scan from before run
486
+ // records existed, OR a CTEM watch-mode cycle, which deliberately writes no record
487
+ // (cli.mjs returns above the record-writing path there; CE's basicCTEM capability text
488
+ // says the alerting loop "adds no evidence retention"). Both are real; name both.
489
+ return refuse('older-format',
490
+ "This scan's raw record carries no run id, so it did not write a run record. This can " +
491
+ 'mean the scan ran under a version of NSAuditor AI that predates run records, or it came ' +
492
+ 'from a CTEM watch-mode cycle, which deliberately writes no run record. Grouping by run ' +
493
+ 'is not available, and coverage cannot be stated for it.');
494
+ }
495
+ const insideWindow = Date.now() - probe.newestMtime < CE_RETENTION_MS;
496
+ const id = probe.anyRunId;
497
+ // ⚠️ CC-5: "the record was deleted or moved" used to be asserted as THE cause, but the
498
+ // evidence on disk here (a raw host directory naming this runId, no run-record file) cannot
499
+ // tell that apart from "the record was never successfully written in the first place" —
500
+ // `writeRunStart`'s own failure is warned and swallowed (cli.mjs), and the scan proceeds
501
+ // with `opts.runId` set regardless, so a disk fault at scan START leaves exactly this shape:
502
+ // real host evidence, no run record, ever. Name both real causes rather than the one that
503
+ // sounds most confident — same discipline `older-format` above already applies.
504
+ const eitherCause = 'This can mean the record was deleted or moved after being written, or ' +
505
+ 'that it was never successfully written when the scan started (for example, a disk fault ' +
506
+ 'at scan time). Both are real; what is on disk now cannot tell them apart.';
507
+ // ⚠️ CC-6: the CE_RETENTION_MS-based age check is a REAL policy only for the CE tier —
508
+ // `pruneRunRecordsForCE` is the only place anything ever prunes on it. Gating an "older than
509
+ // the retention window" sentence on it for a non-CE tier is what produced the prior
510
+ // self-contradiction ("this scan is older than the retention window... retention is
511
+ // unlimited on this tier" — both cannot be true). So the age axis is CONSULTED only when the
512
+ // tier is actually CE; every other tier gets the same honest, age-independent disjunction a
513
+ // CE run gets while still inside its own window.
514
+ if (env.tier === 'ce') {
515
+ if (insideWindow) {
516
+ return refuse('record-absent-inside-window',
517
+ `The run record for \`${id}\` is not present, though this scan is inside the ` +
518
+ `retention window. ${eitherCause}`);
519
+ }
520
+ return refuse('record-absent-outside-window',
521
+ `The run record for \`${id}\` is not present. Community Edition keeps run records for ` +
522
+ '7 days; this scan is older than that window.');
523
+ }
524
+ return refuse('record-absent-inside-window',
525
+ `The run record for \`${id}\` is not present. ${eitherCause}`);
526
+ }
527
+
528
+ const newest = records[0].startedAt;
529
+ const tied = records.filter((r) => r.startedAt === newest);
530
+ // A subject chosen by tie-break is a subject nobody chose.
531
+ if (tied.length > 1) {
532
+ return refuse('ambiguous-run',
533
+ `Refusing to choose: ${tied.length} runs share the newest start time ${newest} — ` +
534
+ `${tied.map((r) => r.runId).join(', ')}. Name one with \`--run <runId>\`.`);
535
+ }
536
+ return finishLoadingRecord(outRoot, records[0], allowPartial);
537
+ }