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.
- package/README.md +176 -565
- package/cli.mjs +271 -37
- package/mcp_server.mjs +27 -16
- package/package.json +2 -2
- package/plugin_manager.mjs +105 -13
- package/plugins/040_tls_cert_auditor.mjs +48 -0
- package/plugins/mcp_scanner.mjs +12 -0
- package/plugins/port_scanner.mjs +27 -23
- package/plugins/upnp_scanner.mjs +88 -9
- package/utils/capabilities.mjs +10 -8
- package/utils/cloud_finding_summary.mjs +1 -1
- package/utils/cloud_providers.mjs +90 -0
- package/utils/delta_reporter.mjs +8 -1
- package/utils/executive_report.mjs +100 -1
- package/utils/finding_identity.mjs +127 -0
- package/utils/host_iterator.mjs +8 -5
- package/utils/report_inputs.mjs +273 -17
- package/utils/run_chain.mjs +296 -0
- package/utils/run_record.mjs +103 -3
- package/utils/scan_delta.mjs +1122 -0
- package/utils/scan_delta_view.mjs +218 -0
- package/utils/scan_history.mjs +92 -2
|
@@ -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
|
+
}
|
package/utils/run_record.mjs
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
+
}
|