nsauditor-ai 0.2.53 → 0.2.55

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,296 @@
1
+ // Tamper-EVIDENCE for run records: a SHA-256 chain, no keys, no egress, no new dependency.
2
+ //
3
+ // ⚠️ SAY WHAT IT IS NOT. A chain stored on the same disk as the records it covers is defeated by
4
+ // an attacker who recomputes every successor. This is evidence against accidental corruption,
5
+ // partial restore and unsophisticated edits — NOT tamper-proofing against host-level access, and
6
+ // NOT non-repudiation: nothing here is signed and nothing names an author. Ed25519 + custody is a
7
+ // separate, heavier property. The delta engine carries that sentence into its own output, because
8
+ // a limit that lives only in documentation is a limit the reader never meets.
9
+ //
10
+ // ⚠️ NO CANONICALISER, DELIBERATELY. The digest covers the EXACT BYTES ON DISK — read back, never
11
+ // re-serialised from the in-memory object. Two recorded incidents say why: a hand-rolled
12
+ // canonicaliser that serialised `new Date()` as `{}` (signer and verifier shared the bug, so it
13
+ // was invisible in both directions), and the 2026-09-14 sample pack whose envelope digests were
14
+ // computed over RAW bytes while the archive shipped SANITIZED ones — 72 of 96 digests named bytes
15
+ // that were never in the archive. Hash what you PERSIST.
16
+ import fsp from 'node:fs/promises';
17
+ import path from 'node:path';
18
+ import crypto from 'node:crypto';
19
+ // ⚠️ SELF-CONTAINED BY NECESSITY: `run_record` imports the sealer from here, so importing the
20
+ // path helper back from it would be a cycle. The filename shape is one line and is pinned by
21
+ // the tests in both modules.
22
+ const runRecordPath = (outRoot, runId) => path.join(outRoot, `scan_run_${runId}.json`);
23
+
24
+ const RUN_FILE_RE = /^scan_run_(.+)\.json$/;
25
+ const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
26
+
27
+ export function chainDigestPath(outRoot, runId) {
28
+ return path.join(outRoot, `scan_run_${runId}.sha256`);
29
+ }
30
+
31
+ // ⚠️ THE CHAIN COVERED THE INDEX AND NOT THE EVIDENCE, and the two are different files. The run
32
+ // record carries no findings; the delta reads THESE, in each written host's directory. Until
33
+ // 2026-09-20 nothing hashed them, so deleting a finding from a sealed baseline left the verdict at
34
+ // `chain-verified` while the comparison moved — and five published surfaces said an altered
35
+ // baseline is detected and refused. Hash what the CONSUMER reads, not merely what the writer
36
+ // indexes.
37
+ export const HOST_EVIDENCE_FILES = Object.freeze(['scan_conclusion_raw.json', 'scan_finding_queue.json']);
38
+
39
+ /**
40
+ * Digest every evidence file of one written host directory, for sealing INTO the record.
41
+ * ⚠️ AN ABSENT FILE IS SEALED AS AN EXPLICIT `null`, NEVER OMITTED and never a fabricated digest.
42
+ * Omitting it would make its later APPEARANCE invisible, and adding a `scan_finding_queue.json` to
43
+ * a sealed baseline injects findings into the comparison — which moves rows out of `resolved` or
44
+ * into `new` just as surely as editing one does.
45
+ * A file that exists but cannot be READ is neither: it is left out and warned, because recording
46
+ * it as absent would accuse it of appearing later, and this module's rule is that not-measured
47
+ * must never render as tampering.
48
+ */
49
+ export async function digestHostEvidence(outRoot, dir) {
50
+ const out = {};
51
+ for (const name of HOST_EVIDENCE_FILES) {
52
+ try {
53
+ out[name] = sha256(await fsp.readFile(path.join(outRoot, dir, name)));
54
+ } catch (e) {
55
+ if (e?.code === 'ENOENT') out[name] = null;
56
+ else console.warn(`[RunChain] could not digest ${dir}/${name}: ${e?.message || e}`);
57
+ }
58
+ }
59
+ return out;
60
+ }
61
+
62
+ // Sealed at FINALIZE and never before: the record is rewritten N+2 times per run (start, one
63
+ // append per host, finalize), so a digest taken earlier names bytes that are meant to change.
64
+ export async function sealRunRecord(outRoot, runId) {
65
+ try {
66
+ const digest = sha256(await fsp.readFile(runRecordPath(outRoot, runId)));
67
+ await fsp.writeFile(chainDigestPath(outRoot, runId), `${digest}\n`, 'utf8');
68
+ return digest;
69
+ } catch (e) {
70
+ console.warn(`[RunChain] could not seal run record: ${e?.message || e}`);
71
+ return null;
72
+ }
73
+ }
74
+
75
+ // The digest of the most recently finalized record in this out root, for the NEXT run to carry as
76
+ // its `prevDigest`. Null when there is none — a first run is not a broken chain.
77
+ // ⚠️ ONE ORDERING FOR THE CHAIN AND FOR `--since prior`, AND IT KEYS ON THE RECORD'S CONTENTS.
78
+ // This used to sort run-record FILENAMES lexically and take the last. `newRunId()` is
79
+ // `<ISO-to-the-SECOND>-<6 random hex>`, so for two records written inside the same second the
80
+ // predecessor was decided by the RANDOM SUFFIX — the chain did not reliably reflect run order,
81
+ // and `--since prior` resolves its baseline through that chain while the view's own comment says
82
+ // silently changing a comparison's subject is the one thing the command does not do. A filename
83
+ // sort is wrong at EVERY second boundary even with a monotonic id, which is why the repair is the
84
+ // key and not the id. `startedAt` is millisecond-precise (`new Date().toISOString()`) and is
85
+ // already what `listRunRecords` sorts on, so this ADOPTS that ordering rather than inventing one.
86
+ //
87
+ // STRICTLY PRECEDES: two runs that started in the same millisecond have no recoverable order, so
88
+ // neither may claim the other — inventing one would be the same defect wearing a better key. Among
89
+ // records sharing the newest qualifying `startedAt` the tie breaks on `runId`: arbitrary, but
90
+ // DETERMINISTIC, which is the property that was missing.
91
+ export function predecessorOf(records, startedAt) {
92
+ const before = (records ?? []).filter((r) => r?.startedAt && String(r.startedAt) < String(startedAt));
93
+ if (!before.length) return null;
94
+ return before.sort((a, b) => String(b.startedAt).localeCompare(String(a.startedAt))
95
+ || String(b.runId ?? '').localeCompare(String(a.runId ?? '')))[0];
96
+ }
97
+
98
+ // ⚠️ READS THE RECORDS RATHER THAN IMPORTING `listRunRecords`: `run_record.mjs` imports THIS
99
+ // module, so importing it back would be a cycle. The parse is the same one, kept local.
100
+ async function readRunRecords(outRoot) {
101
+ let names = [];
102
+ try { names = await fsp.readdir(outRoot); } catch { return []; }
103
+ const out = [];
104
+ for (const n of names) {
105
+ if (!RUN_FILE_RE.test(n)) continue;
106
+ try { out.push(JSON.parse(await fsp.readFile(path.join(outRoot, n), 'utf8'))); } catch { /* not a run */ }
107
+ }
108
+ return out;
109
+ }
110
+
111
+ /**
112
+ * Every run-record digest in this out root, hashed ONCE. `verifyRunChain` resolves `linkedTo`
113
+ * existentially — "does any record here still hash to it?" — and doing that per call makes a
114
+ * caller that verifies N records do N passes over N files.
115
+ *
116
+ * ⚠️ THE COST IS ON THE REFUSAL PATH, which is why it is worth closing even though nothing is
117
+ * wrong: `scan_delta_view` verifies every OTHER record only when it is already refusing, so the
118
+ * command got slowest exactly when a baseline was broken and an operator was waiting on the
119
+ * answer. Same "worst precisely when it matters" shape this repo records about gate:cascade's
120
+ * partial-fetch false clean — there it cost correctness, here only time.
121
+ *
122
+ * Pass the result as `opts.linkIndex` to `verifyRunChain`. Omitting it is unchanged behaviour,
123
+ * so no caller is obliged to know about this.
124
+ */
125
+ export async function buildRunRecordDigestIndex(outRoot) {
126
+ const index = new Set();
127
+ let names = [];
128
+ try { names = await fsp.readdir(outRoot); } catch { return index; }
129
+ for (const n of names) {
130
+ if (!RUN_FILE_RE.test(n)) continue;
131
+ try { index.add(sha256(await fsp.readFile(path.join(outRoot, n)))); } catch { /* unreadable: not a link target */ }
132
+ }
133
+ return index;
134
+ }
135
+
136
+ /**
137
+ * The digest a run starting at `startedAt` should link to: the most recent SEALED record that
138
+ * started before it. SEALED is required here and only here — the chain can only link to a digest.
139
+ * `--since prior` deliberately does NOT skip an unsealed record: skipping would compare against an
140
+ * older run silently, while using it merely earns the disclosed `baseline-unchained` limit.
141
+ * Disclose, never substitute.
142
+ * ⚠️ `startedAt` is REQUIRED. Called without it the old whole-tree "latest" is not recoverable
143
+ * from a correct ordering, so it returns null rather than guessing — a wrong link is worse than
144
+ * an absent one, and an absent one reads as a chain head.
145
+ */
146
+ export async function latestSealedDigest(outRoot, startedAt) {
147
+ if (!startedAt) return null;
148
+ const sealed = [];
149
+ for (const rec of await readRunRecords(outRoot)) {
150
+ if (!rec?.runId) continue;
151
+ try {
152
+ const d = (await fsp.readFile(chainDigestPath(outRoot, rec.runId), 'utf8')).trim();
153
+ if (d) sealed.push(rec);
154
+ } catch { /* unsealed: cannot be linked to */ }
155
+ }
156
+ const prev = predecessorOf(sealed, startedAt);
157
+ if (!prev) return null;
158
+ try { return (await fsp.readFile(chainDigestPath(outRoot, prev.runId), 'utf8')).trim() || null; }
159
+ catch { return null; }
160
+ }
161
+
162
+ /**
163
+ * @returns {{status:'chain-verified'|'chain-broken'|'chain-absent'|'chain-unreadable', reason:string,
164
+ * digest?:string, linkedTo?:string|null, linkBroken?:boolean}}
165
+ * ⚠️ FOUR STATES, NOT TWO, AND THE DISTINCTION IS THE POINT. `chain-absent` (a record written
166
+ * before this shipped) and `chain-unreadable` (we could not measure) are NOT `chain-broken`.
167
+ * Rendering "nothing was checked" as "tampering" accuses honest evidence; rendering it as
168
+ * "verified" is the false clean. Both are their own verdict.
169
+ */
170
+ export async function verifyRunChain(outRoot, runId, opts = {}) {
171
+ let bytes;
172
+ try { bytes = await fsp.readFile(runRecordPath(outRoot, runId)); }
173
+ catch { return { status: 'chain-unreadable', reason: 'the run record itself could not be read' }; }
174
+
175
+ // ⚠️ "PREDATES CHAINING" AND "WAS UNSEALED" ARE DIFFERENT FACTS, AND THEY ARE SEPARABLE
176
+ // DETERMINISTICALLY. This returned `chain-absent` for ANY missing sidecar — so deleting the
177
+ // sidecar, which is the unsophisticated edit the label claims to catch, downgraded a REFUSAL
178
+ // into a limit with a FALSE CAUSE: it told the operator the record was old. Every record
179
+ // `writeRunStart` has produced since chaining carries the `prevDigest` KEY, so a record that
180
+ // has the key and no sidecar was sealed and then unsealed. The key is checked rather than the
181
+ // sidecar's absence, so a genuinely pre-chaining record keeps its honest `chain-absent`.
182
+ let recorded;
183
+ try { recorded = (await fsp.readFile(chainDigestPath(outRoot, runId), 'utf8')).trim(); }
184
+ catch {
185
+ let parsed = null;
186
+ try { parsed = JSON.parse(bytes.toString('utf8')); } catch { /* unparseable: treat as pre-chaining */ }
187
+ if (parsed && typeof parsed === 'object' && 'prevDigest' in parsed) {
188
+ return { status: 'chain-unreadable',
189
+ reason: 'this record was written after chaining shipped (it carries a prevDigest) and its digest '
190
+ + 'sidecar is gone — removed, or never written; alteration can neither be confirmed nor ruled out' };
191
+ }
192
+ return { status: 'chain-absent', reason: 'no digest sidecar — this record predates chained run records' };
193
+ }
194
+
195
+ if (!/^[0-9a-f]{64}$/i.test(recorded)) {
196
+ return { status: 'chain-unreadable', reason: 'the digest sidecar is present but is not a SHA-256 digest' };
197
+ }
198
+ const actual = sha256(bytes);
199
+ if (actual !== recorded.toLowerCase()) {
200
+ return { status: 'chain-broken', digest: actual, reason: 'the run record does not match the digest recorded when it was sealed' };
201
+ }
202
+
203
+ // ── THE EVIDENCE, not just the index. Verified only after the record's own digest matches,
204
+ // because the digests being checked are READ FROM that record: checking them first would be
205
+ // trusting bytes that have not been vouched for yet.
206
+ let parsed = null;
207
+ try { parsed = JSON.parse(bytes.toString('utf8')); } catch { /* the digest matched, so this is a parse we can survive */ }
208
+ const written = Array.isArray(parsed?.hostsWritten) ? parsed.hostsWritten : [];
209
+ // TWO POPULATIONS, COUNTED SEPARATELY — see the coverage clause below for why one number lies.
210
+ let filesPresent = 0;
211
+ let filesAbsent = 0;
212
+ let hostsUncovered = 0;
213
+ for (const h of written) {
214
+ if (!h?.digests || typeof h.digests !== 'object') { hostsUncovered += 1; continue; }
215
+ for (const [name, expected] of Object.entries(h.digests)) {
216
+ const where = `${h.dir}/${name}`;
217
+ let actual = null;
218
+ try {
219
+ actual = sha256(await fsp.readFile(path.join(outRoot, h.dir, name)));
220
+ } catch (e) {
221
+ // ⚠️ ENOENT IS A MEASUREMENT ("it is not there"); anything else is a FAILURE to measure,
222
+ // and the two must not share a verdict. Reporting an unreadable file as missing would
223
+ // accuse honest evidence of having been deleted.
224
+ if (e?.code !== 'ENOENT') {
225
+ return { status: 'chain-unreadable',
226
+ reason: `the findings file ${where} is sealed with this record but could not be read, so alteration could neither be confirmed nor ruled out` };
227
+ }
228
+ }
229
+ if (expected === null && actual !== null) {
230
+ return { status: 'chain-broken',
231
+ reason: `the findings file ${where} did not exist when this record was sealed and exists now — findings added to a baseline after the fact change the comparison exactly as edited ones do` };
232
+ }
233
+ if (expected !== null && actual === null) {
234
+ return { status: 'chain-broken',
235
+ reason: `the findings file ${where} was sealed with this record and is now missing` };
236
+ }
237
+ if (expected !== null && actual !== expected) {
238
+ return { status: 'chain-broken', digest: actual,
239
+ reason: `the findings file ${where} does not match the digest sealed with the record` };
240
+ }
241
+ if (expected === null) filesAbsent += 1; else filesPresent += 1;
242
+ }
243
+ }
244
+
245
+ // The LINK. `prevDigest` names a digest, not an id, so the check is existential: does any record
246
+ // in this out root still hash to it? If none does, this record vouches for bytes that no longer
247
+ // exist — which is what a rewritten predecessor looks like from here.
248
+ const linkedTo = parsed?.prevDigest ?? null;
249
+ let linkBroken = false;
250
+ if (linkedTo) {
251
+ // ⚠️ AN INDEX IF THE CALLER SHARES ONE, THE SAME SCAN IF NOT. A caller verifying many records
252
+ // builds it once (`buildRunRecordDigestIndex`); a caller verifying one pays nothing extra and
253
+ // needs to know nothing. The two paths must answer identically — `tests/run_chain_link_cost`
254
+ // asserts the verdicts alongside the cost, because a cheaper loop that verifies less would
255
+ // satisfy a pure counting assertion perfectly.
256
+ if (opts?.linkIndex) {
257
+ linkBroken = !opts.linkIndex.has(linkedTo);
258
+ } else {
259
+ linkBroken = true;
260
+ let names = [];
261
+ try { names = await fsp.readdir(outRoot); } catch { /* unreadable dir */ }
262
+ for (const n of names) {
263
+ if (!RUN_FILE_RE.test(n)) continue;
264
+ try {
265
+ if (sha256(await fsp.readFile(path.join(outRoot, n))) === linkedTo) { linkBroken = false; break; }
266
+ } catch { /* skip */ }
267
+ }
268
+ }
269
+ }
270
+ // ⚠️ THE REASON STATES ITS OWN COVERAGE, because `chain-verified` over a record that sealed no
271
+ // findings files claims more than it measured — and the surfaces that quote this verdict say an
272
+ // altered BASELINE is refused, which is a claim about the findings.
273
+ // ⚠️ TWO POPULATIONS, NAMED SEPARATELY, AND THE FIRST DRAFT COLLAPSED THEM INTO ONE NUMBER THAT
274
+ // OVERSTATED. It read "6 sealed findings file(s)" on the first REAL record this clause ever
275
+ // described — a three-host cloud run where only THREE findings files exist and the other three
276
+ // entries are recorded ABSENCES sealed as `null`. A reader counts six files on disk; there are
277
+ // three. That is the count-that-says-more-than-it-measured class, inside the very sentence added
278
+ // to stop `chain-verified` from overclaiming, which is why it is named rather than rounded off.
279
+ const coverage = hostsUncovered > 0
280
+ ? `; the findings files of ${hostsUncovered} written host(s) were NOT covered by this record `
281
+ + '(it predates per-host sealing), so alteration of those files could not be ruled out'
282
+ : (written.length
283
+ ? `, together with ${filesPresent} findings file(s) verified · ${filesAbsent} recorded absent, `
284
+ + `verified still absent, across ${written.length} written host(s)`
285
+ : '');
286
+ return {
287
+ status: 'chain-verified',
288
+ digest: actual,
289
+ linkedTo,
290
+ linkBroken,
291
+ filesPresent,
292
+ filesAbsent,
293
+ hostsUncovered,
294
+ reason: `the record matches the digest recorded when it was sealed${coverage}`,
295
+ };
296
+ }
@@ -11,6 +11,7 @@ import fsp from 'node:fs/promises';
11
11
  import path from 'node:path';
12
12
  import crypto from 'node:crypto';
13
13
  import { CE_RETENTION_MS } from './scan_history.mjs';
14
+ import { sealRunRecord, latestSealedDigest, digestHostEvidence } from './run_chain.mjs';
14
15
 
15
16
  export const RUN_RECORD_SCHEMA = 1;
16
17
  const RUN_FILE_RE = /^scan_run_(.+)\.json$/;
@@ -131,10 +132,26 @@ export async function writeRunStart(outRoot, rec) {
131
132
  tier: rec.tier ?? null,
132
133
  ceVersion: rec.ceVersion ?? null,
133
134
  eeVersion: rec.eeVersion ?? null,
135
+ // Additive (MINOR per contract-v1 §9; the run record is not a frozen surface). Carries the
136
+ // digest of the previous finalized record in this out root, so the chain links. Null on a
137
+ // first run and on any run written before chaining shipped — neither is a broken chain.
138
+ // ⚠️ DEFAULTED HERE, NOT AT THE CALL SITE. The first version of this required every caller
139
+ // to pass `prevDigest` and to call `sealRunRecord` after finalizing — and the one shipped
140
+ // caller did neither, so `report --since` read a chain nothing ever wrote while three
141
+ // release surfaces said run records ARE sealed and chained. A contract a caller can forget
142
+ // is one a caller will forget; an explicit value still wins.
143
+ // ⚠️ `startedAt` IS PASSED, and that is the whole C2/C8 repair: the predecessor is the
144
+ // most recent SEALED record that STARTED before this one, never the one whose FILENAME
145
+ // sorts last. `newRunId()` is second-granular with a random suffix, so the old sort chose
146
+ // at random among runs started in the same second.
147
+ prevDigest: rec.prevDigest ?? await latestSealedDigest(outRoot, rec.startedAt),
134
148
  kevLoaded: Boolean(rec.kevLoaded),
135
149
  kevSnapshot: rec.kevSnapshot ?? null,
136
150
  epssLoaded: Boolean(rec.epssLoaded),
137
151
  epssSnapshot: rec.epssSnapshot ?? null,
152
+ // Additive (Enterprise 1.1.0 build 5, board item 23): WHERE the NVD store and response cache
153
+ // this run read live, and what the cache held when the run found it. Set at finalize.
154
+ nvdCache: rec.nvdCache ?? null,
138
155
  };
139
156
  return await writeJsonSafe(runRecordPath(outRoot, record.runId), record, 'run start');
140
157
  } catch (e) {
@@ -173,12 +190,25 @@ function withRunLock(runId, fn) {
173
190
  // scanned" on a client's cover — FALSE, because they WERE scanned; only the record was never
174
191
  // finalized. A false "not scanned" over scanned hosts is the exact false clean this record exists
175
192
  // to prevent, inverted.
176
- export async function appendHostWritten(outRoot, runId, { host, dir }) {
193
+ export async function appendHostWritten(outRoot, runId, { host, dir, scopeScanned = null }) {
177
194
  return withRunLock(runId, async () => {
178
195
  const existing = await readRunRecord(outRoot, runId);
179
196
  if (!existing) return false;
197
+ const normalised = normaliseHost(host);
180
198
  existing.hostsWritten = [...(existing.hostsWritten ?? []),
181
- { host: normaliseHost(host), dir: path.basename(String(dir ?? '')) }];
199
+ { host: normalised, dir: path.basename(String(dir ?? '')) }];
200
+ // ⚠️ WHAT THIS HOST ACTUALLY COVERED, recorded HERE and not at writeRunStart — because at
201
+ // START nothing has resolved yet, and the flag is not the answer. `--aws-region` is often
202
+ // omitted, and the set the scan then discovers and uses is the only honest subject. The host
203
+ // IS the provider on a cloud scan, so a per-host append is a per-provider record for free.
204
+ //
205
+ // ⚠️ MEASURED CONSEQUENCE OF NOT HAVING THIS: two live AWS passes differing only in
206
+ // `--aws-region` reported EIGHT unremediated `eu-west-1` findings as RESOLVED — GuardDuty
207
+ // NOT ENABLED, Inspector2 DISABLED, default EBS encryption DISABLED. Narrowing a follow-up
208
+ // scan is a normal operator action, and nothing in the record could tell it from a fix.
209
+ if (scopeScanned && typeof scopeScanned === 'object') {
210
+ existing.scopeScanned = { ...(existing.scopeScanned ?? {}), [normalised]: scopeScanned };
211
+ }
182
212
  return Boolean(await writeJsonSafe(runRecordPath(outRoot, runId), existing, 'host append'));
183
213
  });
184
214
  }
@@ -207,7 +237,31 @@ export async function finalizeRunRecord(outRoot, runId, opts = {}) {
207
237
  if ('kevSnapshot' in opts) existing.kevSnapshot = opts.kevSnapshot ?? null;
208
238
  if ('epssLoaded' in opts) existing.epssLoaded = Boolean(opts.epssLoaded);
209
239
  if ('epssSnapshot' in opts) existing.epssSnapshot = opts.epssSnapshot ?? null;
210
- return Boolean(await writeJsonSafe(runRecordPath(outRoot, runId), existing, 'run finalize'));
240
+ if ('nvdCache' in opts) existing.nvdCache = opts.nvdCache ?? null;
241
+ // ⚠️ AN ABORTED RUN IS FINALISED AND SEALED TOO, WITH A STATUS (board item 2, CE 0.2.55). A scan
242
+ // that threw after its start record was written — the SSRF guard refusing an RFC1918 host is the
243
+ // measured instance — used to leave `finishedAt: null` and no sidecar, and every later report then
244
+ // labelled that honest refusal `chain-unreadable`: the verdict a STRIPPED sidecar earns. `status`
245
+ // is `finished` or `aborted`, and an aborted run records WHY. A true crash still leaves the record
246
+ // open; nothing can finalise from inside a process that no longer runs.
247
+ if ('status' in opts) existing.status = opts.status ?? null;
248
+ if ('abortReason' in opts) existing.abortReason = opts.abortReason ?? null;
249
+ // ⚠️ SEAL THE EVIDENCE, NOT ONLY THE INDEX — and do it HERE, before the record is written, so
250
+ // the digests are inside the bytes that `sealRunRecord` then covers. Sealing them afterwards
251
+ // would leave them vouched for by nothing, which is the defect one level up: a digest nobody
252
+ // digests. The delta reads the per-host findings files, not this record's contents, so a chain
253
+ // that stops at this file cannot support the published claim that an altered baseline is
254
+ // refused. Failure to digest one file is warned inside the helper and never fatal, for the
255
+ // same reason sealing is: a report-side fault must not fail a scan.
256
+ for (const h of (existing.hostsWritten ?? [])) {
257
+ if (h?.dir) h.digests = await digestHostEvidence(outRoot, h.dir);
258
+ }
259
+ const wrote = Boolean(await writeJsonSafe(runRecordPath(outRoot, runId), existing, 'run finalize'));
260
+ // SEALED HERE, and only here: the record is rewritten N+2 times per run, so a digest taken
261
+ // any earlier names bytes that are meant to change. Failure to seal is warned and never fatal —
262
+ // a report-side fault must not fail a scan, which is the whole promise of this file.
263
+ if (wrote) await sealRunRecord(outRoot, runId);
264
+ return wrote;
211
265
  });
212
266
  }
213
267
 
@@ -239,3 +293,49 @@ export async function pruneRunRecordsForCE(outRoot, now = Date.now()) {
239
293
  }
240
294
  return removed;
241
295
  }
296
+
297
+ /**
298
+ * THE NVD LOCATION A RUN READ, aggregated across its written hosts (Enterprise 1.1.0 build 5, board
299
+ * item 23). The CVE set a scan reports depends on which NVD store and response cache it consulted, and
300
+ * that was a hidden, cwd-relative input nothing recorded. Enterprise now reports it per host
301
+ * (`nvdStore.path` · `source` · `state` · `responseCache`); this is the run-level claim, and it is
302
+ * ALL-OR-NOTHING for the same reason `aggregateStoreLoad` is: a location true for some hosts and wrong
303
+ * for others is worse than none.
304
+ * · no host reported one (Community alone, or enrichment never ran) → null, and nothing to say;
305
+ * · some hosts did and some did not → null, with a warning naming the counts;
306
+ * · the hosts disagree on path or source → null, with a warning naming them;
307
+ * · they agree → the location, with `state` and `responseCache` from the FIRST written host: the
308
+ * cache as this run found it, before any host of the run wrote to it.
309
+ * @param {Array<object|null>} perHost - each written host's reported NVD location, in written order
310
+ * @returns {{ value: object|null, warning: string|null }}
311
+ */
312
+ export function aggregateNvdCache(perHost) {
313
+ const all = Array.isArray(perHost) ? perHost : [];
314
+ const reported = all.filter((v) => v && typeof v.path === 'string' && v.path.length > 0);
315
+ if (reported.length === 0) return { value: null, warning: null };
316
+ if (reported.length < all.length) {
317
+ return { value: null, warning: `NVD location was reported for only ${reported.length} of ${all.length} `
318
+ + 'written host(s) — recording it as unknown for this run.' };
319
+ }
320
+ const distinct = [...new Set(reported.map((v) => `${v.path} (${v.source ?? 'unknown'})`))];
321
+ if (distinct.length > 1) {
322
+ return { value: null, warning: `NVD location disagreed across written hosts (${distinct.join(', ')}) — `
323
+ + 'recording it as unknown for this run.' };
324
+ }
325
+ const first = reported[0];
326
+ return {
327
+ value: { path: first.path, source: first.source ?? null, state: first.state ?? null,
328
+ responseCache: first.responseCache ?? null },
329
+ warning: null,
330
+ };
331
+ }
332
+
333
+ /**
334
+ * The reason an aborted run records: the error's code (else its name) and the FIRST line of its
335
+ * message, capped at 300 characters — never a stack, which carries local paths (board item 2 (iv)).
336
+ */
337
+ export function abortReasonOf(err) {
338
+ const code = err?.code ?? err?.name ?? 'Error';
339
+ const first = String(err?.message ?? err).split('\n')[0].slice(0, 300);
340
+ return `${code}: ${first}`;
341
+ }