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,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
|
+
}
|
package/utils/scan_history.mjs
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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
|
}
|