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,1122 @@
|
|
|
1
|
+
// Cross-run delta — "what changed since the last scan" (Pro/Enterprise).
|
|
2
|
+
//
|
|
3
|
+
// ⚠️ NOT `utils/delta_reporter.mjs`. That is the FREE CE last-vs-current alerting delta on the
|
|
4
|
+
// webhook path and it stays free, untouched and un-gated. This is a second engine, over two
|
|
5
|
+
// ARBITRARY run records, at FINDING level.
|
|
6
|
+
//
|
|
7
|
+
// ⚠️ THE WHOLE DESIGN IS THE REFUSAL, because "RESOLVED" IS THE DANGEROUS VERDICT. A finding
|
|
8
|
+
// that disappeared for any reason other than being fixed reads to a buyer, and to an assessor,
|
|
9
|
+
// as remediation. Five ways a finding can vanish without being fixed, every one observed in this
|
|
10
|
+
// product's own history:
|
|
11
|
+
// 1. HOST — the host was not scanned in the current run.
|
|
12
|
+
// 2. PLUGIN — the plugin that produced it did not run (`--plugins` differed).
|
|
13
|
+
// 3. SCOPE — an evidence gap: AccessDenied, budget exceeded, incomplete region enumeration.
|
|
14
|
+
// A FINDING THAT DISAPPEARED BECAUSE THE SCANNER LOST PERMISSION IS NOT FIXED.
|
|
15
|
+
// 4. FRAMEWORK — the control enumeration moved (PCI went 19/9/39 → 19/9/44 in one cycle), so a
|
|
16
|
+
// control that "appeared" or "left" is an enumeration change, not a posture change.
|
|
17
|
+
// 5. PRODUCT — the §5.3 semantics boundary, below. This one is written in our own contract.
|
|
18
|
+
// A finding may be called RESOLVED only when its host, plugin, scope and framework enumeration
|
|
19
|
+
// were in scope in BOTH runs. Otherwise it goes to NOT-COMPARABLE **with its reason** — never
|
|
20
|
+
// into `resolved`, and never silently dropped.
|
|
21
|
+
// Integrity is a fact about BYTES ON DISK, so the caller measures it with `utils/run_chain.mjs`
|
|
22
|
+
// and passes the verdict in. Keeping it out of this module means the comparability rules can be
|
|
23
|
+
// driven without a filesystem, and the chain can be driven without the delta.
|
|
24
|
+
export const SCAN_DELTA_SCHEMA = 1;
|
|
25
|
+
|
|
26
|
+
// ⚠️ REASON CODES ARE APPEND-ONLY UNDER `SCAN_DELTA_SCHEMA = 1`. Adding one is additive and needs
|
|
27
|
+
// no schema bump; RENAMING or REMOVING one changes what an existing consumer's stored verdicts
|
|
28
|
+
// mean, and bumps the schema.
|
|
29
|
+
//
|
|
30
|
+
// ⚠️ AND THEY ARE DECLARED HERE BECAUSE TWO SURFACES ARE WRITTEN FROM THEM RATHER THAN FROM A
|
|
31
|
+
// COUNT SOMEBODY DID BY HAND. The CE CHANGELOG's "N ways a finding can vanish without being
|
|
32
|
+
// fixed" sentence and the agent-skill's item (2) have both already shipped a count that disagreed
|
|
33
|
+
// with the code — a count with no list beside it is invisible to every gate this product owns.
|
|
34
|
+
// `tests/scan_delta.test.mjs` holds these in EQUALITY with the codes derived from this file's own
|
|
35
|
+
// source: a new code that forgets the declaration fails, and so does a declaration whose code is
|
|
36
|
+
// gone. The ORDER is the order the engine evaluates them, which is the order a reader meets them.
|
|
37
|
+
//
|
|
38
|
+
// ⚠️ CE 0.2.55 IS THE FIRST TARBALL THAT SHIPS THIS FILE, SO IT IS THE LAST RELEASE IN WHICH A
|
|
39
|
+
// CODE HERE CAN BE RENAMED FOR FREE. Measured, both directions: `npm pack nsauditor-ai@0.2.54`
|
|
40
|
+
// contains 87 entries, 47 under `utils/`, and NO `utils/scan_delta.mjs` (positive control:
|
|
41
|
+
// `utils/report_inputs.mjs` is present); `npm pack --dry-run` at this version contains 92 entries
|
|
42
|
+
// INCLUDING `scan_delta.mjs`, `scan_delta_view.mjs` and `executive_report.mjs`. That is why
|
|
43
|
+
// `plugin-identity-basis-changed` could be renamed to `identity-basis-changed` this cycle rather
|
|
44
|
+
// than papered over with a display label — zero published verdicts carried it. From the next
|
|
45
|
+
// release on, a consumer can hold stored verdicts keyed on these strings, and a rename becomes a
|
|
46
|
+
// schema change that must be versioned rather than an edit. Check the claim before relying on
|
|
47
|
+
// either half of it: the question is what the PUBLISHED TARBALL contains, never what git says.
|
|
48
|
+
// ⚠️ ONE NAME, TWO CONSUMERS. The delta refuses a straddling row with this reason, and Enterprise's
|
|
49
|
+
// MTTR engine withholds the same row's CLOSURE with the same reason (EE build 5, F1(b)) — the two
|
|
50
|
+
// must not disagree about what counts as the same finding, so the second one IMPORTS the value
|
|
51
|
+
// rather than typing it. Same value as before, so no stored verdict changes meaning.
|
|
52
|
+
export const IDENTITY_BASIS_CHANGED_REASON = 'identity-basis-changed';
|
|
53
|
+
// The same rule for the two PLUGIN-scope reasons: Enterprise's MTTR engine withholds a prior row's
|
|
54
|
+
// closure when its plugin did not run (or ran and was not measured) in the current scan — the
|
|
55
|
+
// delta's own two verdicts — so it imports these names rather than typing the strings. Values
|
|
56
|
+
// unchanged, so no stored verdict changes meaning.
|
|
57
|
+
export const PLUGIN_NOT_RUN_REASON = 'plugin-not-run';
|
|
58
|
+
export const PLUGIN_NOT_MEASURED_REASON = 'plugin-not-measured';
|
|
59
|
+
// And the COVERAGE reason: Enterprise's MTTR engine refuses to call a prior regional row remediated
|
|
60
|
+
// when its region lay outside the current scan's recorded scope — the delta's own verdict, same name.
|
|
61
|
+
export const SCOPE_NOT_SCANNED_REASON = 'scope-not-scanned';
|
|
62
|
+
// And the PORT reason (EE 1.1.0 build 9, F6). A probe RAN on a port the port scanner saw open and did not
|
|
63
|
+
// complete its connection there (a reset, a timeout, a handshake that never finished), so that port was
|
|
64
|
+
// not measured in the run. Enterprise records it as a service-set INPUT GAP carrying the port. A baseline
|
|
65
|
+
// row on that port that is absent now was not fixed — nobody looked. Measured on a live acceptance run:
|
|
66
|
+
// the router's 443 answered the port scanner and then reset the HTTPS probe; crypto_agent's row about 443
|
|
67
|
+
// could not be produced, and this engine reported it RESOLVED. Enterprise's MTTR engine applies the same
|
|
68
|
+
// reason to a prior row's CLOSURE and imports both names from here, so the two channels cannot disagree.
|
|
69
|
+
export const PROBE_NOT_MEASURED_REASON = 'probe-not-measured';
|
|
70
|
+
// And the port that STOPPED ANSWERING (EE 1.1.0 build 12). The port scanner saw it OPEN in the run that holds
|
|
71
|
+
// the finding, and in the other run it RAN and recorded the port neither open nor closed — no answer inside its
|
|
72
|
+
// timeout (filtered), or not probed at all. Nothing was measured there, so a row on that port neither vanished nor
|
|
73
|
+
// appeared. Measured on Gate 2 of build 11: the gateway filtered 21 and 80 for three minutes, and three findings on
|
|
74
|
+
// those ports read RESOLVED in the client's delta; five minutes later both ports were open again. A port in the
|
|
75
|
+
// other run's tcpClosed WAS measured, and RESOLVED there stays legitimate. Enterprise's MTTR imports this name.
|
|
76
|
+
export const PORT_NOT_MEASURED_REASON = 'port-not-measured';
|
|
77
|
+
// The gap class Enterprise's service-set input gap stamps (`evidence.raw.gapClass`). Spelled ONCE, here, on
|
|
78
|
+
// the reading side: Enterprise imports it, so the producer and this reader cannot drift to two spellings.
|
|
79
|
+
export const INPUT_GAP_CLASS = 'input_gap';
|
|
80
|
+
|
|
81
|
+
export const NOT_COMPARABLE_REASONS = Object.freeze([
|
|
82
|
+
'host-not-scanned', // the other run never wrote this host
|
|
83
|
+
'producer-unknown', // the finding carries no producer identity to adjudicate
|
|
84
|
+
PLUGIN_NOT_RUN_REASON, // the producing plugin was not requested in the other run
|
|
85
|
+
'evidence-gap', // a producer DECLARED it could not read the surface
|
|
86
|
+
PLUGIN_NOT_MEASURED_REASON, // the plugin was attempted on the host and errored / timed out / was skipped
|
|
87
|
+
'framework-enumeration-changed', // the control left or joined the enumeration between the runs
|
|
88
|
+
IDENTITY_BASIS_CHANGED_REASON, // the producer changed WHAT IT NAMES between the two releases
|
|
89
|
+
SCOPE_NOT_SCANNED_REASON, // the finding's coverage unit was outside the OTHER run's recorded scope
|
|
90
|
+
PROBE_NOT_MEASURED_REASON, // a probe ran on the finding's port, open per the port scanner, and did not complete there
|
|
91
|
+
PORT_NOT_MEASURED_REASON, // the finding's port was open in its run and neither open nor closed in the other (filtered / unprobed)
|
|
92
|
+
]);
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* WHEN EACH PRODUCER LAST CHANGED WHAT IT NAMES AS A FINDING'S OBJECT.
|
|
96
|
+
*
|
|
97
|
+
* A finding's identity is keyed partly on `resource`. Canonicalisation (`finding_identity.mjs`)
|
|
98
|
+
* makes an old decorated value and a new clean one compute the SAME key, so a producer that
|
|
99
|
+
* always named its object compares across an upgrade unchanged. It cannot do that for a producer
|
|
100
|
+
* that GAINED an identity: a finding keyed on `'-'` in the baseline and on `sg-0def2…` in the
|
|
101
|
+
* current run is one exposure wearing two keys, and a naive delta reports it as one RESOLVED
|
|
102
|
+
* plus one NEW — a fabricated remediation and a fabricated exposure in the same table, on the
|
|
103
|
+
* feature's headline use.
|
|
104
|
+
*
|
|
105
|
+
* The alternative was bumping `RUN_RECORD_SCHEMA`, which refuses EVERY pre-upgrade baseline
|
|
106
|
+
* outright — including for the ~20 producers that did not move. Per-producer declaration pays
|
|
107
|
+
* the cost in DISCLOSURE instead: the affected producer's rows are declared not comparable and
|
|
108
|
+
* every other producer's delta stands.
|
|
109
|
+
*
|
|
110
|
+
* ⚠️ THIS TABLE IS A CLAIM SURFACE IN BOTH DIRECTIONS AND BOTH ARE FATAL. An UNDECLARED mover
|
|
111
|
+
* fabricates churn — the defect this exists to prevent. A DECLARED producer that did NOT move
|
|
112
|
+
* throws away a real comparison, silently, for ever. It is held in equality with what the
|
|
113
|
+
* artifacts actually show by `tests/identity_basis_instrument.test.mjs`, which derives the mover
|
|
114
|
+
* set by running THIS loader over a pre-change run and a post-change run and comparing the key
|
|
115
|
+
* sets for the same objects. **Derive it; never hand-edit it to match a prediction.**
|
|
116
|
+
*
|
|
117
|
+
* The nine members are E1's own movers: 1150 / 1170 / 1190 claimed a REGION as their object and
|
|
118
|
+
* now name the real one; 1020 / 1024 / 1025 / 1030 / 1200 / 1210 named nothing and now name an
|
|
119
|
+
* object or their scope. `null → a value` moves a key exactly as `region → object id` does.
|
|
120
|
+
*/
|
|
121
|
+
/**
|
|
122
|
+
* THE NON-NUMERIC PRODUCERS A DECLARATION MAY NAME.
|
|
123
|
+
*
|
|
124
|
+
* ⚠️ THE TABLE BELOW WAS KEYED ON PLUGIN IDS AND PINNED `/^\d{3,4}$/`, because every declared
|
|
125
|
+
* producer was a plugin. Enterprise's ANALYSIS AGENTS are producers too — a finding out of the
|
|
126
|
+
* finding QUEUE carries `evidence.source` as its identity, never an id — and one of them changes
|
|
127
|
+
* what it names at EE 1.1.0. So the vocabulary has to widen, and widening it to "any string" is
|
|
128
|
+
* not an option: a typo would declare a producer that does not exist, `identityBasisChanged` would
|
|
129
|
+
* find nothing on lookup, and the REAL producer would stay undeclared and fabricate churn. That is
|
|
130
|
+
* the defect the table exists to prevent, arriving through the table's own key.
|
|
131
|
+
*
|
|
132
|
+
* ⚠️ IT IS DECLARED HERE AND DERIVED THERE. Community cannot see Enterprise's agent registry, and
|
|
133
|
+
* Enterprise cannot edit this file — so a hand list in either repo is a copy that rots when the
|
|
134
|
+
* other side moves. `tests/agent_producer_vocabulary.test.mjs` in EE holds this set in TWO-WAY
|
|
135
|
+
* equality with the set derived from EE's own registry and its mapper's exported source constant:
|
|
136
|
+
* a member here that EE does not emit fails, and a source EE emits that is missing here fails too.
|
|
137
|
+
* A subset check in either direction would be a superset guard, structurally unable to catch the
|
|
138
|
+
* other side's omission.
|
|
139
|
+
*/
|
|
140
|
+
export const AGENT_PRODUCER_KEYS = Object.freeze([
|
|
141
|
+
'auth_agent', 'crypto_agent', 'config_agent', 'service_agent', 'exposure_agent',
|
|
142
|
+
'intelligence_engine',
|
|
143
|
+
]);
|
|
144
|
+
|
|
145
|
+
export const IDENTITY_BASIS_CHANGED_AT = Object.freeze({
|
|
146
|
+
1020: '1.1.0', 1024: '1.1.0', 1025: '1.1.0', 1030: '1.1.0',
|
|
147
|
+
// ⚠️ 1040 JOINS AT THE STAMP, LIKE 1120 BELOW. Every CloudTrail-auditor row carried NO region at all
|
|
148
|
+
// (20 of 20 on build 4's pack), so `scopeNotScanned` read each as account-wide; EE build 5 stamps
|
|
149
|
+
// the region of the object a row is about (a trail's home region, a trail bucket's own location,
|
|
150
|
+
// config.region for the alarm and Config checks). That puts `region` into `keyOf` and CHANGES WHAT
|
|
151
|
+
// THOSE FINDINGS ARE, so the straddle is declared exactly as 1120's was.
|
|
152
|
+
1040: '1.1.0',
|
|
153
|
+
// ⚠️ 1110 IS A THIRD KIND: A TEXT CORRECTION, NOT A RENAMED OBJECT OR A NEW REGION. Its HIGH row
|
|
154
|
+
// (kms:Decrypt on Resource:*) told the reader the KMS key-policy and grant layers had been
|
|
155
|
+
// cross-referenced under a HIGH→INFO downgrade contract — and from EE 1.1.0 build 5 that downgrade
|
|
156
|
+
// runs on no shipped path, because a one-region read cannot establish that no key trusts a
|
|
157
|
+
// principal (CFN-3). The corrected sentence changes the row's content digest, which is part of
|
|
158
|
+
// `keyOf`, so every such HIGH straddling the upgrade would read as one finding resolved and a new
|
|
159
|
+
// one appearing. Declared for the same reason as the rest: what identifies the finding changed.
|
|
160
|
+
1110: '1.1.0',
|
|
161
|
+
// ⚠️ 1120 JOINS AT THE STAMP, NOT AT THE RENAME. Its replication and lifecycle rows recorded
|
|
162
|
+
// their region as `details.sourceBucketRegion` and carried NO top-level `region`, so in this
|
|
163
|
+
// engine's frame they were non-regional: `scopeNotScanned` skipped them and narrowing a later
|
|
164
|
+
// scan away from the source region read them as REMEDIATED. Stamping the field the loader
|
|
165
|
+
// actually reads puts `region` into `keyOf`, which CHANGES WHAT THOSE FINDINGS ARE — 36 of its
|
|
166
|
+
// 297 archived rows gain one — so the straddle must be declared like any other basis change.
|
|
167
|
+
// 1200 and 1210 take the same stamp and are already declared above; only 1120 was new.
|
|
168
|
+
1120: '1.1.0',
|
|
169
|
+
1150: '1.1.0', 1170: '1.1.0', 1190: '1.1.0', 1200: '1.1.0', 1210: '1.1.0',
|
|
170
|
+
// ⚠️ 1023 JOINS AT BUILD 12, BATCHED WITH THE SIX ABOVE SO CUSTOMERS TAKE ONE 1023 STRADDLE, NEVER TWO. Two of the
|
|
171
|
+
// four rows the build-11 delta called RESOLVED were 1023's and no port rule could reach them: "FTP port open —
|
|
172
|
+
// verify no anonymous or default credentials" is emitted BECAUSE 21 is open yet carried no port, and the exposure
|
|
173
|
+
// row carried its COUNT in the title, so 16 → 15 open ports read as a fix plus a new finding. Build 12 attaches the
|
|
174
|
+
// gating port to every row emitted because one port is open (21 · 22 · 23 · 3389 · 5900) and gives the exposure
|
|
175
|
+
// rows one count-free title with the count in details. Both put new values into `keyOf` — `port`, `title` — which
|
|
176
|
+
// CHANGES WHAT THOSE FINDINGS ARE, so the straddle is declared like 1040's region stamp.
|
|
177
|
+
1023: '1.1.0',
|
|
178
|
+
// ⚠️ AN ANALYSIS AGENT, NOT A PLUGIN, AND THE FIRST NON-NUMERIC KEY THIS TABLE HAS HELD. A
|
|
179
|
+
// finding from Enterprise's finding QUEUE emits no resource, no region, no identity qualifier
|
|
180
|
+
// and no content digest, so `keyOf` reduces to `host · producer · port · TITLE` — the title IS
|
|
181
|
+
// what this producer names. EE 1.1.0 moved the coverage-gap title off `${program} ${version}`,
|
|
182
|
+
// a discovery fingerprint that changes when the scan does (measured: one service came back
|
|
183
|
+
// `DNS-SD/mDNS` in one run and `Unknown` in the next, same port, same host), and onto
|
|
184
|
+
// `${protocol}/${service}`. Same definition as the nine above, read off the field that carries
|
|
185
|
+
// the answer in this container.
|
|
186
|
+
//
|
|
187
|
+
// The key is bounded by `AGENT_PRODUCER_KEYS` and held in two-way equality with what Enterprise
|
|
188
|
+
// actually emits, because a key outside the vocabulary can never match a finding — and a
|
|
189
|
+
// declaration that matches nothing is SILENT while the real producer stays undeclared.
|
|
190
|
+
intelligence_engine: '1.1.0',
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// ⚠️ NO SECOND COMPARATOR. This file already has `cmpVersion` (below, used by the
|
|
194
|
+
// finding-count-semantics boundary) and my first draft shipped a duplicate beside it — two
|
|
195
|
+
// version comparators in one module is exactly how two call sites come to disagree about what
|
|
196
|
+
// "earlier" means. `String(null)` parses to 0 there, so a null baseline version sorts BEFORE any
|
|
197
|
+
// release, which is the behaviour this declaration needs: a run record with no `eeVersion`
|
|
198
|
+
// predates every declared change.
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Did this producer's identity basis change BETWEEN the two runs?
|
|
202
|
+
* True only when the baseline predates the declared change and the current run is at or after
|
|
203
|
+
* it — never merely because the versions differ, which would declare on every upgrade for ever.
|
|
204
|
+
*/
|
|
205
|
+
export function identityBasisChanged(plugin, baselineEeVersion, currentEeVersion) {
|
|
206
|
+
const at = IDENTITY_BASIS_CHANGED_AT[plugin];
|
|
207
|
+
if (!at) return false;
|
|
208
|
+
return cmpVersion(baselineEeVersion, at) < 0 && cmpVersion(currentEeVersion, at) >= 0;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ⚠️ THERE IS NO DISPLAY-LABEL MAP HERE, AND THE REASON IS A MEASUREMENT.
|
|
212
|
+
//
|
|
213
|
+
// `plugin-identity-basis-changed` was named when every declared producer WAS a plugin. EE 1.1.0
|
|
214
|
+
// declares an analysis AGENT, and both render seams build their cell as `${f.reason}: ${f.detail}`
|
|
215
|
+
// — so the row read "plugin-identity-basis-changed: producer intelligence_engine changed what it
|
|
216
|
+
// names ...", detail repaired and CODE still asserting "plugin" about an agent.
|
|
217
|
+
//
|
|
218
|
+
// The first repair added a render-time LABEL map and kept the code, justified by: "the vocabulary
|
|
219
|
+
// is append-only under SCAN_DELTA_SCHEMA; renaming changes what a consumer's stored verdicts
|
|
220
|
+
// MEAN." That premise is CHECKABLE and was not checked. Measured against the published artifact
|
|
221
|
+
// rather than against git — git answers about the repo, not about what a consumer holds —
|
|
222
|
+
// `npm pack nsauditor-ai@0.2.54` does not contain THIS FILE at all, and the string appears
|
|
223
|
+
// nowhere in those bytes (positive control: `utils/report_inputs.mjs` present, 47 files in
|
|
224
|
+
// `utils/`). The engine is unpublished: zero stored verdicts carry the code, and no guard in
|
|
225
|
+
// either repo ever stated the append-only rule.
|
|
226
|
+
//
|
|
227
|
+
// The map was also actively worse than the rename. It was a SECOND COPY of the outcome's spelling,
|
|
228
|
+
// free to drift from the code it stood in for; and it moved ONE of two human surfaces, so the
|
|
229
|
+
// terminal said `plugin-identity-basis-changed` while the client HTML said `identity basis
|
|
230
|
+
// changed` — one outcome under two names, which makes a support call unanswerable. The code is
|
|
231
|
+
// renamed instead: one spelling, true of a plugin and of an agent alike, on every surface that
|
|
232
|
+
// interpolates it — including any seam added later, which a per-seam label can never cover.
|
|
233
|
+
|
|
234
|
+
// Whole-comparison refusals: cases where NO per-finding verdict is trustworthy, so none is offered.
|
|
235
|
+
export const REFUSAL_REASONS = Object.freeze([
|
|
236
|
+
'ee-presence-differs',
|
|
237
|
+
'tier-differs',
|
|
238
|
+
'finding-count-semantics-boundary',
|
|
239
|
+
'run-record-schema-differs',
|
|
240
|
+
'baseline-chain-broken',
|
|
241
|
+
'baseline-integrity-unmeasurable',
|
|
242
|
+
]);
|
|
243
|
+
|
|
244
|
+
// ⚠️ SEPARATE, AND DELIBERATELY SO. `tests/scan_delta.test.mjs` holds `REFUSAL_REASONS` in EXACT
|
|
245
|
+
// equality with the `refuse('…')` calls in THIS file — a guard that catches both a new code that
|
|
246
|
+
// forgets the constant and a code deleted from the constant while still produced. A view-level
|
|
247
|
+
// refusal put into that array breaks the equality, and the only way to keep one array would be to
|
|
248
|
+
// relax it to a SUBSET check: a superset guard, which this repo's own notes call structurally
|
|
249
|
+
// incapable of catching an omission. So the vocabulary is still one thing to a reader
|
|
250
|
+
// (`DECLARED_OUTCOMES` unions them) and two things to the two derivations that can each stay
|
|
251
|
+
// EXACT. Raised by `scan_delta_view.mjs`, never by `buildScanDelta`: the baseline record exists
|
|
252
|
+
// but this build cannot read it — a schema it does not understand, or a corrupt file.
|
|
253
|
+
export const VIEW_REFUSAL_REASONS = Object.freeze([
|
|
254
|
+
'baseline-unloadable',
|
|
255
|
+
]);
|
|
256
|
+
|
|
257
|
+
// ⚠️ THE BOUNDARY CONTRACT. Three fields were dropped at ONE seam before this existed —
|
|
258
|
+
// `resource` (a new exposure masked), `plugin` (every comparison fell to plugin-not-run) and
|
|
259
|
+
// `control` (a finding whose control left the enumeration read as RESOLVED). Each was invisible
|
|
260
|
+
// to this module's own tests because each fixture was built by hand with the field present: a fix
|
|
261
|
+
// verified only against fixtures the author constructs is verified against the author's idea of
|
|
262
|
+
// the input. `tests/delta_boundary_contract.test.mjs` asserts CONSUMED ⊆ EMITTED ∪ DECLARED_ABSENT
|
|
263
|
+
// against the REAL loader, so the fourth instance fails by name instead of shipping.
|
|
264
|
+
export const CONSUMED_FINDING_FIELDS = ['host', 'plugin', 'pluginName', 'producerKind', 'evidenceGap', 'gapClass', 'deferredScope',
|
|
265
|
+
'contentDigest', 'identityQualifier', 'resource', 'region', 'port', 'title', 'severity', 'control'];
|
|
266
|
+
|
|
267
|
+
// Fields this module WRITES onto its output records; they are never read from a loaded finding,
|
|
268
|
+
// so they must not be demanded of the loader. Declared so the derivation can subtract them.
|
|
269
|
+
export const OUTPUT_ONLY_FINDING_FIELDS = ['reason', 'detail', 'direction', 'from', 'to'];
|
|
270
|
+
|
|
271
|
+
// A consumed field the loader legitimately cannot produce. NOT a bare allowlist: each entry names
|
|
272
|
+
// the limit that DISCLOSES its absence, and the guard verifies that limit is actually emitted —
|
|
273
|
+
// a carve-out whose premise nobody checks is how an absence becomes a silent pass.
|
|
274
|
+
// ⚠️ EVERY DECLARATION NAMES THE PATH IT EXCUSES. A finding reaches the delta through one of two
|
|
275
|
+
// containers — a plugin ENVELOPE (`shapeFinding`) or the finding QUEUE (`shapeQueueEntry`) — and
|
|
276
|
+
// they have different vocabularies. An unqualified declaration excuses a field on BOTH, so the
|
|
277
|
+
// day either container stopped emitting it the carve-out would already be there to cover it.
|
|
278
|
+
export const DECLARED_ABSENT_FINDING_FIELDS = {
|
|
279
|
+
control: {
|
|
280
|
+
paths: ['plugin', 'queue'],
|
|
281
|
+
reason: 'Community ships no compliance data (data/compliance is empty) and report_inputs.mjs '
|
|
282
|
+
+ 'emits no control id, so a CE run record cannot carry one. Compliance routing is EE\'s.',
|
|
283
|
+
disclosedBy: 'FRAMEWORK_MOVEMENT_NOT_EVALUATED',
|
|
284
|
+
},
|
|
285
|
+
// ── QUEUE-PATH ABSENCES ───────────────────────────────────────────────────────────────────
|
|
286
|
+
// Found because the guard above had never been asked about this path: its fixture built a
|
|
287
|
+
// plugin envelope, so CONSUMED ⊆ EMITTED was checked against one container and believed of two.
|
|
288
|
+
contentDigest: {
|
|
289
|
+
paths: ['queue'],
|
|
290
|
+
reason: 'The digest exists because PLUGIN titles are SYNTHESISED from `issues` and CUT at 160 '
|
|
291
|
+
+ 'characters — three CRITICAL ingress rules on one security group once synthesised to the '
|
|
292
|
+
+ 'same 158-character string and held one identity. A queue title is AUTHORED by its '
|
|
293
|
+
+ 'producer and never truncated, so the collision the digest was built for cannot arise on '
|
|
294
|
+
+ 'this path. A digest here would have to hash the DESCRIPTION, which carries the same '
|
|
295
|
+
+ 'volatile program name that EE 1.1.0 is moving OUT of identity — the volatility would '
|
|
296
|
+
+ 'return through the digest. A collision that does occur anyway is named, per identity, at '
|
|
297
|
+
+ 'runtime by IDENTITY_COLLAPSE.',
|
|
298
|
+
disclosedBy: 'AGENT_SCOPE_FROM_TIER',
|
|
299
|
+
},
|
|
300
|
+
identityQualifier: {
|
|
301
|
+
paths: ['queue'],
|
|
302
|
+
reason: 'A producer-emitted rule discriminator (`details.groupId` / protocol / port range). '
|
|
303
|
+
+ 'Analysis agents emit no rule ids and carry no `details` object at all, so there is '
|
|
304
|
+
+ 'nothing to qualify with. The key falls back rather than requiring it.',
|
|
305
|
+
disclosedBy: 'AGENT_SCOPE_FROM_TIER',
|
|
306
|
+
},
|
|
307
|
+
resource: {
|
|
308
|
+
paths: ['queue'],
|
|
309
|
+
reason: 'A cloud noun — the object a cloud producer names. A queue finding is scoped to a '
|
|
310
|
+
+ 'HOST and a PORT, which the path does emit; inventing a resource for it would put a '
|
|
311
|
+
+ 'fabricated object identity into the delta key.',
|
|
312
|
+
disclosedBy: 'AGENT_SCOPE_FROM_TIER',
|
|
313
|
+
},
|
|
314
|
+
region: {
|
|
315
|
+
paths: ['queue'],
|
|
316
|
+
reason: 'A cloud coverage unit. Queue findings come from network analysis agents, which have '
|
|
317
|
+
+ 'no region; `scopeNotScanned` is silent for a finding with no unit, which is the correct '
|
|
318
|
+
+ 'reading — a narrowed region says nothing about a port on a network host.',
|
|
319
|
+
disclosedBy: 'AGENT_SCOPE_FROM_TIER',
|
|
320
|
+
},
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
// ⚠️ DERIVED FROM THE FROZEN CONTRACT, NOT INVENTED HERE. `docs/contract-v1.md` §5.3: the
|
|
325
|
+
// `findingCount` key KEPT ITS NAME AND CHANGED ITS VALUE in the first release after EE 0.46.0 —
|
|
326
|
+
// every archived pack before that release printed the ISSUE count under it. A delta spanning that
|
|
327
|
+
// boundary would report a mass remediation that never happened, so it is REFUSED, not annotated.
|
|
328
|
+
export const FINDING_COUNT_BOUNDARY_EE = '0.46.0';
|
|
329
|
+
|
|
330
|
+
// The honest label, and it rides in the OUTPUT rather than only in the docs: a hash chain on the
|
|
331
|
+
// same disk as the records it covers is defeated by anyone who can recompute the successors.
|
|
332
|
+
export const CHAIN_ASSURANCE_LABEL =
|
|
333
|
+
'Baseline integrity is tamper-EVIDENT against accidental corruption, partial restore and ' +
|
|
334
|
+
'unsophisticated edits. It is NOT tamper-proof against an attacker with host-level access, ' +
|
|
335
|
+
'and it is not non-repudiation: the chain carries no signature and names no author.';
|
|
336
|
+
|
|
337
|
+
// EXPORTED so a consumer that asks "was this written before release X?" asks it with THIS
|
|
338
|
+
// comparator — Enterprise's MTTR loader gates its region-identity warning on it (item 26). The "no
|
|
339
|
+
// second comparator" rule above holds across the two packages, not only within this file.
|
|
340
|
+
export const cmpVersion = (a, b) => {
|
|
341
|
+
const pa = String(a).split('.').map((n) => parseInt(n, 10) || 0);
|
|
342
|
+
const pb = String(b).split('.').map((n) => parseInt(n, 10) || 0);
|
|
343
|
+
for (let i = 0; i < 3; i += 1) if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) < (pb[i] ?? 0) ? -1 : 1;
|
|
344
|
+
return 0;
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
// ⚠️ IDENTITY MUST CARRY THE RESOURCE, and the first draft of this did not — a HIGH defect an
|
|
348
|
+
// independent seat found by reading the FIXTURES rather than the code. Cloud issue strings carry
|
|
349
|
+
// the DEFECT, not the resource ("No public access block configured…"), so twelve buckets with one
|
|
350
|
+
// misconfiguration share a title; resource identity lives in `finding.resource` (contract-v1 §1.1).
|
|
351
|
+
// Under a host+plugin+title key those twelve collapse to ONE, and the failure is in the dangerous
|
|
352
|
+
// direction: fix bucket-c, acquire bucket-d with the same defect, and the delta reported
|
|
353
|
+
// "unchanged" — A NEW EXPOSURE ABSENT FROM `newFindings` ENTIRELY, measured, not theorised.
|
|
354
|
+
//
|
|
355
|
+
// ⚠️ `severity` IS DELIBERATELY NOT IN THE KEY. Adding it would render one escalation as a
|
|
356
|
+
// resolved finding PLUS a new one — two false verdicts from one true change. Identity first, then
|
|
357
|
+
// severity compared between MATCHED findings into `changed`.
|
|
358
|
+
//
|
|
359
|
+
// ⚠️ `originalIndex` IS NOT A CANDIDATE, though contract-v1 §1.2 freezes it on every record: it is
|
|
360
|
+
// the index in the INPUT array and all fan-out records of one cloud finding SHARE it (§1.2:53).
|
|
361
|
+
// Positional and non-unique — adopting it as an id would be worse than this composed key.
|
|
362
|
+
// ⚠️ NO `?? f.target` FALLBACK, and its removal is the F5 lesson applied to a benign case. The
|
|
363
|
+
// boundary guard flagged `target` as consumed-but-never-emitted on its FIRST run: report_inputs
|
|
364
|
+
// already folds target INTO resource (`resource: f?.resource ?? f?.target ?? …`), so a finding can
|
|
365
|
+
// never reach here carrying a bare target. A fallback that cannot fire through the shipped path is
|
|
366
|
+
// not defensive depth — it is dead code that reads as coverage, which is exactly what made the
|
|
367
|
+
// framework-enumeration leg look complete while being inert.
|
|
368
|
+
// ⚠️ NEVER OVER A TRUNCATED STRING. The title is SYNTHESISED and CUT AT 160 CHARS for producers
|
|
369
|
+
// that emit none, and on a live estate three world-open ingress rules on one security group
|
|
370
|
+
// differed only past the cut — one identity for three CRITICAL exposures, so a NEW world-open
|
|
371
|
+
// database port would have read as UNCHANGED. `contentDigest` covers the untrucated content and
|
|
372
|
+
// `identityQualifier` carries the producer's OWN rule discriminators; the title remains the
|
|
373
|
+
// fallback for anything emitting neither.
|
|
374
|
+
// ⚠️ BOTH `title` AND `contentDigest`, NEVER ONE INSTEAD OF THE OTHER — measured, and the first
|
|
375
|
+
// draft got it wrong in the dangerous direction. `contentDigest ?? title` looked equivalent-or-
|
|
376
|
+
// better and was STRICTLY COARSER for producers that emit no raw title: 1020 names the bucket only
|
|
377
|
+
// in the SYNTHESISED title, while the digest covers `[rawTitle, issues]` where rawTitle is null and
|
|
378
|
+
// the issue text carries no bucket. Two different S3 buckets — `aws-config-logs-…` and
|
|
379
|
+
// `cloudtrail-violator-logs-…` — hashed identically and collapsed. The fix for one producer's
|
|
380
|
+
// masking re-created it at another, which is why this was caught on the REAL record and not by the
|
|
381
|
+
// fixtures written from 1170.
|
|
382
|
+
//
|
|
383
|
+
// Taking both is strictly finer than either: the digest separates content that TRUNCATION hid, the
|
|
384
|
+
// title separates naming the digest never saw, and the qualifier separates rules that share both.
|
|
385
|
+
//
|
|
386
|
+
// ⚠️ `region` IS A COMPONENT SINCE E1, AND REMOVING THE SUFFIX WITHOUT ADDING IT WOULD COLLAPSE
|
|
387
|
+
// A POPULATION. Before E1 the region entered this key only as decoration inside `resource`
|
|
388
|
+
// (` [us-east-1]`, appended downstream by EE's `_stampRegion`), so a per-region finding that
|
|
389
|
+
// names no object — a scope literal like `backup:account`, emitted once per region — was
|
|
390
|
+
// separated ONLY by that suffix. De-suffixing alone would have mapped every region's copy onto
|
|
391
|
+
// one key and reported N-1 of them as removed. It is taken from the finding's own FIELD, which
|
|
392
|
+
// both sides of any comparison recompute identically, so unlike a suffix it fabricates nothing
|
|
393
|
+
// across an upgrade.
|
|
394
|
+
// The host is compared by `hostKey` (census G6): one host is one host whatever case it was typed in.
|
|
395
|
+
const keyOf = (f) => [hostKey(f.host), f.plugin, f.resource ?? '-', f.port ?? '-',
|
|
396
|
+
f.region ?? '-', f.identityQualifier ?? '-', f.title, f.contentDigest ?? '-'].join('|');
|
|
397
|
+
|
|
398
|
+
// A plugin status that means THE SURFACE WAS NOT READ. `ran` is the only status that licenses a
|
|
399
|
+
// comparison; the rest are the machine saying so itself.
|
|
400
|
+
// ⚠️ EXPORTED AS A FROZEN LIST, NOT AS THE SET. Enterprise's MTTR engine reads the same vocabulary
|
|
401
|
+
// (a prior row whose plugin was not measured now is not closed), and a shared Set is one any
|
|
402
|
+
// importer could `add` to. The vocabulary is CLOSED: `tests/scan_delta.test.mjs` holds every
|
|
403
|
+
// status literal the plugin manager assigns inside MEASURED_STATUS ∪ NOT_MEASURED_STATUSES.
|
|
404
|
+
export const MEASURED_STATUS = 'ran';
|
|
405
|
+
export const NOT_MEASURED_STATUSES = Object.freeze(['error', 'timeout', 'skipped']);
|
|
406
|
+
const NOT_MEASURED_STATUS = new Set(NOT_MEASURED_STATUSES);
|
|
407
|
+
|
|
408
|
+
// ⚠️ SCOPE IS WHAT A RUN MEASURED, WHICH IS NARROWER THAN WHAT IT REQUESTED IN THREE WAYS, and
|
|
409
|
+
// every one of them was fail-open here until 2026-09-20 — each produced a `resolved` row in a
|
|
410
|
+
// CLIENT artifact when driven through `report --since`:
|
|
411
|
+
// HOSTS were `hostsRequested ∪ hostsWritten`, so a host that was asked for and never produced
|
|
412
|
+
// output counted as scanned and its baseline findings read as remediation.
|
|
413
|
+
// PLUGIN STATUS was not consulted at all, though the loader has always carried it, so a plugin
|
|
414
|
+
// that ERRORED or TIMED OUT on a host counted as having measured it.
|
|
415
|
+
// EVIDENCE GAPS were read from `side.evidenceGaps`, which NO shipped caller ever populated —
|
|
416
|
+
// dead since the day it was written, and the fall-through from a dead leg is `resolved`.
|
|
417
|
+
// ⚠️ THE `side.evidenceGaps` READ IS DELETED RATHER THAN REPAIRED. It was unreachable AND
|
|
418
|
+
// shape-wrong: the only producer of that record shape (`cloud_finding_summary.mjs`) builds
|
|
419
|
+
// `plugin: String(r?.id ?? '')` with NO `host` key at all, so even a caller that filled it would
|
|
420
|
+
// key `undefined|1170` against a lookup keyed `<host>|<id>`. Leaving a second unreachable path
|
|
421
|
+
// beside the repaired one is the F5 mistake this module already records at the framework leg —
|
|
422
|
+
// dead code that reads as coverage.
|
|
423
|
+
/**
|
|
424
|
+
* The COVERAGE UNIT a finding sits in, per provider, and whether the OTHER run recorded covering
|
|
425
|
+
* it. Returns a detail string when the finding is outside that run's recorded scope, else null.
|
|
426
|
+
*
|
|
427
|
+
* Each provider resolves a different unit, so the unit is read per provider rather than assumed:
|
|
428
|
+
* aws → REGION, carried per finding as `f.region`
|
|
429
|
+
* azure → SUBSCRIPTION, carried by the HOST (every finding of that host shares it)
|
|
430
|
+
* gcp → PROJECT, likewise
|
|
431
|
+
*
|
|
432
|
+
* ⚠️ THREE SILENCES, EACH DELIBERATE. (1) BOTH sides unknown → silent: every record written
|
|
433
|
+
* before this field existed lacks it, and firing there would turn every historical comparison
|
|
434
|
+
* into a wall of rows. (2) A finding with NO unit (`iam:account`, no region) is not scoped by
|
|
435
|
+
* that unit and is untouched — otherwise a narrowed region makes the whole account
|
|
436
|
+
* not-comparable. (3) A side that DISAGREED with itself about its coverage is UNKNOWN, not
|
|
437
|
+
* covered: the resolver memo is keyed on the credential fingerprint, so two keys resolving
|
|
438
|
+
* different sets inside one host's scan means the run has no single coverage to difference.
|
|
439
|
+
*/
|
|
440
|
+
// ⚠️ IMPORTED, NOT DECLARED HERE — this module's ONLY import, and it buys the removal of a
|
|
441
|
+
// second copy. Enterprise's CPE mapper needs the same provider list to know that a cloud host
|
|
442
|
+
// has no service-feeding upstreams; `utils/cloud_providers.mjs` is the single home and derives
|
|
443
|
+
// its host set from THIS map's keys, so adding a provider is one edit in one file.
|
|
444
|
+
import { PROVIDER_SCOPE_UNIT, canonicalHost, hostKey } from './cloud_providers.mjs';
|
|
445
|
+
|
|
446
|
+
// ── A PORT THE RUN COULD NOT MEASURE (EE 1.1.0 build 9, F6) — one decision, shared with Enterprise's MTTR ──
|
|
447
|
+
/** A port-scoped input gap: Enterprise's record that a probe could not complete on this port. */
|
|
448
|
+
// Written as plain `f.` reads so the boundary contract's derivation SEES them (it reads `f.<field>`; an
|
|
449
|
+
// optional chain is invisible to it, and `gapClass` would read as consumed-but-undeclared).
|
|
450
|
+
export const isPortInputGap = (f) => f != null && f.evidenceGap === true && f.gapClass === INPUT_GAP_CLASS && Number(f.port) > 0;
|
|
451
|
+
/** The key a port gap is looked up by — `hostKey`, so a host typed in two cases is one host. */
|
|
452
|
+
export const portGapKey = (host, port) => `${hostKey(host)}|${Number(port)}`;
|
|
453
|
+
/** Every port a run could not measure: `portGapKey` → the gap's title (what the reader is told). */
|
|
454
|
+
export function portsNotMeasured(findings) {
|
|
455
|
+
const out = new Map();
|
|
456
|
+
for (const f of Array.isArray(findings) ? findings : []) {
|
|
457
|
+
if (!isPortInputGap(f)) continue;
|
|
458
|
+
const k = portGapKey(f.host, f.port);
|
|
459
|
+
if (!out.has(k)) out.set(k, f.title ?? 'an input gap was recorded on this port');
|
|
460
|
+
}
|
|
461
|
+
return out;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Per host, what the port scanner recorded: `hostKey` → { open: Set, closed: Set }. Read from the loader's
|
|
466
|
+
* `portScan`, which it sets only where 003 RAN and carried both lists. Two directories naming one host whose scans
|
|
467
|
+
* DISAGREE about a port are resolved conservatively: a port counts as open if either saw it open and as closed only
|
|
468
|
+
* if some scan closed it — the verdict below needs "open on one side" AND "in neither list on the other".
|
|
469
|
+
*/
|
|
470
|
+
function portStateOf(byHost) {
|
|
471
|
+
const out = new Map();
|
|
472
|
+
for (const h of Array.isArray(byHost) ? byHost : []) {
|
|
473
|
+
const ps = h?.portScan;
|
|
474
|
+
if (!ps || !Array.isArray(ps.tcpOpen) || !Array.isArray(ps.tcpClosed)) continue;
|
|
475
|
+
const k = hostKey(h.host);
|
|
476
|
+
const e = out.get(k) ?? { open: new Set(), closed: new Set() };
|
|
477
|
+
for (const p of ps.tcpOpen) if (Number(p) > 0) e.open.add(Number(p));
|
|
478
|
+
for (const p of ps.tcpClosed) if (Number(p) > 0) e.closed.add(Number(p));
|
|
479
|
+
out.set(k, e);
|
|
480
|
+
}
|
|
481
|
+
return out;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function scopeNotScanned(f, mine, theirs) {
|
|
485
|
+
const provider = hostKey(f?.host);
|
|
486
|
+
const mineEntry = mine?.scopeScanned?.[provider] ?? null;
|
|
487
|
+
const theirEntry = theirs?.scopeScanned?.[provider] ?? null;
|
|
488
|
+
if (!mineEntry && !theirEntry) return null; // (1) neither side knows
|
|
489
|
+
|
|
490
|
+
// ⚠️ THE UNIT IS A PROPERTY OF THE PROVIDER, NEVER OF THE RECORD ENTRY — and the first draft
|
|
491
|
+
// had it the other way round, which made the both-sides-unknown guard DEAD CODE: with no entry
|
|
492
|
+
// on either side the unit fell back to a literal that is not 'region', so the value lookup
|
|
493
|
+
// returned null and the guard below caught every case first. A mutant deleting guard (1)
|
|
494
|
+
// survived the whole fixture set, which is how it was found. Reading the unit from the provider
|
|
495
|
+
// makes the guard load-bearing: a pre-fix pair with a regional finding now reaches it.
|
|
496
|
+
const unitName = PROVIDER_SCOPE_UNIT[provider] ?? null;
|
|
497
|
+
if (unitName === null) return null; // a provider with no coverage unit of its own
|
|
498
|
+
// ⚠️ THE UNIT COMES FROM THE FINDING'S OWN SIDE, NEVER BORROWED FROM THE OTHER — and borrowing
|
|
499
|
+
// was a live defect. A region RIDES ON the finding, so a finding's existence proves its own run
|
|
500
|
+
// covered that region and the only unknown is ever the OTHER side's coverage; that asymmetry is
|
|
501
|
+
// deliberate. A subscription or project rides on the HOST, so when the finding's own record
|
|
502
|
+
// carries no scope there is nothing to say WHICH subscription it belonged to. Reading
|
|
503
|
+
// `mineEntry ?? theirEntry` made `covered.has(value)` true by construction: measured, a baseline
|
|
504
|
+
// azure finding whose record lacked the field read RESOLVED against a current scoped to a
|
|
505
|
+
// DIFFERENT subscription — the first delta after upgrading, on a possibly different estate.
|
|
506
|
+
let value;
|
|
507
|
+
if (unitName === 'region') {
|
|
508
|
+
value = typeof f?.region === 'string' && f.region ? f.region : null;
|
|
509
|
+
if (value === null) return null; // (2) not a regional finding
|
|
510
|
+
} else if (!mineEntry) {
|
|
511
|
+
// Guard (1) already returned when NEITHER side knows, so the other side does know — and this
|
|
512
|
+
// finding's own run does not. Which unit it belongs to is unknowable, so it fails closed.
|
|
513
|
+
return `this run's record carries no ${unitName} scope, so which ${unitName} this finding `
|
|
514
|
+
+ 'belongs to is not known — it cannot be called fixed or new against the other run';
|
|
515
|
+
} else {
|
|
516
|
+
value = mineEntry.scanned?.[0] ?? null;
|
|
517
|
+
if (value === null) return null; // (2) recorded an empty scope
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (!theirEntry) {
|
|
521
|
+
return `the other run's record carries no ${unitName} scope, so it is not known whether `
|
|
522
|
+
+ `${unitName} ${value} was covered there — this finding cannot be called fixed or new `
|
|
523
|
+
+ 'against an unknown scope';
|
|
524
|
+
}
|
|
525
|
+
if (theirEntry.disagreed === true || mineEntry?.disagreed === true) {
|
|
526
|
+
return `a run disagreed with itself about which ${unitName}s it covered, so its scope is ` // (3)
|
|
527
|
+
+ `unknown; ${unitName} ${value} cannot be differenced against it`;
|
|
528
|
+
}
|
|
529
|
+
const covered = new Set(Array.isArray(theirEntry.scanned) ? theirEntry.scanned : []);
|
|
530
|
+
if (covered.has(value)) {
|
|
531
|
+
// ⚠️ THE RUN-LEVEL SET IS A UNION, AND A UNION IS NOT A STATEMENT ABOUT ONE PRODUCER. It is
|
|
532
|
+
// every region ANY producer resolved in that run, so a record saying `[us-east-1, eu-west-1]`
|
|
533
|
+
// can contain a producer that only ever looked at `us-east-1` — 1200 resolves regions ONLY
|
|
534
|
+
// under an explicit `awsRegionIntent`, and several plugins iterate no regions at all. Without
|
|
535
|
+
// this refinement the check answers "eu-west-1 WAS covered", the finding is differenced, and a
|
|
536
|
+
// surface that producer never examined reads as REMEDIATED: the same defect `scope-not-scanned`
|
|
537
|
+
// exists for, one level of granularity down.
|
|
538
|
+
//
|
|
539
|
+
// ⚠️ A MISSING ENTRY IS NOT A NEGATIVE, and that asymmetry is the rule. `byPlugin` refines
|
|
540
|
+
// only where it SPEAKS: no map at all is every record written before the field shipped, and a
|
|
541
|
+
// map without THIS producer means the run made no statement about it — a producer that earns
|
|
542
|
+
// no entry because it resolves no regions is the BACKSTOP's subject, adjudicated on its own
|
|
543
|
+
// terms. Reading either silence as "covered nothing" would fire on the ABSENCE of information
|
|
544
|
+
// and refuse every archived comparison at once.
|
|
545
|
+
const perProducer = theirEntry.byPlugin?.[String(f?.plugin)];
|
|
546
|
+
if (Array.isArray(perProducer) && !perProducer.includes(value)) {
|
|
547
|
+
return `${unitName} ${value} is inside the other run's overall scope, but producer `
|
|
548
|
+
+ `${f.plugin} only covered ${perProducer.join(', ') || 'no ' + unitName + 's'} there — `
|
|
549
|
+
+ 'the run-level set is the union across producers, so this surface was not looked at by '
|
|
550
|
+
+ 'the producer that would have found it, which is not the same as the finding being fixed';
|
|
551
|
+
}
|
|
552
|
+
return null; // genuinely comparable
|
|
553
|
+
}
|
|
554
|
+
return `${unitName} ${value} was outside the other run's recorded scope `
|
|
555
|
+
+ `(${[...covered].join(', ') || 'none recorded'}) — the surface was not looked at there, `
|
|
556
|
+
+ 'which is not the same as the finding being fixed';
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const scopeOf = (side) => {
|
|
560
|
+
const rec = side?.record ?? {};
|
|
561
|
+
// ⚠️ EVERY HOST HERE IS A `hostKey` (census G6). This set and `scopeScanned` below come from the RAW
|
|
562
|
+
// record, not the loader's model, so a record written as `AWS` before the parse-time fold — or a host
|
|
563
|
+
// typed `MyHost.local` in one run and `myhost.local` in the next — must be keyed HERE, or the pair
|
|
564
|
+
// reads host-not-scanned. And the gap and statement maps are keyed the same way: a host set that paired
|
|
565
|
+
// across case while the gap map did not would lose the other run's gap and call its finding RESOLVED.
|
|
566
|
+
// What a reader is SHOWN keeps the recorded spelling (`hostNames`).
|
|
567
|
+
const hostNames = new Map();
|
|
568
|
+
const keyHost = (h) => {
|
|
569
|
+
const k = hostKey(h);
|
|
570
|
+
if (k && !hostNames.has(k)) hostNames.set(k, canonicalHost(h));
|
|
571
|
+
return k;
|
|
572
|
+
};
|
|
573
|
+
const hosts = new Set((rec.hostsWritten ?? []).map((h) => keyHost(h?.host)).filter(Boolean));
|
|
574
|
+
const plugins = new Set(rec.pluginsRequested ?? []);
|
|
575
|
+
const gaps = new Map();
|
|
576
|
+
// Gaps as the PRODUCER records them — on the finding, where they already ride.
|
|
577
|
+
// ⚠️ EXCEPT A PORT-SCOPED INPUT GAP (EE 1.1.0 build 9). Enterprise emits one per analysis agent for each
|
|
578
|
+
// port a probe could not measure, so keyed here by PRODUCER it would set aside that agent's rows on EVERY
|
|
579
|
+
// port of the host, and a genuine fix on a port that WAS measured would stop reading resolved. It is
|
|
580
|
+
// scoped to its port, in `portGaps`, and nowhere else.
|
|
581
|
+
for (const fi of side?.findings ?? []) {
|
|
582
|
+
if (fi?.evidenceGap === true && fi.plugin != null && !isPortInputGap(fi)) {
|
|
583
|
+
gaps.set(`${keyHost(fi.host)}|${fi.plugin}`,
|
|
584
|
+
{ kind: 'recorded-gap', reason: fi.title ?? fi.detail ?? 'an evidence gap was recorded' });
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
// ⚠️ SCOPE STATEMENTS — a producer's own declaration of what it does NOT examine
|
|
588
|
+
// (`details.deferredScope`). Not an exposure and not a gap: set aside here, beside the gaps, so the
|
|
589
|
+
// pairing below never sees them and a reworded boundary cannot read as a fix plus a new finding.
|
|
590
|
+
// A row carrying BOTH flags is a GAP: "could not read" outranks "does not examine".
|
|
591
|
+
const statements = new Map();
|
|
592
|
+
for (const fi of side?.findings ?? []) {
|
|
593
|
+
if (fi?.deferredScope === true && fi?.evidenceGap !== true && fi.plugin != null) {
|
|
594
|
+
const k = `${keyHost(fi.host)}|${fi.plugin}`;
|
|
595
|
+
statements.set(k, [...(statements.get(k) ?? []), fi.title ?? '']);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
// And as the ENGINE records them: a plugin the host never successfully ran.
|
|
599
|
+
for (const h of (Array.isArray(side?.pluginStatus) ? side.pluginStatus : [])) {
|
|
600
|
+
for (const ps of (h?.status ?? [])) {
|
|
601
|
+
if (!NOT_MEASURED_STATUS.has(ps?.status)) continue;
|
|
602
|
+
// ⚠️ A DIFFERENT KIND, AND THE DIFFERENCE IS CLIENT-VISIBLE. Both mean "this surface was not
|
|
603
|
+
// measured", so both belong in the same scope map — but a producer DECLARING a gap
|
|
604
|
+
// (AccessDenied, budget exceeded) and a plugin CRASHING are not the same event, and the
|
|
605
|
+
// first draft of this reported the second with the first's sentence: "the other run
|
|
606
|
+
// recorded an evidence gap … the plugin's status on that host was error". The other run
|
|
607
|
+
// recorded nothing of the sort. That sentence renders into the client's basis cell, which
|
|
608
|
+
// is the surface this whole engine exists to keep honest.
|
|
609
|
+
gaps.set(`${keyHost(h.host)}|${String(ps.id)}`, { kind: 'not-measured',
|
|
610
|
+
reason: `the plugin's status on that host was "${ps.status}"${ps.reason ? `: ${ps.reason}` : ''}` });
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
return {
|
|
614
|
+
hosts, hostNames, plugins, gaps, statements,
|
|
615
|
+
// The ports this side could not measure, keyed `hostKey|port` (EE 1.1.0 build 9).
|
|
616
|
+
portGaps: portsNotMeasured(side?.findings),
|
|
617
|
+
// What this side's PORT SCANNER recorded, per host (EE 1.1.0 build 12): present only where 003 RAN and wrote
|
|
618
|
+
// both lists (the loader's `portScan`), so an absent entry means "no oracle", never "nothing open".
|
|
619
|
+
portState: portStateOf(side?.pluginStatus),
|
|
620
|
+
// The release that WROTE this side. Carried on the scope because the identity-basis
|
|
621
|
+
// declaration is a property of the comparison — which releases the two runs straddle — and
|
|
622
|
+
// `incomparabilityReason` sees only the two scopes.
|
|
623
|
+
eeVersion: rec.eeVersion ?? null,
|
|
624
|
+
// ⚠️ WHAT THE RUN ACTUALLY COVERED, per provider — never the FLAG. `undefined` means the
|
|
625
|
+
// record predates this field and the scope is UNKNOWN, which is not the same as "covered
|
|
626
|
+
// nothing": the rule below fails closed on unknown and stays silent when BOTH sides are
|
|
627
|
+
// unknown, because every record written before this change lacks it.
|
|
628
|
+
scopeScanned: rec.scopeScanned
|
|
629
|
+
? Object.fromEntries(Object.entries(rec.scopeScanned).map(([h, v]) => [hostKey(h), v]))
|
|
630
|
+
: null,
|
|
631
|
+
// ⚠️ AN ABSENT ORACLE IS NOT A CLEAN ONE. `[]` means "measured, no gaps"; MISSING means
|
|
632
|
+
// nothing was measured, and the two must not render alike. Declared in `limits`, never
|
|
633
|
+
// absorbed here — gate:cascade's LEG (ii) is this repo's precedent for the distinction.
|
|
634
|
+
// ⚠️ THIS READ `Array.isArray(side?.pluginStatus)` UNTIL THE OUTCOME CENSUS DROVE IT. Through
|
|
635
|
+
// the shipped path `side.pluginStatus` is `model.plugins.byHost`, which the loader ALWAYS
|
|
636
|
+
// builds — so the test was always true, `scope-not-evaluated` was dead, and a run that
|
|
637
|
+
// recorded no per-host plugin status rendered identically to one that recorded a clean
|
|
638
|
+
// status. That is the exact false clean the comment above forbids, living inside the guard
|
|
639
|
+
// written to prevent it. The loader now carries `pluginStatusRecorded` beside the defaulted
|
|
640
|
+
// array, and this asks the question the comment always meant.
|
|
641
|
+
evaluable: Array.isArray(side?.pluginStatus)
|
|
642
|
+
&& side.pluginStatus.every((h) => h?.pluginStatusRecorded !== false),
|
|
643
|
+
frameworks: side?.frameworkEnumeration ?? null,
|
|
644
|
+
};
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
export const SCOPE_NOT_EVALUATED =
|
|
648
|
+
'Scope non-evaluation: one of the two runs recorded no per-host plugin status, so evidence gaps '
|
|
649
|
+
+ 'and failed plugins could NOT be distinguished from a clean scan on that side. A finding '
|
|
650
|
+
+ 'reported as resolved here may instead be one the scanner could not read. Re-run the '
|
|
651
|
+
+ 'comparison against a run recorded by this release or later before treating any row as remediation.';
|
|
652
|
+
|
|
653
|
+
// ⚠️ THE SECOND HALF WAS ADDED BECAUSE FOUR DECLARED ABSENCES POINT AT THIS SENTENCE AND IT SAID
|
|
654
|
+
// NOTHING ABOUT THEM. `contentDigest`, `identityQualifier`, `resource` and `region` are all absent
|
|
655
|
+
// on the queue path and each is excused HERE — but the text disclosed only how an agent's SCOPE is
|
|
656
|
+
// derived, while what those absences change is IDENTITY. A carve-out is disclosed only if the
|
|
657
|
+
// sentence a reader actually meets is the sentence that discloses it.
|
|
658
|
+
//
|
|
659
|
+
// The consequence is not theoretical: on a real 192.168.1.1 record, TWENTY CVE rows share host,
|
|
660
|
+
// port, protocol, service, program and version — `53/udp/dns`, `dnsmasq 2.78` — and are separated
|
|
661
|
+
// by their titles alone (20 rows, 20 distinct titles, derived; a first count of 21 was mine and
|
|
662
|
+
// was wrong). With no digest on this path, one edit to that title template collapses all twenty
|
|
663
|
+
// into a single identity, and IDENTITY_COLLAPSE is the only thing that would say so — after the
|
|
664
|
+
// fact. The 1.1.1 repair must hash STABLE content (for a CVE row, `evidence.cve[]` sorted plus
|
|
665
|
+
// the service key) and NEVER `description`, which carries the program name identity is moving
|
|
666
|
+
// away from — a digest over volatile prose reintroduces the volatility through the digest.
|
|
667
|
+
export const AGENT_SCOPE_FROM_TIER =
|
|
668
|
+
'Agent-produced findings: their scope is derived from the run TIER, not from a per-agent run '
|
|
669
|
+
+ 'record — this edition persists no per-agent status, and the agent set is a function of the '
|
|
670
|
+
+ 'licensed capabilities. The two runs carry the same tier, which is what makes them comparable; '
|
|
671
|
+
+ 'a tier difference refuses the comparison outright rather than narrowing it. '
|
|
672
|
+
+ 'Their IDENTITY is also narrower than a plugin finding\'s: an agent finding is keyed on host, '
|
|
673
|
+
+ 'producer, port and title, and on nothing else — it carries no object (resource), no region, '
|
|
674
|
+
+ 'no rule qualifier and NO CONTENT DIGEST. So two agent findings that differ only in text the '
|
|
675
|
+
+ 'title does not carry share one identity, and one of them can be masked by the other; where '
|
|
676
|
+
+ 'that happens it is named individually under the identity-collapse limit.';
|
|
677
|
+
|
|
678
|
+
// The producer as a READER should see it. `plugin` is an id because that is the vocabulary the
|
|
679
|
+
// run record can be checked against; the id alone is not a sentence, and this string is rendered
|
|
680
|
+
// into the client's report. Both are printed when they differ, so the prose is readable AND the
|
|
681
|
+
// token matches the baseline scope line, which prints `pluginsRequested` — ids.
|
|
682
|
+
const producerLabel = (f) => (f.pluginName && f.pluginName !== f.plugin ? `${f.pluginName} (${f.plugin})` : String(f.plugin));
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* THE NOUN FOR A PRODUCER, KEYED ON ITS KIND AND NEVER ON ITS NAME.
|
|
686
|
+
*
|
|
687
|
+
* ⚠️ EXPORTED SO IT CAN BE PROVEN, because it CANNOT be driven. A reviewing seat's mutant re-keyed
|
|
688
|
+
* this from `producerKind` to `f.plugin === 'intelligence_engine'` and passed every leg in the
|
|
689
|
+
* declaration suite — with exactly ONE agent in `IDENTITY_BASIS_CHANGED_AT`, no fixture built from
|
|
690
|
+
* the corpus can tell agent-NESS from that one NAME, and the only branch that prints this noun
|
|
691
|
+
* requires a DECLARED producer. So the case that distinguishes the two implementations is not
|
|
692
|
+
* expressible through the shipped path today, and the day a second agent is declared the client
|
|
693
|
+
* cell would quietly call it a plugin. A helper that cannot be driven is exported and tested
|
|
694
|
+
* directly rather than left to be verified by a fixture that cannot reach it.
|
|
695
|
+
*/
|
|
696
|
+
export const producerNoun = (f) => (f?.producerKind === 'agent' ? 'producer' : 'plugin');
|
|
697
|
+
|
|
698
|
+
// Why a finding present in ONE run cannot be compared against the other. Order matters only for
|
|
699
|
+
// which reason is reported first; each is independently sufficient.
|
|
700
|
+
function incomparabilityReason(f, mine, theirs) {
|
|
701
|
+
if (!theirs.hosts.has(hostKey(f.host))) return { reason: 'host-not-scanned', detail: `host ${f.host} was not scanned in the other run` };
|
|
702
|
+
// ⚠️ A NULL IDENTITY MAY NEVER SATISFY A SCOPE CHECK, and it may never be DESCRIBED as one
|
|
703
|
+
// either. Before this leg a finding with no producer fell into `plugin-not-run` and reported
|
|
704
|
+
// "plugin null did not run in the other run" — a sentence that is false about the run, about a
|
|
705
|
+
// finding whose comparability was never established. The honest verdict names the thing that is
|
|
706
|
+
// missing. It is checked FIRST because every leg below keys on the producer.
|
|
707
|
+
if (f.plugin == null) {
|
|
708
|
+
return { reason: 'producer-unknown',
|
|
709
|
+
detail: 'this finding carries no producer identity, so whether it was in scope in the other run cannot be established' };
|
|
710
|
+
}
|
|
711
|
+
// ⚠️ THE ORACLE DEPENDS ON THE PRODUCER KIND, and guessing it from the string's SHAPE is what
|
|
712
|
+
// this leg refuses to do. A plugin id is answerable from `pluginsRequested`. An EE analysis
|
|
713
|
+
// agent appears in no such list and never will, so checking it there would bucket every
|
|
714
|
+
// agent-produced finding as `plugin-not-run` for ever — safe, and useless, which is the failure
|
|
715
|
+
// mode this engine was built to avoid on the other axis. Its scope is the run TIER, and the two
|
|
716
|
+
// whole-comparison refusals above (`ee-presence-differs`, `tier-differs`) are what make that
|
|
717
|
+
// sound: by the time control reaches here both sides carry Enterprise and carry the SAME tier,
|
|
718
|
+
// so the agent set is identical on both. No per-finding agent check is written here, because a
|
|
719
|
+
// check that cannot fail through the shipped path is dead code that reads as coverage.
|
|
720
|
+
if (f.producerKind !== 'agent' && !theirs.plugins.has(f.plugin)) {
|
|
721
|
+
// ⚠️ THE NOUN HERE IS A LITERAL `plugin` AND THAT IS CORRECT — but only because of the
|
|
722
|
+
// `producerKind !== 'agent'` guard on this very line, which makes the branch unreachable for
|
|
723
|
+
// an agent. Delete that guard and this sentence starts calling agents plugins, which is the
|
|
724
|
+
// defect the identity branch below had to be repaired for. The coupling is written here
|
|
725
|
+
// because a reader deleting the guard is reading THIS line, not the sentence's test.
|
|
726
|
+
return { reason: PLUGIN_NOT_RUN_REASON, detail: `plugin ${producerLabel(f)} did not run in the other run` };
|
|
727
|
+
}
|
|
728
|
+
// ⚠️ THE PRODUCER CHANGED WHAT IT NAMES BETWEEN THESE TWO RELEASES, so its two keys for one
|
|
729
|
+
// object cannot be matched and must not be DIFFERENCED either. Checked here, before the
|
|
730
|
+
// evidence-gap legs, because it is a statement about the COMPARISON rather than about either
|
|
731
|
+
// run's coverage: the surface was read on both sides, and what moved is the noun.
|
|
732
|
+
//
|
|
733
|
+
// It is keyed on the baseline PREDATING the declared change, never on the versions merely
|
|
734
|
+
// differing — the latter would declare on every upgrade for ever and quietly retire the
|
|
735
|
+
// feature. `identityBasisChanged` is one-directional for that reason.
|
|
736
|
+
if (identityBasisChanged(f.plugin, mine.eeVersion, theirs.eeVersion)
|
|
737
|
+
|| identityBasisChanged(f.plugin, theirs.eeVersion, mine.eeVersion)) {
|
|
738
|
+
const at = IDENTITY_BASIS_CHANGED_AT[f.plugin];
|
|
739
|
+
return { reason: IDENTITY_BASIS_CHANGED_REASON,
|
|
740
|
+
// ⚠️ "producer" FOR AN AGENT. This sentence renders into the client artifact's basis cell,
|
|
741
|
+
// and it hardcoded "plugin" — harmless while every declared producer WAS one, and false the
|
|
742
|
+
// moment an analysis agent joined the table. It was UNREACHABLE for an agent until that
|
|
743
|
+
// happened, which is why the wording lands in the same commit as the declaration rather
|
|
744
|
+
// than earlier: before this, no test could have driven it. Same class as the
|
|
745
|
+
// "plugin undefined did not run in the other run" detail this engine already had to fix.
|
|
746
|
+
detail: `${producerNoun(f)} ${producerLabel(f)} `
|
|
747
|
+
+ `changed what it names as a finding's object at EE ${at}; `
|
|
748
|
+
+ 'the two runs straddle that change, so this finding\'s identity is not comparable '
|
|
749
|
+
+ 'between them. It is NOT reported as fixed or as new — rescan to compare.' };
|
|
750
|
+
}
|
|
751
|
+
// ⚠️ DID THE OTHER RUN EVEN LOOK THERE? Placed after plugin-not-run and the identity
|
|
752
|
+
// declaration — those are statements about the PRODUCER and the COMPARISON — and before the
|
|
753
|
+
// gap legs, because "the surface was outside the scan's scope" is more precise than "no gap was
|
|
754
|
+
// recorded for it": a region nobody scanned records no gap by construction.
|
|
755
|
+
//
|
|
756
|
+
// ⚠️ MEASURED, NOT HYPOTHETICAL. Two live AWS passes differing only in `--aws-region` reported
|
|
757
|
+
// EIGHT unremediated `eu-west-1` findings as RESOLVED — GuardDuty NOT ENABLED, Inspector2
|
|
758
|
+
// DISABLED, default EBS encryption DISABLED — because narrowing a follow-up scan is a normal
|
|
759
|
+
// operator action and nothing in the record could tell it from remediation.
|
|
760
|
+
const scopeMiss = scopeNotScanned(f, mine, theirs);
|
|
761
|
+
if (scopeMiss) return { reason: SCOPE_NOT_SCANNED_REASON, detail: scopeMiss };
|
|
762
|
+
|
|
763
|
+
// ⚠️ `theirs` ONLY, AND THE `?? mine.gaps` THAT USED TO SIT HERE WAS WRONG IN BOTH HALVES.
|
|
764
|
+
// A gap says "this run could not read that surface", so it explains what a run is MISSING —
|
|
765
|
+
// never what a run is HOLDING. This lookup only ever runs for an UNMATCHED finding, and in both
|
|
766
|
+
// directions `theirs` is the side that failed to look: for a row that vanished, the other run is
|
|
767
|
+
// where it should have reappeared; for a row that appeared, the other run is where it should
|
|
768
|
+
// already have been. `mine` is never the side that failed to look at a finding my own run is
|
|
769
|
+
// holding in its hand.
|
|
770
|
+
//
|
|
771
|
+
// ⚠️ IT WAS UNREACHABLE, WHICH IS WHY IT SHIPPED — and EE 1.1.0's widened mapper boundary made
|
|
772
|
+
// it live on ordinary pairs. Before, the `mine` half needed a gap on the finding's own side
|
|
773
|
+
// beside a bucketed row from the SAME producer, and no shipped producer made that pair. Now both
|
|
774
|
+
// sides of any degraded network comparison carry the mapper's gap. Driven before this fix: a
|
|
775
|
+
// genuinely NEW row on the degraded side, against a full baseline that recorded no gap at all,
|
|
776
|
+
// came back `new = 0` and `not-comparable · evidence-gap` — a suppressed new exposure, explained
|
|
777
|
+
// by the detail below, which says "the OTHER run recorded an evidence gap" and was false about
|
|
778
|
+
// the baseline. Narrowing to `theirs` is what makes that sentence unconditionally true.
|
|
779
|
+
const gap = theirs.gaps.get(`${hostKey(f.host)}|${f.plugin}`);
|
|
780
|
+
if (gap) {
|
|
781
|
+
return gap.kind === 'recorded-gap'
|
|
782
|
+
? { reason: 'evidence-gap',
|
|
783
|
+
detail: `the other run recorded an evidence gap on ${f.host}/${producerLabel(f)}: ${gap.reason}` }
|
|
784
|
+
: { reason: PLUGIN_NOT_MEASURED_REASON,
|
|
785
|
+
detail: `${f.host}/${producerLabel(f)} was not measured in the other run — ${gap.reason}` };
|
|
786
|
+
}
|
|
787
|
+
// ⚠️ THE FINDING'S PORT WAS NOT MEASURED IN THE OTHER RUN (EE 1.1.0 build 9, F6). Any producer's row: a
|
|
788
|
+
// probe that could not complete on an OPEN port starves every consumer of that port's service. Placed
|
|
789
|
+
// AFTER the producer-wide legs, so a verdict they already give does not move. `theirs` only, as above.
|
|
790
|
+
const portGap = f.port != null && Number(f.port) > 0 ? theirs.portGaps?.get(portGapKey(f.host, f.port)) : null;
|
|
791
|
+
if (portGap) {
|
|
792
|
+
return { reason: PROBE_NOT_MEASURED_REASON,
|
|
793
|
+
detail: `port ${f.port} on ${f.host} was not measured in the other run — a probe ran there and did not `
|
|
794
|
+
+ `complete its connection (${portGap}). It is NOT reported as fixed or as new — rescan to compare.` };
|
|
795
|
+
}
|
|
796
|
+
// ⚠️ THE FINDING'S PORT STOPPED ANSWERING (EE 1.1.0 build 12). Open in the run that HOLDS the finding, and in the
|
|
797
|
+
// other run the port scanner ran and put it in NEITHER list: no answer inside its timeout, or not probed. After
|
|
798
|
+
// F6, so a probe-not-measured verdict does not move. Keyed on the HOLDING run's tcpOpen, so a UDP service or a
|
|
799
|
+
// port no TCP list ever named is untouched; and only where the other run's scanner RAN on this host — with no
|
|
800
|
+
// oracle there the rule is silent and the legs above govern (a stated limit, not an inference).
|
|
801
|
+
const port = Number(f.port);
|
|
802
|
+
if (port > 0) {
|
|
803
|
+
const held = mine.portState?.get(hostKey(f.host));
|
|
804
|
+
const other = theirs.portState?.get(hostKey(f.host));
|
|
805
|
+
if (held?.open.has(port) && other && !other.open.has(port) && !other.closed.has(port)) {
|
|
806
|
+
return { reason: PORT_NOT_MEASURED_REASON,
|
|
807
|
+
detail: `port ${port} on ${f.host} was open in the run that holds this finding, and the other run's port `
|
|
808
|
+
+ 'scanner recorded it neither open nor closed — no answer inside its timeout (filtered), or not probed — so '
|
|
809
|
+
+ 'the port was not measured there. It is NOT reported as fixed or as new — rescan to compare. (This rule '
|
|
810
|
+
+ 'reads TCP ports the port scanner saw open; UDP-only and host-wide findings are not judged by it.)' };
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
// ⚠️ NO SILENT SHORT-CIRCUIT. This used to read `mine.frameworks && theirs.frameworks &&
|
|
814
|
+
// f.control`, and all three are absent through the shipped path — so the leg returned null and
|
|
815
|
+
// the finding fell through to `resolved`. That is FAIL-OPEN, the opposite of the `plugin` gap:
|
|
816
|
+
// a finding whose control stopped being enumerated read as REMEDIATED in a client artifact.
|
|
817
|
+
// Whether the oracle exists is now decided ONCE, by `frameworkEnumerationEvaluable`, and its
|
|
818
|
+
// absence is DECLARED in `limits` rather than absorbed here.
|
|
819
|
+
if (frameworkEnumerationEvaluable(mine, theirs) && f.control) {
|
|
820
|
+
const inMine = mine.frameworks.includes(f.control);
|
|
821
|
+
const inTheirs = theirs.frameworks.includes(f.control);
|
|
822
|
+
if (inMine !== inTheirs) {
|
|
823
|
+
return { reason: 'framework-enumeration-changed', detail: `control ${f.control} is enumerated in only one of the two runs` };
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
return null;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// The oracle test, separated from the per-finding check so its ABSENCE has somewhere to be
|
|
830
|
+
// declared. gate:cascade's LEG (ii) is the precedent this repo already set: when the oracle is
|
|
831
|
+
// missing, print NOT EVALUATED and never pass silently.
|
|
832
|
+
export const FRAMEWORK_MOVEMENT_NOT_EVALUATED =
|
|
833
|
+
'Coverage-matrix movement between the two runs was NOT EVALUATED: this edition records no '
|
|
834
|
+
+ 'framework enumeration on a run, so if a control stopped being enumerated between the two '
|
|
835
|
+
+ 'scans, findings that mapped to it may appear as resolved here. Compare the two runs\' '
|
|
836
|
+
+ 'framework coverage separately before treating any row as remediation.';
|
|
837
|
+
|
|
838
|
+
export const BASELINE_UNCHAINED =
|
|
839
|
+
'The baseline carries no integrity digest (it predates chained run records), so alteration of '
|
|
840
|
+
+ 'the baseline could not be ruled out.';
|
|
841
|
+
|
|
842
|
+
export const EVIDENCE_GAPS_RECORDED =
|
|
843
|
+
'One or more EVIDENCE GAPS were recorded. A gap is a surface the scanner could not read, not a '
|
|
844
|
+
+ 'finding: gaps are listed under coverage, and any finding a gap covers is reported as '
|
|
845
|
+
+ 'not-comparable rather than as resolved.';
|
|
846
|
+
|
|
847
|
+
// The stable head of the collision limit; the colliding identities are appended at the call site.
|
|
848
|
+
export const IDENTITY_COLLAPSE =
|
|
849
|
+
'Two or more findings collapsed to one identity (same host, plugin, resource, port, rule '
|
|
850
|
+
+ 'qualifier and content). Appearance and disappearance of individual instances cannot be '
|
|
851
|
+
+ 'distinguished, so A NEW FINDING MAY BE MASKED by a surviving one that shares its identity. '
|
|
852
|
+
+ 'Colliding identities: ';
|
|
853
|
+
|
|
854
|
+
// ⚠️ THESE TWO ARE EMITTED BY `scan_delta_view.mjs`, AND THEY LIVE HERE ANYWAY. The vocabulary is
|
|
855
|
+
// one module or it is two copies: the view imports them. Declaring them beside the view that
|
|
856
|
+
// pushes them would put half the census's subject outside the export the census reads.
|
|
857
|
+
export const CURRENT_UNCHAINED =
|
|
858
|
+
'The current run carries no integrity digest (it predates chained run records), so alteration '
|
|
859
|
+
+ 'of the run being reported could not be ruled out.';
|
|
860
|
+
|
|
861
|
+
export const CURRENT_CHAIN_LINK_BROKEN =
|
|
862
|
+
'The current run record names a predecessor whose bytes no longer exist (the record was deleted '
|
|
863
|
+
+ 'or rewritten). The baseline used here was named explicitly, so it is the one that was asked '
|
|
864
|
+
+ 'for \u2014 but the run chain covering this comparison is incomplete.';
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
// ────────────────────────────────────────────────────────────────────────────────────────────
|
|
868
|
+
// THE OUTCOME VOCABULARY — ONE EXPORT, READ BY THE CENSUS, COPIED NOWHERE.
|
|
869
|
+
//
|
|
870
|
+
// ⚠️ WHY THIS EXISTS: a guard can be disarmed by a FIX THAT NARROWS ITS SUBJECT, with nothing
|
|
871
|
+
// failing and nobody editing the guard. It happened twice in two commits in this lane. The census
|
|
872
|
+
// (`tests/delta_outcome_census.test.mjs`) asserts SET EQUALITY between what is declared here and
|
|
873
|
+
// what the SHIPPED entry point actually produces, so a narrowed trigger fails BY NAME. A new
|
|
874
|
+
// outcome joins the census by being written here — incompleteness costs NOISE, never silence.
|
|
875
|
+
// ⚠️ APPEND-ONLY under `SCAN_DELTA_SCHEMA = 1`, exactly as the reason codes are.
|
|
876
|
+
// ────────────────────────────────────────────────────────────────────────────────────────────
|
|
877
|
+
|
|
878
|
+
// How much of a limit's sentence identifies it. A limit has no short code in its own text, so the
|
|
879
|
+
// census probes its HEAD; the self-check refuses two limits whose heads collide.
|
|
880
|
+
export const OUTCOME_PROBE_CHARS = 48;
|
|
881
|
+
|
|
882
|
+
export const DECLARED_LIMITS = Object.freeze({
|
|
883
|
+
'baseline-unchained': BASELINE_UNCHAINED,
|
|
884
|
+
'chain-assurance': CHAIN_ASSURANCE_LABEL,
|
|
885
|
+
'framework-movement-not-evaluated': FRAMEWORK_MOVEMENT_NOT_EVALUATED,
|
|
886
|
+
'scope-not-evaluated': SCOPE_NOT_EVALUATED,
|
|
887
|
+
'evidence-gaps-recorded': EVIDENCE_GAPS_RECORDED,
|
|
888
|
+
'agent-scope-from-tier': AGENT_SCOPE_FROM_TIER,
|
|
889
|
+
'identity-collapse': IDENTITY_COLLAPSE,
|
|
890
|
+
'current-unchained': CURRENT_UNCHAINED,
|
|
891
|
+
'current-chain-link-broken': CURRENT_CHAIN_LINK_BROKEN,
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
const outcome = (species, probe) => Object.freeze({ species, probe });
|
|
895
|
+
export const DECLARED_OUTCOMES = Object.freeze({
|
|
896
|
+
...Object.fromEntries(NOT_COMPARABLE_REASONS.map((c) => [c, outcome('not-comparable', c)])),
|
|
897
|
+
...Object.fromEntries([...REFUSAL_REASONS, ...VIEW_REFUSAL_REASONS]
|
|
898
|
+
.map((c) => [c, outcome('refusal', c)])),
|
|
899
|
+
...Object.fromEntries(Object.entries(DECLARED_LIMITS)
|
|
900
|
+
.map(([c, text]) => [c, outcome('limit', text.slice(0, OUTCOME_PROBE_CHARS))])),
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
// ⚠️ NOT A WAIVER, AND NOT A BARE ALLOWLIST. Each member names the limit that DISCLOSES its
|
|
904
|
+
// absence, and the census verifies that limit is actually produced — the same premise-checking
|
|
905
|
+
// discipline as `DECLARED_ABSENT_FINDING_FIELDS` above, for the same reason: a carve-out whose
|
|
906
|
+
// premise nobody re-checks is how an absence becomes a silent pass.
|
|
907
|
+
export const DECLARED_UNREACHABLE_OUTCOMES = Object.freeze({
|
|
908
|
+
'run-record-schema-differs': {
|
|
909
|
+
reason: 'An EARLIER layer refuses first and this branch cannot be reached. `loadRun` pins the '
|
|
910
|
+
+ 'run-record schema to the one this build understands and refuses any other outright, so two '
|
|
911
|
+
+ 'records that both LOAD always agree on schema. The guard stays because it is the library '
|
|
912
|
+
+ 'contract for a caller that builds a delta without the loader.',
|
|
913
|
+
instead: 'this build understands schema',
|
|
914
|
+
},
|
|
915
|
+
'framework-enumeration-changed': {
|
|
916
|
+
reason: 'Reaching this needs a framework enumeration on BOTH runs and a `control` on a finding. '
|
|
917
|
+
+ 'Community ships no compliance data, `report_inputs.mjs` emits no control id, and '
|
|
918
|
+
+ '`scan_delta_view.mjs` passes no frameworkEnumeration to buildScanDelta — so no CE run can '
|
|
919
|
+
+ 'produce it. It stays declared because EE routing supplies exactly these inputs, and the '
|
|
920
|
+
+ 'engine must refuse rather than report a de-enumerated control as remediated.',
|
|
921
|
+
disclosedBy: 'framework-movement-not-evaluated',
|
|
922
|
+
},
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
function frameworkEnumerationEvaluable(mine, theirs) {
|
|
926
|
+
return Boolean(mine?.frameworks && theirs?.frameworks);
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// Hosts are SHOWN as recorded: the maps are keyed by `hostKey`, and `hostNames` carries the spelling.
|
|
930
|
+
const shownHost = (scope, k) => scope.hostNames?.get(k) ?? k;
|
|
931
|
+
|
|
932
|
+
const statementList = (scope) => [...scope.statements.entries()].flatMap(([k, titles]) => {
|
|
933
|
+
const i = k.indexOf('|');
|
|
934
|
+
return titles.map((title) => ({ host: shownHost(scope, k.slice(0, i)), plugin: k.slice(i + 1), title }));
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
const gapList = (scope) => [...scope.gaps.entries()].map(([k, g]) => {
|
|
938
|
+
const i = k.indexOf('|');
|
|
939
|
+
return { host: shownHost(scope, k.slice(0, i)), plugin: k.slice(i + 1), kind: g.kind, reason: g.reason };
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
export function buildScanDelta({ baseline, current }) {
|
|
943
|
+
const limits = [];
|
|
944
|
+
const refuse = (reason, detail) => ({
|
|
945
|
+
schema: SCAN_DELTA_SCHEMA, comparable: false, refusal: { reason, detail },
|
|
946
|
+
newFindings: [], resolved: [], unchanged: [], changed: [], notComparable: [],
|
|
947
|
+
baselineIntegrity: null, currentIntegrity: null, limits: [detail],
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
// ── Whole-comparison refusals. Each is a case where NO per-finding verdict is trustworthy.
|
|
951
|
+
const bEE = baseline?.record?.eeVersion ?? null;
|
|
952
|
+
const cEE = current?.record?.eeVersion ?? null;
|
|
953
|
+
if ((bEE === null) !== (cEE === null)) {
|
|
954
|
+
return refuse('ee-presence-differs',
|
|
955
|
+
'one run carries Enterprise findings and the other does not, so the two populations are not the same population');
|
|
956
|
+
}
|
|
957
|
+
if (bEE && cEE) {
|
|
958
|
+
const bPre = cmpVersion(bEE, FINDING_COUNT_BOUNDARY_EE) <= 0;
|
|
959
|
+
const cPre = cmpVersion(cEE, FINDING_COUNT_BOUNDARY_EE) <= 0;
|
|
960
|
+
if (bPre !== cPre) {
|
|
961
|
+
return refuse('finding-count-semantics-boundary',
|
|
962
|
+
`EE ${bEE} and EE ${cEE} sit on opposite sides of the ${FINDING_COUNT_BOUNDARY_EE} findingCount semantics boundary ` +
|
|
963
|
+
'(contract-v1 §5.3): before it the key carried the ISSUE count, so a comparison across it shows a fall that is a ' +
|
|
964
|
+
'correction and not remediation');
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
// ⚠️ TIER IS A STATEMENT ABOUT THE PRODUCER POPULATION, not about how much detail a report
|
|
968
|
+
// shows. `agents/agent_runner.mjs` runs the agents the run's CAPABILITIES license, so an
|
|
969
|
+
// enterprise baseline compared against a pro current is missing an entire producer — and every
|
|
970
|
+
// finding that producer found would read as REMEDIATION. This refuses rather than annotating,
|
|
971
|
+
// for the same reason `ee-presence-differs` does: the two populations are not the same
|
|
972
|
+
// population, and no per-finding verdict across them is trustworthy.
|
|
973
|
+
const bTier = baseline?.record?.tier ?? null;
|
|
974
|
+
const cTier = current?.record?.tier ?? null;
|
|
975
|
+
if (bTier !== cTier) {
|
|
976
|
+
return refuse('tier-differs',
|
|
977
|
+
`the baseline ran at tier "${bTier}" and the current run at tier "${cTier}": the licensed `
|
|
978
|
+
+ 'producer set differs between them, so findings absent from the narrower run may simply '
|
|
979
|
+
+ 'never have been looked for');
|
|
980
|
+
}
|
|
981
|
+
if ((baseline?.record?.schema ?? null) !== (current?.record?.schema ?? null)) {
|
|
982
|
+
return refuse('run-record-schema-differs',
|
|
983
|
+
`run record schema ${baseline?.record?.schema} vs ${current?.record?.schema}: the two records do not describe coverage the same way`);
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// ── Baseline integrity. A broken chain means the baseline on disk is not the baseline that was
|
|
987
|
+
// written, so every verdict derived from it is unsound — refuse rather than annotate.
|
|
988
|
+
const baselineIntegrity = baseline?.integrity ?? 'chain-absent';
|
|
989
|
+
// ⚠️ THE CURRENT RUN IS ALTERABLE TOO, and until T3 nothing could verify it. A basis that names
|
|
990
|
+
// only the baseline describes half the evidence the verdict rests on. Unlike the baseline this
|
|
991
|
+
// one is never a refusal HERE — the view refuses on it before the engine is called, because the
|
|
992
|
+
// engine is deliberately filesystem-free and integrity is a fact about bytes on disk.
|
|
993
|
+
const currentIntegrity = current?.integrity ?? null;
|
|
994
|
+
if (baselineIntegrity === 'chain-broken') {
|
|
995
|
+
return refuse('baseline-chain-broken',
|
|
996
|
+
'the baseline run record does not match its recorded digest: it was altered, truncated or partially restored after the run');
|
|
997
|
+
}
|
|
998
|
+
// ⚠️ COULD-NOT-MEASURE IS NEVER A PASS. `chain-unreadable` is not `chain-absent`: absent means
|
|
999
|
+
// the record predates chaining and we know that; unreadable means the instrument failed and we
|
|
1000
|
+
// know nothing. Letting the second one through would be a verdict from a run that measured nothing.
|
|
1001
|
+
if (baselineIntegrity === 'chain-unreadable') {
|
|
1002
|
+
return refuse('baseline-integrity-unmeasurable',
|
|
1003
|
+
'the baseline integrity digest could not be read, so alteration of the baseline could neither be confirmed nor ruled out');
|
|
1004
|
+
}
|
|
1005
|
+
if (baselineIntegrity === 'chain-absent') {
|
|
1006
|
+
limits.push(BASELINE_UNCHAINED);
|
|
1007
|
+
}
|
|
1008
|
+
limits.push(CHAIN_ASSURANCE_LABEL);
|
|
1009
|
+
// Declared once for the whole comparison, not per finding: it is a property of the two RUNS.
|
|
1010
|
+
if (!frameworkEnumerationEvaluable(scopeOf(baseline), scopeOf(current))) {
|
|
1011
|
+
limits.push(FRAMEWORK_MOVEMENT_NOT_EVALUATED);
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
const bScope = scopeOf(baseline);
|
|
1015
|
+
const cScope = scopeOf(current);
|
|
1016
|
+
if (!bScope.evaluable || !cScope.evaluable) limits.push(SCOPE_NOT_EVALUATED);
|
|
1017
|
+
|
|
1018
|
+
// ⚠️ A GAP RECORD IS SCOPE AND MUST NOT BE BUCKETED AS A FINDING. It has a severity, a title and
|
|
1019
|
+
// — since the producer identity landed — a plugin, so it is comparable like anything else and
|
|
1020
|
+
// would otherwise arrive in the client's NEW EXPOSURES table as an INFO row reading
|
|
1021
|
+
// "Evidence gap (…)". It has already been read INTO `scopeOf` above, which is its whole job:
|
|
1022
|
+
// it explains why its neighbours are not comparable. Measured before the exclusion: the
|
|
1023
|
+
// `gap-leg` scenario read `1 new · 1 resolved` where the honest answer is `0 new · 0 resolved`.
|
|
1024
|
+
const isGap = (f) => f.evidenceGap === true;
|
|
1025
|
+
// A declared scope boundary is set aside the same way (read into `scopeOf` as a statement); a row
|
|
1026
|
+
// flagged as both is a gap, which the line above already removes.
|
|
1027
|
+
const isScopeStatement = (f) => f.deferredScope === true;
|
|
1028
|
+
const bFind = (baseline?.findings ?? []).filter((f) => !isGap(f) && !isScopeStatement(f));
|
|
1029
|
+
const cFind = (current?.findings ?? []).filter((f) => !isGap(f) && !isScopeStatement(f));
|
|
1030
|
+
if ((baseline?.findings ?? []).some(isGap) || (current?.findings ?? []).some(isGap)) {
|
|
1031
|
+
// ⚠️ THE WORDING AVOIDS THE LITERAL BUCKET NAME ON PURPOSE. `scripts/board_probe_delta_driver.mjs`
|
|
1032
|
+
// harvests reasons by matching that phrase against every output line, so a LIMIT containing it
|
|
1033
|
+
// is read back as if it were a per-finding reason — an instrument the next seat reads,
|
|
1034
|
+
// reporting a bucket that no finding is in.
|
|
1035
|
+
limits.push(EVIDENCE_GAPS_RECORDED);
|
|
1036
|
+
}
|
|
1037
|
+
if ((baseline?.findings ?? []).some((f) => f.producerKind === 'agent')
|
|
1038
|
+
|| (current?.findings ?? []).some((f) => f.producerKind === 'agent')) {
|
|
1039
|
+
limits.push(AGENT_SCOPE_FROM_TIER);
|
|
1040
|
+
}
|
|
1041
|
+
const bMap = new Map(bFind.map((f) => [keyOf(f), f]));
|
|
1042
|
+
const cMap = new Map(cFind.map((f) => [keyOf(f), f]));
|
|
1043
|
+
// ⚠️ THE LIMIT NAMES THE DIRECTION, because the previous wording ("per-instance deltas are not
|
|
1044
|
+
// distinguished") read as a granularity note when the real consequence is a missing exposure.
|
|
1045
|
+
if (bMap.size !== bFind.length || cMap.size !== cFind.length) {
|
|
1046
|
+
// ⚠️ THE LIMIT NAMES EACH COLLIDING IDENTITY. A generic disclosure tells a reader that SOME
|
|
1047
|
+
// finding may be masked and gives them nothing to act on; the whole point is that they can go
|
|
1048
|
+
// and look at the one that collided.
|
|
1049
|
+
const collide = [];
|
|
1050
|
+
for (const [side, list] of [['baseline', bFind], ['current', cFind]]) {
|
|
1051
|
+
const seen = new Map();
|
|
1052
|
+
for (const f of list) {
|
|
1053
|
+
const k = keyOf(f);
|
|
1054
|
+
seen.set(k, (seen.get(k) ?? 0) + 1);
|
|
1055
|
+
}
|
|
1056
|
+
for (const f of list) {
|
|
1057
|
+
const k = keyOf(f);
|
|
1058
|
+
if ((seen.get(k) ?? 0) > 1 && !collide.some((c) => c.k === k)) {
|
|
1059
|
+
// ⚠️ THE REGION IS NAMED WHEN THERE IS NO RESOURCE (board E1). This limit exists so a
|
|
1060
|
+
// reader can ACT on a collision, which means it has to say WHICH object collided. The
|
|
1061
|
+
// population that collides most is the one whose producer emits no resource at all —
|
|
1062
|
+
// and before E1 those findings borrowed the REGION as their resource, so the limit
|
|
1063
|
+
// read `us-east-1` and looked actionable. E1 correctly stops that borrowing, which
|
|
1064
|
+
// would have left this limit saying `no resource` and nothing else. The region is a
|
|
1065
|
+
// field now, so it is named as what it is rather than impersonating an object id.
|
|
1066
|
+
const where = f.resource ?? (f.region ? `no resource · region ${f.region}` : 'no resource');
|
|
1067
|
+
collide.push({ k, text: `${side}: plugin ${f.plugin} · ${where} · `
|
|
1068
|
+
+ `"${String(f.title ?? '').slice(0, 80)}" ×${seen.get(k)}` });
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
limits.push(IDENTITY_COLLAPSE + collide.map((c) => c.text).join(' | '));
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
const resolved = [];
|
|
1076
|
+
const newFindings = [];
|
|
1077
|
+
const unchanged = [];
|
|
1078
|
+
const changed = [];
|
|
1079
|
+
const notComparable = [];
|
|
1080
|
+
|
|
1081
|
+
for (const [k, f] of bMap) {
|
|
1082
|
+
if (cMap.has(k)) {
|
|
1083
|
+
// Matched on identity — so a severity move is the SAME finding getting worse or better,
|
|
1084
|
+
// which is the trend report's actual deliverable. Never a resolution plus a new finding.
|
|
1085
|
+
const now = cMap.get(k);
|
|
1086
|
+
if ((f.severity ?? null) !== (now.severity ?? null)) changed.push({ ...now, from: f.severity ?? null, to: now.severity ?? null });
|
|
1087
|
+
else unchanged.push(f);
|
|
1088
|
+
continue;
|
|
1089
|
+
}
|
|
1090
|
+
const why = incomparabilityReason(f, bScope, cScope);
|
|
1091
|
+
if (why) notComparable.push({ ...f, direction: 'disappeared', ...why });
|
|
1092
|
+
else resolved.push(f);
|
|
1093
|
+
}
|
|
1094
|
+
for (const [k, f] of cMap) {
|
|
1095
|
+
if (bMap.has(k)) continue;
|
|
1096
|
+
// The mirror, and it is not symmetric in danger: a finding "new" only because its host or
|
|
1097
|
+
// plugin was out of scope last time is NEW COVERAGE, not a new exposure.
|
|
1098
|
+
const why = incomparabilityReason(f, cScope, bScope);
|
|
1099
|
+
if (why) notComparable.push({ ...f, direction: 'appeared', ...why });
|
|
1100
|
+
else newFindings.push(f);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
return {
|
|
1104
|
+
schema: SCAN_DELTA_SCHEMA, comparable: true, refusal: null,
|
|
1105
|
+
newFindings, resolved, unchanged, changed, notComparable,
|
|
1106
|
+
baselineIntegrity, currentIntegrity, limits,
|
|
1107
|
+
coverage: {
|
|
1108
|
+
hostsOnlyInBaseline: [...bScope.hosts].filter((h) => !cScope.hosts.has(h)).map((h) => shownHost(bScope, h)),
|
|
1109
|
+
hostsOnlyInCurrent: [...cScope.hosts].filter((h) => !bScope.hosts.has(h)).map((h) => shownHost(cScope, h)),
|
|
1110
|
+
pluginsOnlyInBaseline: [...bScope.plugins].filter((p) => !cScope.plugins.has(p)),
|
|
1111
|
+
pluginsOnlyInCurrent: [...cScope.plugins].filter((p) => !bScope.plugins.has(p)),
|
|
1112
|
+
// The gaps themselves, named. A reader who sees a NOT-COMPARABLE row needs to be able to
|
|
1113
|
+
// find out WHICH surface was unreadable without reading the raw envelope.
|
|
1114
|
+
gapsInBaseline: gapList(bScope),
|
|
1115
|
+
gapsInCurrent: gapList(cScope),
|
|
1116
|
+
// And the boundaries each run DECLARED — named, never paired: what a producer says it does not
|
|
1117
|
+
// examine is a fact about its scope, not a finding that can be fixed or appear.
|
|
1118
|
+
scopeStatementsInBaseline: statementList(bScope),
|
|
1119
|
+
scopeStatementsInCurrent: statementList(cScope),
|
|
1120
|
+
},
|
|
1121
|
+
};
|
|
1122
|
+
}
|