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,218 @@
1
+ // `report --since` — baseline SELECTION and the operator-facing delta view.
2
+ //
3
+ // ⚠️ THE BASELINE SELECTION IS ITSELF A CLAIM, which is why this module names it rather than
4
+ // silently diffing against it. If the selected run was narrow — a `--plugins` subset, one host,
5
+ // an interrupted run — the delta is technically correct and practically a wall of NOT-COMPARABLE,
6
+ // and an operator who meets that twice stops reading the feature. So the baseline's id, timestamp
7
+ // and scope are printed BEFORE any verdict.
8
+ //
9
+ // ⚠️ AND REFUSING IS NOT STONEWALLING. When the selected baseline cannot support a verdict, this
10
+ // says which earlier records exist and which of them are chain-verified — but it NEVER
11
+ // auto-falls-back to one. Falling back silently changes the SUBJECT of the comparison, which is
12
+ // the single thing the delta engine exists not to do.
13
+ import { listRunRecords, readRunRecord } from './run_record.mjs';
14
+ import { verifyRunChain, predecessorOf, buildRunRecordDigestIndex } from './run_chain.mjs';
15
+ import { buildScanDelta, SCAN_DELTA_SCHEMA, CURRENT_UNCHAINED, CURRENT_CHAIN_LINK_BROKEN } from './scan_delta.mjs';
16
+ import { loadRun } from './report_inputs.mjs';
17
+
18
+ // ⚠️ WRITTEN, NOT REQUESTED — AND BOTH WHEN THEY DIFFER (board C5). This counted
19
+ // `hostsRequested`, while the bucketing in `scan_delta.mjs` counts `hostsWritten`: G4's rule is
20
+ // that scope is what a run WROTE, and it reached the verdicts without reaching the sentence that
21
+ // EXPLAINS them. An interrupted baseline therefore printed `scope 2 host(s)` directly above
22
+ // `host-not-scanned: host 10.0.0.9 was not scanned in the other run` — the line whose whole job
23
+ // is to make that row make sense was the line that made it look like a bug. Same shape as G8,
24
+ // where a per-row basis contradicted the limits block on the same page.
25
+ //
26
+ // Naming the gap rather than just correcting the number, because `1 of 2` is the fact an
27
+ // operator needs: the baseline did not finish, which is why rows below are not comparable.
28
+ const scopeOf = (rec) => {
29
+ const written = (rec?.hostsWritten ?? []).length;
30
+ const requested = (rec?.hostsRequested ?? []).length;
31
+ const plugins = (rec?.pluginsRequested ?? []).join(', ') || 'none recorded';
32
+ const hosts = (requested && requested !== written)
33
+ ? `${written} of ${requested} host(s) written`
34
+ : `${written} host(s)`;
35
+ return `${hosts} · plugins ${plugins}`;
36
+ };
37
+
38
+ /** `prior` = the record immediately before the current one; otherwise an explicit runId. */
39
+ export async function resolveBaseline(outRoot, currentRunId, since, records = null) {
40
+ // `records` lets a caller that already has the list share it; omitting it reads them, so no
41
+ // caller is obliged to know. Same shape as `verifyRunChain`'s `linkIndex`.
42
+ const all = records ?? await listRunRecords(outRoot); // newest startedAt first
43
+ if (since === 'prior') {
44
+ // ⚠️ THE SAME ORDERING THE CHAIN USES, and previously it was not. `all[i + 1]` is a POSITION
45
+ // in a list whose sort key is `startedAt` — and `localeCompare` returns 0 for two records
46
+ // that share one, so ties fell through to `Array.prototype.sort`'s stability, i.e. to readdir
47
+ // order, i.e. to the filename. The chain picked by filename too, but by a DIFFERENT filename
48
+ // rule, so the two could name different records for the same run. One ordering now, keyed on
49
+ // the record's contents, with ties broken deterministically.
50
+ // ⚠️ AND IT DOES NOT REQUIRE THE PREDECESSOR TO BE SEALED, unlike the chain. Skipping an
51
+ // unsealed record would compare against an OLDER run without saying so — the subject
52
+ // substitution this command refuses — while comparing against an unsealed baseline merely
53
+ // earns the disclosed `baseline-unchained` limit. Disclose, never substitute.
54
+ const cur = all.find((r) => r?.runId === currentRunId);
55
+ return predecessorOf(all, cur?.startedAt ?? all[0]?.startedAt) ?? null;
56
+ }
57
+ return all.find((r) => r?.runId === since) ?? null;
58
+ }
59
+
60
+ // ⚠️ TWO KINDS OF EXIT 2, AND THE ARTIFACT IS WHAT DISTINGUISHES THEM (board C9).
61
+ // WITH a delta — the requested COMPARISON was refused. The report still renders, and the
62
+ // client artifact carries the refusal and NO verdict rows.
63
+ // WITHOUT a delta — the REQUEST or the RUN ITSELF was refused. There is nothing honest to
64
+ // render at all, so nothing is written and any pre-existing `--out` file is
65
+ // NAMED as stale by the caller rather than left to pass as this run's output.
66
+ // Could-not-measure stays loud either way: the exit is 2, never 0.
67
+ const refusedComparison = (out, err, reason, detail) => ({
68
+ code: 2, out, err,
69
+ delta: { schema: SCAN_DELTA_SCHEMA, comparable: false, refusal: { reason, detail },
70
+ newFindings: [], resolved: [], unchanged: [], changed: [], notComparable: [],
71
+ baselineIntegrity: null, currentIntegrity: null, limits: [detail] },
72
+ });
73
+
74
+ export async function buildSinceView({ outRoot, model, since, allowPartial, tier }) {
75
+ const out = [];
76
+ const err = [];
77
+ // Hashed ONCE for this whole invocation and shared with every `verifyRunChain` below. Without
78
+ // it each call rescans and rehashes every record in the out root, and the two alternatives
79
+ // loops call it once per record — N passes over N files, on the refusal path.
80
+ const linkIndex = await buildRunRecordDigestIndex(outRoot);
81
+ // Read ONCE too. Three call sites each read every record in the out root — `resolveBaseline`
82
+ // and both alternatives loops — so the record list was walked three times per invocation on
83
+ // top of the per-call hashing.
84
+ const records = await listRunRecords(outRoot);
85
+ const baseRec = await resolveBaseline(outRoot, model.runId, since, records);
86
+ if (!baseRec) {
87
+ err.push(`[report] --since ${since}: no such run record in ${outRoot}. `
88
+ + 'Refusing rather than comparing against an arbitrary run.');
89
+ return { code: 2, out, err };
90
+ }
91
+
92
+ // Name it FIRST — before verifying it, before any verdict. A refusal about a named baseline is
93
+ // actionable; a refusal about an unnamed one is not.
94
+ out.push(`[report] baseline: runId ${baseRec.runId} · started ${baseRec.startedAt} · scope ${scopeOf(baseRec)}`);
95
+
96
+ const chain = await verifyRunChain(outRoot, baseRec.runId, { linkIndex });
97
+ if (chain.status === 'chain-broken' || chain.status === 'chain-unreadable') {
98
+ // ⚠️ THE DECLARED CODE TRAVELS WITH THE PROSE. This refusal used to name only
99
+ // `chain.status`, so the vocabulary an operator could grep for (`baseline-chain-broken`,
100
+ // `baseline-integrity-unmeasurable`) appeared NOWHERE on any surface, while `buildScanDelta`
101
+ // carried both codes in branches this refusal made unreachable. Two names for one event, and
102
+ // the declared one was the unreachable one — found by the outcome census.
103
+ err.push(`[report] REFUSED: ${chain.status === 'chain-broken' ? 'baseline-chain-broken' : 'baseline-integrity-unmeasurable'}`
104
+ + ` — the baseline is ${chain.status}: ${chain.reason}. `
105
+ + 'No finding can be called resolved against a baseline that may have been altered.');
106
+ const others = records
107
+ .filter((r) => r?.runId && r.runId !== baseRec.runId && r.runId !== model.runId);
108
+ if (others.length) {
109
+ err.push('[report] earlier records you can name explicitly with `--since <runId>`:');
110
+ for (const r of others) {
111
+ const v = await verifyRunChain(outRoot, r.runId, { linkIndex });
112
+ err.push(`[report] ${r.runId} · ${r.startedAt} · ${v.status}`);
113
+ }
114
+ err.push('[report] NOT falling back automatically: choosing a different baseline changes the '
115
+ + 'subject of the comparison, and that is the operator\'s decision to make, not this command\'s.');
116
+ }
117
+ // The BASELINE is untrustworthy; this run is fine. That is a refused COMPARISON, so the
118
+ // client still gets an artifact — one that names the refusal and no finding.
119
+ return refusedComparison(out, err,
120
+ chain.status === 'chain-broken' ? 'baseline-chain-broken' : 'baseline-integrity-unmeasurable',
121
+ `the baseline is ${chain.status}: ${chain.reason}. No finding can be called resolved against `
122
+ + 'a baseline that may have been altered.');
123
+ }
124
+
125
+ // ⚠️ THE CURRENT RECORD'S LINK, WHICH WAS COMPUTED AND NEVER READ. `verifyRunChain` works out
126
+ // whether this run's `prevDigest` still names bytes that exist; this view read `status` alone.
127
+ // Delete the middle of three chained records and `prior` quietly resolved to the survivor — the
128
+ // silent substitution this module's own header says it never makes, and the verdict that
129
+ // vanished with the deleted record is the one the operator was relying on.
130
+ //
131
+ // ⚠️ SCOPED TO `prior`, DELIBERATELY, AND NARROWER THAN THE PRESCRIPTION. Under `prior` a broken
132
+ // link CHANGED THE SUBJECT: the command picked a baseline other than the one it names. Under an
133
+ // explicit `--since <runId>` the operator chose the subject, so nothing was substituted, and
134
+ // refusing there would turn ordinary housekeeping — deleting old records — into a blanket
135
+ // refusal. That is the accuse-honest-evidence direction. Refuse the substitution; DISCLOSE the
136
+ // integrity fact.
137
+ const curChain = await verifyRunChain(outRoot, model.runId, { linkIndex });
138
+ // ⚠️ THE CURRENT RUN IS JUST AS ALTERABLE AS THE BASELINE, and only the baseline was verified.
139
+ // `report --run <older> --since <even-older>` is a legitimate invocation — both records are
140
+ // historical — and an edited CURRENT findings file produced `new` and `resolved` rows with no
141
+ // mention of it. The refusal NAMES the side, because "the run is chain-broken" sends an operator
142
+ // to whichever file they assume, and the point of naming a subject is that they check the right
143
+ // one. Same two fatal statuses as the baseline: an altered current run cannot support a `new`
144
+ // row any more than an altered baseline supports a `resolved` one.
145
+ if (curChain.status === 'chain-broken' || curChain.status === 'chain-unreadable') {
146
+ err.push(`[report] REFUSED: the CURRENT run ${model.runId} is ${curChain.status} — ${curChain.reason}. `
147
+ + 'No finding can be called new or resolved when the run being REPORTED may have been altered.');
148
+ return { code: 2, out, err };
149
+ }
150
+ // ⚠️ COLLECTED, NOT PRINTED. These are facts about the RUNS that the engine cannot derive — it
151
+ // is deliberately filesystem-free — so the view supplies them. Pushing them to `out` alone would
152
+ // put them on STDOUT ONLY: read by the operator, who knows what the tool does, and absent from
153
+ // the HTML read by the person being billed. That is the stdout-only shape this lane already
154
+ // retired once, for the not-comparable count. They go into `delta.limits`, which renders into
155
+ // the client artifact AND is echoed to stdout by the limits loop below.
156
+ const viewLimits = [];
157
+ if (curChain.status === 'chain-absent') {
158
+ viewLimits.push(CURRENT_UNCHAINED);
159
+ }
160
+ if (curChain.linkBroken) {
161
+ if (since === 'prior') {
162
+ err.push('[report] REFUSED: this run\'s record names a predecessor whose bytes no longer exist, '
163
+ + `so \`--since prior\` would compare against ${baseRec.runId}, which is NOT that predecessor. `
164
+ + 'Silently changing the subject of the comparison is the one thing this command does not do.');
165
+ const others = records
166
+ .filter((r) => r?.runId && r.runId !== model.runId);
167
+ if (others.length) {
168
+ err.push('[report] records you can name explicitly with `--since <runId>`:');
169
+ for (const r of others) {
170
+ const v = await verifyRunChain(outRoot, r.runId, { linkIndex });
171
+ err.push(`[report] ${r.runId} · ${r.startedAt} · ${v.status}`);
172
+ }
173
+ }
174
+ return { code: 2, out, err };
175
+ }
176
+ viewLimits.push(CURRENT_CHAIN_LINK_BROKEN);
177
+ }
178
+
179
+ const loadedBase = await loadRun(outRoot, { runId: baseRec.runId, allowPartial: !!allowPartial }, { tier });
180
+ if (!loadedBase.ok) {
181
+ err.push(`[report] REFUSED: baseline-unloadable — the baseline run could not be loaded: ${loadedBase.message}`);
182
+ return refusedComparison(out, err, 'baseline-unloadable',
183
+ `the baseline run ${baseRec.runId} could not be loaded: ${loadedBase.message}`);
184
+ }
185
+
186
+ const currentRec = await readRunRecord(outRoot, model.runId);
187
+ // ⚠️ `pluginStatus` TRAVELS WITH EACH SIDE, and its ABSENCE is a different fact from an empty
188
+ // one. The loader has always carried it (`model.plugins.byHost`); this view passed only
189
+ // `findings`, so the engine could not tell a plugin that RAN from one that ERRORED on the host
190
+ // and could not tell "no gaps" from "no oracle". Both read as clean, which is the fail-open
191
+ // direction: a finding whose plugin crashed read as REMEDIATED in the client's report.
192
+ const delta = buildScanDelta({
193
+ baseline: { record: baseRec, findings: loadedBase.model.findings, integrity: chain.status,
194
+ pluginStatus: loadedBase.model.plugins.byHost },
195
+ current: { record: currentRec, findings: model.findings, pluginStatus: model.plugins.byHost,
196
+ integrity: curChain.status },
197
+ });
198
+
199
+ if (!delta.comparable) {
200
+ err.push(`[report] REFUSED: ${delta.refusal.reason} — ${delta.refusal.detail}`);
201
+ // ⚠️ THE DELTA TRAVELS WITH THE REFUSAL. It was dropped here, which is what made
202
+ // `executive_report.mjs`'s `delta-refused` block unreachable through every shipped path.
203
+ return { code: 2, out, err, delta };
204
+ }
205
+ delta.limits.push(...viewLimits);
206
+
207
+ out.push(`[report] delta vs ${baseRec.runId}: ${delta.newFindings.length} new · `
208
+ + `${delta.resolved.length} resolved · ${delta.changed.length} changed · `
209
+ + `${delta.notComparable.length} not-comparable · ${delta.unchanged.length} unchanged`);
210
+ for (const f of delta.changed) out.push(`[report] CHANGED ${f.title}${f.resource ? ` (${f.resource})` : ''} — ${f.from} → ${f.to}`);
211
+ // ⚠️ NOT-COMPARABLE IS PRINTED WITH ITS REASON, EVERY TIME. A count alone re-creates the defect
212
+ // this engine exists to prevent: the reader assumes the rest were comparable.
213
+ for (const f of delta.notComparable) {
214
+ out.push(`[report] NOT COMPARABLE (${f.direction}) ${f.title}${f.resource ? ` (${f.resource})` : ''} — ${f.reason}: ${f.detail}`);
215
+ }
216
+ for (const l of delta.limits) out.push(`[report] LIMIT: ${l}`);
217
+ return { code: 0, out, err, delta };
218
+ }
@@ -31,6 +31,20 @@ export async function recordScan(outputDir, summary) {
31
31
  openPorts: Array.isArray(summary.openPorts) ? summary.openPorts : [],
32
32
  os: summary.os ?? null,
33
33
  findingsCount: summary.findingsCount ?? 0,
34
+ // ⚠️ WHAT THAT NUMBER MEANS, carried on the line itself (board C10). `findingsCount` kept its
35
+ // name and changed its value: it used to be service attributes plus plugin `result.findings`,
36
+ // and is now what the report loader shapes — which includes the finding QUEUE, where a
37
+ // network host's findings actually live. Lines written before the change have no basis, and
38
+ // `computeDiff` refuses to subtract across that boundary rather than reporting a fabricated
39
+ // "+N new" on the first scan after an upgrade.
40
+ findingsCountBasis: summary.findingsCountBasis ?? null,
41
+ // ⚠️ WHICH TIER COUNTED IT (board E9). The tiers do not count the same things: Enterprise
42
+ // writes a finding QUEUE that Community never produces, and `findingsCount` is shaped by the
43
+ // report loader, which reads it. So a Community → Pro upgrade would otherwise report "+N new
44
+ // findings" on the free webhook the day the queue first appears — no estate changed, the
45
+ // COUNTER changed. `computeDiff` refuses across a tier boundary for the same reason it
46
+ // refuses across a basis boundary, and this field is what makes that refusal possible.
47
+ tier: summary.tier ?? null,
34
48
  // review re-fold R-1: persist the cloud/service split so a cloud (--host aws)
35
49
  // scan's findings are machine-visible in history (findingsCount already
36
50
  // includes them; this surfaces how many came from cloud auditors).
@@ -89,6 +103,14 @@ export async function getLastScan(outputDir, host) {
89
103
  * @param {object|null} previous - previous scan summary (null for first scan)
90
104
  * @returns {object} diff object
91
105
  */
106
+ /**
107
+ * Why `computeDiff` refused to subtract two lines. DECLARED, because a reason string invented at
108
+ * a call site is one no consumer can switch on; held in two-way equality with what the function
109
+ * actually emits by `tests/scan_history_tier_comparability.test.mjs`, so a new reason must join
110
+ * the set and a retired one must leave it.
111
+ */
112
+ export const NOT_COMPARABLE_REASONS = ['basis-changed', 'tier-changed', 'tier-unknown'];
113
+
92
114
  export function computeDiff(current, previous) {
93
115
  if (!previous) {
94
116
  return {
@@ -96,6 +118,8 @@ export function computeDiff(current, previous) {
96
118
  removedServices: [],
97
119
  changedServices: [],
98
120
  newFindings: current?.findingsCount ?? 0,
121
+ findingsNotComparable: false,
122
+ findingsNotComparableReason: null,
99
123
  summary: 'No previous scan for comparison.',
100
124
  };
101
125
  }
@@ -139,7 +163,53 @@ export function computeDiff(current, previous) {
139
163
  }
140
164
  }
141
165
 
142
- const findingsDelta = (current?.findingsCount ?? 0) - (previous?.findingsCount ?? 0);
166
+ // ⚠️ `findingsCount` KEPT ITS NAME AND CHANGED ITS VALUE (board C10), so two lines are only
167
+ // subtractable when they were counted the same way. A line written before the fix summed
168
+ // service attributes plus plugin `result.findings`; one written after counts what the loader
169
+ // shapes, which includes the finding QUEUE. Subtracting across that boundary reports a
170
+ // fabricated "+N new" on the first scan after an upgrade — an alarm produced by our own
171
+ // correction, on an estate where nothing changed.
172
+ //
173
+ // contract-v1 §5.3 records the same shape for `findingCount` in the archived packs, and the
174
+ // answer there was to REFUSE the comparison rather than compute it. Two OLD lines still
175
+ // compare with each other: they are commensurable, and refusing would break a working
176
+ // comparison for a customer who has not rescanned yet.
177
+ const basisOf = (rec) => rec?.findingsCountBasis ?? null;
178
+ const basisChanged = basisOf(current) !== basisOf(previous);
179
+
180
+ // ⚠️ THE TIER BOUNDARY (board E9), and its carve-out mirrors the basis one deliberately: two
181
+ // lines that BOTH lack a tier still compare. Neither of them can have carried a queue, so they
182
+ // are commensurable, and refusing would break a working comparison for a customer who has not
183
+ // rescanned since the field landed — the same judgement made for pre-1.1.0 lines above.
184
+ // ABSENT-vs-PRESENT is refused, because that is exactly the shape an upgrade takes.
185
+ const tierOf = (rec) => rec?.tier ?? null;
186
+ const tierChanged = tierOf(current) !== tierOf(previous);
187
+ // ⚠️ NO `tierChanged &&` HERE, and its deletion is a MEASUREMENT rather than a tidy-up. With
188
+ // the verdict gate below, this value is only ever read when the comparison was already refused,
189
+ // so in the branch that reads it `tierChanged` is necessarily true — the conjunct was dead. It
190
+ // and the gate each made the other unfalsifiable: BOTH mutants survived, each masked by its
191
+ // twin, which is the pair-masking shape this repo has recorded before. One mechanism is kept
192
+ // (the gate, because it holds the invariant by construction for every future branch) and the
193
+ // redundant one is removed, so the kept one is mutant-provable.
194
+ const tierUnknown = tierOf(current) === null || tierOf(previous) === null;
195
+
196
+ const findingsComparable = !basisChanged && !tierChanged;
197
+ // Precedence is deterministic and the WIDER boundary wins: a basis change is the older and
198
+ // more fundamental incommensurability, so it names the refusal when both apply. The tier fact
199
+ // is still stated in the summary rather than dropped — a reader needs both.
200
+ // ⚠️ THE REASON IS GATED ON THE VERDICT, so "non-null reason ⟺ refused" holds BY CONSTRUCTION
201
+ // rather than by three branches agreeing. Found by a surviving mutant: with the reason computed
202
+ // independently, dropping the both-absent carve-out left `findingsNotComparable: false` sitting
203
+ // beside `reason: 'tier-unknown'` — a diff that says it compared AND says why it could not, and
204
+ // a consumer switching on the reason would refuse a comparison the verdict allowed.
205
+ const findingsNotComparableReason = findingsComparable ? null
206
+ : basisChanged ? 'basis-changed'
207
+ : tierUnknown ? 'tier-unknown'
208
+ : tierChanged ? 'tier-changed'
209
+ : null;
210
+ const findingsDelta = findingsComparable
211
+ ? (current?.findingsCount ?? 0) - (previous?.findingsCount ?? 0)
212
+ : null;
143
213
 
144
214
  // Build human-readable summary
145
215
  const parts = [];
@@ -152,7 +222,22 @@ export function computeDiff(current, previous) {
152
222
  if (changedServices.length) {
153
223
  parts.push(`${changedServices.length} service(s) changed`);
154
224
  }
155
- if (findingsDelta !== 0) {
225
+ if (!findingsComparable) {
226
+ // Said out loud, not omitted. A missing findings clause reads as "no findings changed".
227
+ // BOTH facts when both apply: the reason names one boundary, the sentence states every one
228
+ // that was crossed, because a reader who is told only about the basis will rescan and be
229
+ // surprised again by the tier.
230
+ const why = [];
231
+ if (basisChanged) {
232
+ why.push('counted findings on a different basis '
233
+ + `(${basisOf(previous) ?? 'pre-1.1.0'} → ${basisOf(current) ?? 'pre-1.1.0'})`);
234
+ }
235
+ if (tierChanged) {
236
+ why.push(`ran at a different licence tier (${tierOf(previous) ?? 'unrecorded'} → `
237
+ + `${tierOf(current) ?? 'unrecorded'}), which counts a different set of findings`);
238
+ }
239
+ parts.push(`findings not comparable: the two scans ${why.join(', and ')}; rescan to compare`);
240
+ } else if (findingsDelta !== 0) {
156
241
  const sign = findingsDelta > 0 ? '+' : '';
157
242
  parts.push(`findings delta: ${sign}${findingsDelta}`);
158
243
  }
@@ -165,7 +250,12 @@ export function computeDiff(current, previous) {
165
250
  newServices,
166
251
  removedServices,
167
252
  changedServices,
253
+ // `null`, never 0, when the bases differ: 0 is a CLAIM that nothing changed, and this
254
+ // function does not know that. `delta_reporter` gates its webhook on this value, so a number
255
+ // here would send the fabricated alarm and a 0 would suppress a real one.
168
256
  newFindings: findingsDelta,
257
+ findingsNotComparable: !findingsComparable,
258
+ findingsNotComparableReason,
169
259
  summary,
170
260
  };
171
261
  }