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.
- package/README.md +70 -3
- package/cli.mjs +443 -7
- package/package.json +1 -1
- package/utils/brand.mjs +252 -0
- package/utils/capabilities.mjs +1 -0
- package/utils/executive_report.mjs +574 -0
- package/utils/jira_export.mjs +174 -0
- package/utils/report_finding_census.mjs +123 -0
- package/utils/report_inputs.mjs +537 -0
- package/utils/run_record.mjs +241 -0
- package/utils/scan_history.mjs +1 -1
|
@@ -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
|
+
}
|
package/utils/scan_history.mjs
CHANGED
|
@@ -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.
|