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,241 @@
1
+ // The run record: one file per scan run, holding the coverage facts a report needs in order
2
+ // to state what was attempted rather than imply it.
3
+ //
4
+ // ⚠️ IT CARRIES HOSTS AND SCOPE ONLY — no credentials, no environment, NO FILESYSTEM PATHS.
5
+ // It sits in a directory a consultant zips and sends. Two shapes leak through "hosts and scope"
6
+ // if nobody stops them: `--host` accepts `user:pass@host`, so a credential arrives through the
7
+ // one field nobody classifies as a credential field; and the `--host-file` path and out root are
8
+ // environment (`/Users/<name>/…` publishes the operator's account). We hold the host LIST and
9
+ // never the path it came from.
10
+ import fsp from 'node:fs/promises';
11
+ import path from 'node:path';
12
+ import crypto from 'node:crypto';
13
+ import { CE_RETENTION_MS } from './scan_history.mjs';
14
+
15
+ export const RUN_RECORD_SCHEMA = 1;
16
+ const RUN_FILE_RE = /^scan_run_(.+)\.json$/;
17
+
18
+ // ⚠️ THE INVARIANT IS "NO CREDENTIAL, EVER" — NOT "THE HOST SURVIVES". Deliberately the
19
+ // simplest rule that can hold it: decode an encoded separator, then after the LAST '@', then cut
20
+ // at the first URL delimiter. No heuristics, because every heuristic tried here bought a leak:
21
+ // authority-first leaked `admin:1234` from `admin:1234/56@10.0.0.7` (a numeric password with
22
+ // a slash reads exactly like host:port)
23
+ // credential-first leaked `ss` from `user:p@ss/wd@host` (a '/' inside the password truncates
24
+ // the authority before the second '@' is seen)
25
+ // Two more findings were second CREDENTIAL SHAPES, not further heuristics:
26
+ // query/fragment cutting on '/' alone left `10.0.0.7?api_key=SECRET123` and
27
+ // `10.0.0.7#SECRET123` attached to the host, since neither has a '/' to cut
28
+ // on — closed by cutting on '/', '?' AND '#'.
29
+ // percent-encoded `user:pass%40host` has NO literal '@', so `lastIndexOf('@')` returned -1,
30
+ // separator took the "no userinfo" branch, and returned the WHOLE credential-bearing
31
+ // string unstripped — worse than any fragment leak, because nothing was
32
+ // stripped at all. Reachable: the record is written at scan START from
33
+ // `hostsRequested`, before DNS resolution, so a host that can never resolve
34
+ // still lands in the file. Closed by decoding `%40` to `@` before deciding.
35
+ // ACCEPTED COST, stated rather than hidden: shapes carrying a URL delimiter inside a PATH with no
36
+ // preceding userinfo — e.g. `https://user:pass@host/path@x` and `10.0.0.7/path@x` — resolve to
37
+ // the fragment after that delimiter instead of the host. A wrong host with no credential is the
38
+ // safe direction; a right host with a credential sometimes is not. None is a `--host` value
39
+ // anyone types.
40
+ //
41
+ // ⚠️ DENY-BY-DEFAULT ON THE OUTPUT, because the input side is unbounded. Five leaks in this
42
+ // function were closed by making the parse smarter, and two remained (`%2540` double-encoded,
43
+ // U+FF20 `@` fullwidth) with a whole Unicode confusables table behind them. A host token is a
44
+ // CLOSED shape — letters, digits, dots, hyphens, underscores, an optional numeric port, or a
45
+ // bracketed IPv6 literal. Anything else is not a host, so it is not written, whatever produced
46
+ // it. This is the leg that makes the CLASS closed rather than the instance closed: a future
47
+ // input nobody imagined cannot leak, because the guard no longer depends on recognising it.
48
+ //
49
+ // ⚠️ THE ACCEPTED SHAPE NEEDS AS MUCH CARE AS THE REFUSED ONE. The first version of this took
50
+ // IPv6 only in brackets and ASCII-only labels, so `::1` and `fe80::1` — which this product scans,
51
+ // see utils/net_validation.mjs — were recorded as unparseable. That is the coverage lie: a record
52
+ // that cannot name a host that WAS scanned, which is the same class of dishonesty as a leak,
53
+ // pointed the other way. Unicode letters and digits are allowed because the confusables this guard
54
+ // exists for are PUNCTUATION look-alikes (U+FF20 '@', U+2044 '⁄'), which \p{L}\p{N} excludes —
55
+ // a Cyrillic or German label is a host, not a credential.
56
+ const HOST_TOKEN_RE =
57
+ /^(?:[\p{L}\p{N}._-]+|(?=[0-9A-Fa-f]*:)[0-9A-Fa-f:.]{2,}|\[[0-9A-Fa-f:.]+(?:%[A-Za-z0-9]+)?\])(?::\d+)?$/u;
58
+ export const UNPARSEABLE = '<unparseable-host>';
59
+
60
+ export function normaliseHost(raw) {
61
+ const s0 = String(raw ?? '').trim().replace(/^[a-z][a-z0-9+.-]*:\/\//i, '');
62
+ // A legitimate host can never contain `%40`. Only an encoded SEPARATOR or an encoded '@'
63
+ // inside a password can, and both must be readable by the same rule — otherwise a
64
+ // percent-encoded separator takes the "no userinfo" branch below and the entire credential
65
+ // string is returned unstripped. Decode before deciding.
66
+ const s = s0.replace(/%40/gi, '@');
67
+ const at = s.lastIndexOf('@');
68
+ const token = (at === -1 ? s : s.slice(at + 1)).split(/[/?#]/)[0];
69
+ if (token !== '' && !HOST_TOKEN_RE.test(token)) {
70
+ // Name the FIELD, never the value — logging the rejected string would defeat the whole
71
+ // point of a guard that exists because the input side cannot be enumerated.
72
+ console.warn('[RunRecord] normaliseHost: refused a non-host-shaped token');
73
+ return UNPARSEABLE;
74
+ }
75
+ return token;
76
+ }
77
+
78
+ export function runRecordPath(outRoot, runId) {
79
+ return path.join(outRoot, `scan_run_${runId}.json`);
80
+ }
81
+
82
+ // Per-RUN filename, because an out root accumulates runs and a fixed name is silently
83
+ // overwritten — which destroys the ability to re-render an earlier deliverable.
84
+ export function newRunId() {
85
+ const t = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
86
+ return `${t}-${crypto.randomBytes(3).toString('hex')}`;
87
+ }
88
+
89
+ // ⚠️ EVERY WRITE IS WRAPPED. A report-side fault must never fail a scan: that is the whole
90
+ // promise of reading a run directory instead of rendering inline. A failure becomes the scan's
91
+ // own warning and returns null.
92
+ //
93
+ // Write to a sibling `.tmp` and rename over the target — `rename` is atomic within a
94
+ // filesystem. This record is rewritten N+2 times per run (start, one append per host,
95
+ // finalize); a direct `writeFile` that crashes mid-write leaves truncated JSON, which
96
+ // `readRunRecord` then reports as ABSENT, and every later append or finalize call silently
97
+ // returns false against a record that never actually disappeared.
98
+ async function writeJsonSafe(file, obj, what) {
99
+ const tmpFile = `${file}.tmp`;
100
+ try {
101
+ await fsp.mkdir(path.dirname(file), { recursive: true });
102
+ await fsp.writeFile(tmpFile, JSON.stringify(obj, null, 2), 'utf8');
103
+ await fsp.rename(tmpFile, file);
104
+ return file;
105
+ } catch (e) {
106
+ // `writeFile` can succeed and the following `rename` can still fail, leaving the `.tmp`
107
+ // behind — a stray file in the out root that a compliance evidence-pack walk will meet.
108
+ // Best-effort cleanup; if it was never created (mkdir/writeFile itself failed), this is a
109
+ // harmless ENOENT.
110
+ await fsp.unlink(tmpFile).catch(() => {});
111
+ console.warn(`[RunRecord] could not write ${what}: ${e?.message || e}`);
112
+ return null;
113
+ }
114
+ }
115
+
116
+ export async function writeRunStart(outRoot, rec) {
117
+ // The whole body is wrapped, not just the fs call — `writeJsonSafe` already never throws,
118
+ // but building `record` from caller input can: `(rec.hostsRequested ?? []).map(...)` throws
119
+ // synchronously if a caller passes a non-array. "returns the path, or null on a wrapped
120
+ // failure" (the interface contract) means EVERY failure, not only an fs failure.
121
+ try {
122
+ const record = {
123
+ schema: RUN_RECORD_SCHEMA,
124
+ runId: String(rec.runId),
125
+ startedAt: rec.startedAt,
126
+ finishedAt: null,
127
+ hostsRequested: (rec.hostsRequested ?? []).map(normaliseHost),
128
+ hostsWritten: [],
129
+ pluginsRequested: rec.pluginsRequested ?? [],
130
+ portsRequested: rec.portsRequested ?? null,
131
+ tier: rec.tier ?? null,
132
+ ceVersion: rec.ceVersion ?? null,
133
+ eeVersion: rec.eeVersion ?? null,
134
+ kevLoaded: Boolean(rec.kevLoaded),
135
+ kevSnapshot: rec.kevSnapshot ?? null,
136
+ epssLoaded: Boolean(rec.epssLoaded),
137
+ epssSnapshot: rec.epssSnapshot ?? null,
138
+ };
139
+ return await writeJsonSafe(runRecordPath(outRoot, record.runId), record, 'run start');
140
+ } catch (e) {
141
+ console.warn(`[RunRecord] could not write run start: ${e?.message || e}`);
142
+ return null;
143
+ }
144
+ }
145
+
146
+ // ⚠️ IN-PROCESS PER-RUN LOCK. `appendHostWritten` and `finalizeRunRecord` are both a
147
+ // read-modify-write over the SAME file, and CE ships `--parallel <n>` (cli.mjs) — real runs
148
+ // finish multiple hosts at once, in one process. Without serialization, N concurrent callers
149
+ // each read the same stale `hostsWritten`, each write back their own single addition, and the
150
+ // last write wins: N appends land as 1. A run is one process, so an in-memory promise chain
151
+ // keyed by runId is sufficient — no file lock, no new dependency. A caller's own failure does
152
+ // not wedge the queue: the chain recovers from a rejection before letting the next entry run.
153
+ const runLocks = new Map();
154
+
155
+ function withRunLock(runId, fn) {
156
+ const key = String(runId);
157
+ // `prior` is always either `Promise.resolve()` (a fresh key) or the tail wrapper stored
158
+ // below (which itself always resolves via its own `(() => {}, () => {})`) — it never
159
+ // rejects, so `fn` as the SECOND argument here is unreachable by construction. Kept
160
+ // rather than dropped: it costs nothing and stops a future edit to the tail wrapper from
161
+ // silently making `prior` capable of rejecting without this call site being revisited.
162
+ const prior = runLocks.get(key) ?? Promise.resolve();
163
+ const settled = prior.then(fn, fn);
164
+ runLocks.set(key, settled.then(() => {}, () => {}));
165
+ return settled;
166
+ }
167
+
168
+ // ⚠️ APPEND PER HOST, and this is not a style choice — it is what makes the interrupted-run
169
+ // case REACHABLE. If `hostsWritten` were populated only at finalize, an interrupted run would
170
+ // ALWAYS carry `hostsWritten: []`, so (a) the "crash after the last directory" shape the second
171
+ // refusal exists for could never be produced by this writer, and (b) far worse, `--allow-partial`
172
+ // over an interrupted run would render ZERO hosts and put "N of N requested hosts were not
173
+ // scanned" on a client's cover — FALSE, because they WERE scanned; only the record was never
174
+ // finalized. A false "not scanned" over scanned hosts is the exact false clean this record exists
175
+ // to prevent, inverted.
176
+ export async function appendHostWritten(outRoot, runId, { host, dir }) {
177
+ return withRunLock(runId, async () => {
178
+ const existing = await readRunRecord(outRoot, runId);
179
+ if (!existing) return false;
180
+ existing.hostsWritten = [...(existing.hostsWritten ?? []),
181
+ { host: normaliseHost(host), dir: path.basename(String(dir ?? '')) }];
182
+ return Boolean(await writeJsonSafe(runRecordPath(outRoot, runId), existing, 'host append'));
183
+ });
184
+ }
185
+
186
+ export async function finalizeRunRecord(outRoot, runId, opts = {}) {
187
+ // ⚠️ REFUSE THE STRAY KEY RATHER THAN IGNORE IT. This signature NARROWED — `hostsWritten` moved
188
+ // to appendHostWritten() — and an ignored option is a silent contract. The caller who gets burned
189
+ // is not one of the six that were migrated; it is the seventh, written next cycle by someone
190
+ // reading an old example, whose fixture then reads perfectly clean while recording no hosts at
191
+ // all. Fail at the call site, where the mistake is, not in a fixture that looks green.
192
+ if ('hostsWritten' in opts) {
193
+ throw new TypeError('finalizeRunRecord no longer takes hostsWritten; call appendHostWritten per host');
194
+ }
195
+ const { finishedAt } = opts;
196
+ return withRunLock(runId, async () => {
197
+ const existing = await readRunRecord(outRoot, runId);
198
+ if (!existing) return false;
199
+ existing.finishedAt = finishedAt ?? new Date().toISOString();
200
+ // ⚠️ ADDITIVE AND OPTIONAL, checked by PRESENCE (`in`), never by truthiness. A caller
201
+ // that only passes `finishedAt` (every caller before this one existed) must keep
202
+ // TODAY's behaviour — the false/null `writeRunStart` already set stands. `kevLoaded:
203
+ // false` is itself a meaningful value a caller must be able to assert ("checked, and it
204
+ // was NOT loaded for every host") — silently dropping it for being falsy would be the
205
+ // same "truthiness ate an explicit false" class of bug this repo has hit before.
206
+ if ('kevLoaded' in opts) existing.kevLoaded = Boolean(opts.kevLoaded);
207
+ if ('kevSnapshot' in opts) existing.kevSnapshot = opts.kevSnapshot ?? null;
208
+ if ('epssLoaded' in opts) existing.epssLoaded = Boolean(opts.epssLoaded);
209
+ if ('epssSnapshot' in opts) existing.epssSnapshot = opts.epssSnapshot ?? null;
210
+ return Boolean(await writeJsonSafe(runRecordPath(outRoot, runId), existing, 'run finalize'));
211
+ });
212
+ }
213
+
214
+ export async function readRunRecord(outRoot, runId) {
215
+ try { return JSON.parse(await fsp.readFile(runRecordPath(outRoot, runId), 'utf8')); }
216
+ catch { return null; }
217
+ }
218
+
219
+ export async function listRunRecords(outRoot) {
220
+ let names = [];
221
+ try { names = await fsp.readdir(outRoot); } catch { return []; }
222
+ const out = [];
223
+ for (const n of names) {
224
+ if (!RUN_FILE_RE.test(n)) continue;
225
+ try { out.push(JSON.parse(await fsp.readFile(path.join(outRoot, n), 'utf8'))); } catch { /* unreadable: not a run */ }
226
+ }
227
+ return out.sort((a, b) => String(b.startedAt ?? '').localeCompare(String(a.startedAt ?? '')));
228
+ }
229
+
230
+ // Run records fall under the SAME CE window as scan history, from the same constant — two
231
+ // retention horizons over one scan's artifacts is a discrepancy a customer would find first.
232
+ export async function pruneRunRecordsForCE(outRoot, now = Date.now()) {
233
+ const cutoff = now - CE_RETENTION_MS;
234
+ let removed = 0;
235
+ for (const rec of await listRunRecords(outRoot)) {
236
+ const t = Date.parse(rec?.startedAt ?? '');
237
+ if (!Number.isFinite(t) || t >= cutoff) continue;
238
+ try { await fsp.unlink(runRecordPath(outRoot, rec.runId)); removed += 1; } catch { /* already gone */ }
239
+ }
240
+ return removed;
241
+ }
@@ -170,7 +170,7 @@ export function computeDiff(current, previous) {
170
170
  };
171
171
  }
172
172
 
173
- const CE_RETENTION_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
173
+ export const CE_RETENTION_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
174
174
 
175
175
  /**
176
176
  * Remove JSONL entries older than 7 days from the given history file.