nsauditor-ai 0.2.55 → 0.2.56
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 +71 -58
- package/cli.mjs +49 -42
- package/mcp_server.mjs +15 -2
- package/package.json +1 -1
- package/plugins/mcp_scanner.mjs +2 -1
- package/plugins/result_concluder.mjs +3 -2
- package/utils/ee_load.mjs +45 -0
- package/utils/executive_report.mjs +16 -3
- package/utils/output_dir.mjs +68 -0
- package/utils/report_inputs.mjs +64 -1
- package/utils/report_md.mjs +16 -1
- package/utils/scan_delta.mjs +359 -38
- package/utils/scan_delta_view.mjs +5 -3
package/utils/scan_delta.mjs
CHANGED
|
@@ -18,6 +18,10 @@
|
|
|
18
18
|
// A finding may be called RESOLVED only when its host, plugin, scope and framework enumeration
|
|
19
19
|
// were in scope in BOTH runs. Otherwise it goes to NOT-COMPARABLE **with its reason** — never
|
|
20
20
|
// into `resolved`, and never silently dropped.
|
|
21
|
+
// ⚠️ 1.2.0 limit, boarded for 1.2.1: "plugin" is a PLUGIN row's own producer. An analysis agent's or the
|
|
22
|
+
// CVE mapper's row is derived from other plugins' output, and whether those were requested in both runs
|
|
23
|
+
// is NOT checked — narrow `--plugins` in one run and such a row goes into `resolved` (or `new`).
|
|
24
|
+
// AGENT_SCOPE_FROM_TIER discloses it; tests/build6_delta_scope_honesty.test.mjs pins it until the check exists.
|
|
21
25
|
// Integrity is a fact about BYTES ON DISK, so the caller measures it with `utils/run_chain.mjs`
|
|
22
26
|
// and passes the verdict in. Keeping it out of this module means the comparability rules can be
|
|
23
27
|
// driven without a filesystem, and the chain can be driven without the delta.
|
|
@@ -74,10 +78,147 @@ export const PROBE_NOT_MEASURED_REASON = 'probe-not-measured';
|
|
|
74
78
|
// those ports read RESOLVED in the client's delta; five minutes later both ports were open again. A port in the
|
|
75
79
|
// other run's tcpClosed WAS measured, and RESOLVED there stays legitimate. Enterprise's MTTR imports this name.
|
|
76
80
|
export const PORT_NOT_MEASURED_REASON = 'port-not-measured';
|
|
81
|
+
// A UDP-TRANSPORT FINDING THAT VANISHED WAS NOT MEASURED AS FIXED EITHER (1.1.1 board — found by the 1.1.0 web
|
|
82
|
+
// cascade's reviewer driving the shipped engine). The TCP rule above keys on the port NUMBER in the holding run's
|
|
83
|
+
// tcpOpen, so an SNMP 161/udp row that disappeared read RESOLVED with no caveat anywhere, and a udp/53 DNS row was
|
|
84
|
+
// judged by TCP/53's state. No shipped UDP producer records a positive negative — SNMP records `no response`, DNS a
|
|
85
|
+
// timeout, and the port scanner's `udpClosed` is empty on every sealed raw measured — so UDP SILENCE IS
|
|
86
|
+
// INDISTINGUISHABLE FROM FILTERED, and a UDP row in one run only is NOT COMPARABLE unless the other run's service set
|
|
87
|
+
// shows the port MEASURED for that producer (`udpPortMeasurement`: closed, or answered — and, for the CVE mapper,
|
|
88
|
+
// identified). Same token, its own detail. What decides "UDP" is the finding's `protocol`, which is an APPLICATION LABEL, not a transport (the
|
|
89
|
+
// concluder takes it from `probe_protocol || result.protocol || 'tcp'`), so the decision is this table.
|
|
90
|
+
//
|
|
91
|
+
// ⚠️ THE TABLE IS DERIVED, NOT HAND-LISTED: its keys are held in equality with every protocol label the shipped
|
|
92
|
+
// producers WRITE — CE's plugins and utils here (`tests/udp_transport_label_census.test.mjs`), and Enterprise's
|
|
93
|
+
// plugins, agents and engine on its side — so a label outside it is unreachable through the shipped path, and the
|
|
94
|
+
// runtime default for an unknown label (not UDP, today's verdict) is a default nothing exercises. `dns` is classed
|
|
95
|
+
// UDP because that is the SAFE direction, and its cost is STATED: a TCP-53 row labelled `dns` — a zone transfer (AXFR)
|
|
96
|
+
// finding, which is TCP — OVER-REFUSES (it reads not-comparable where a closed TCP/53 would have been a measured fix),
|
|
97
|
+
// while a UDP DNS row labelled TCP would read RESOLVED on a closed TCP/53. 060 writes port-53 rows under both `udp`
|
|
98
|
+
// and `dns`, so the choice is live, not theoretical. `other` is a label with no port-bearing transport (ICMP, ARP, the
|
|
99
|
+
// concluder's meta labels, which it filters out of the service set before any producer reads them).
|
|
100
|
+
export const TRANSPORT_OF_LABEL = Object.freeze({
|
|
101
|
+
udp: 'udp', mdns: 'udp', dnssd: 'udp', llmnr: 'udp', upnp: 'udp', dns: 'udp',
|
|
102
|
+
tcp: 'tcp', 'tcp-ack': 'tcp', http: 'tcp', https: 'tcp', tr069: 'tcp',
|
|
103
|
+
icmp: 'other', 'icmp-timestamp': 'other', arp: 'other', 'os-detector': 'other', assessment: 'other', api: 'other',
|
|
104
|
+
});
|
|
105
|
+
export const UDP_TRANSPORT_LABELS = Object.freeze(Object.keys(TRANSPORT_OF_LABEL).filter((l) => TRANSPORT_OF_LABEL[l] === 'udp'));
|
|
106
|
+
/** Is this protocol label a UDP transport? Case-insensitive; anything not a string, or not in the table, is not. */
|
|
107
|
+
export const isUdpTransport = (label) => typeof label === 'string' && TRANSPORT_OF_LABEL[label.toLowerCase()] === 'udp';
|
|
108
|
+
|
|
109
|
+
// The queue producer whose rows are CVE matches over a service's IDENTITY (program + version). Community cannot import
|
|
110
|
+
// Enterprise, so the name is declared here and Enterprise's test holds it equal to its `INTELLIGENCE_ENGINE_SOURCE`.
|
|
111
|
+
export const CVE_MAPPER_PRODUCER = 'intelligence_engine';
|
|
112
|
+
const identified = (program) => typeof program === 'string' && program.trim() !== '' && program.trim().toLowerCase() !== 'unknown';
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* WAS THIS UDP PORT MEASURED IN A RUN, FOR THIS PRODUCER? (1.1.1) — ONE decision, called by the delta and by
|
|
116
|
+
* Enterprise's MTTR, so the two channels cannot disagree. `services` is that run's UDP service records on the host
|
|
117
|
+
* (the loader's `udpServicesOf`), or null when the run recorded no service set.
|
|
118
|
+
*
|
|
119
|
+
* - `closed` on the port → MEASURED: the concluder writes it only from ECONNREFUSED, a real negative ("a CLOSED port
|
|
120
|
+
* was measured, and closure there stands", as for TCP).
|
|
121
|
+
* - `open` → MEASURED for an analysis agent: it reads the fields the probe filled, so it looked and found nothing.
|
|
122
|
+
* - `open` for the CVE MAPPER → MEASURED only when the service was IDENTIFIED (a program that is not `Unknown`, and a
|
|
123
|
+
* version). The mapper returns nothing and records nothing for an unidentified service (intelligence_engine's
|
|
124
|
+
* program guard), so an `open` port whose program is `Unknown` would otherwise "resolve" every CVE row on it —
|
|
125
|
+
* twenty dnsmasq rows on the router corpus — through this exemption's own front door (the audit seat's condition).
|
|
126
|
+
* - anything else (`no response`, `unknown`, the port absent from the set, no set at all) → NOT measured.
|
|
127
|
+
*
|
|
128
|
+
* @returns {{measured: true, basis: string} | {measured: false, kind: 'no-oracle'|'not-answered'|'unidentified', why: string}}
|
|
129
|
+
*/
|
|
130
|
+
export function udpPortMeasurement(port, producer, services, runName = 'the other run') {
|
|
131
|
+
if (!Array.isArray(services)) {
|
|
132
|
+
return { measured: false, kind: 'no-oracle', why: `${runName} recorded no service set, so nothing says whether a UDP service answered there` };
|
|
133
|
+
}
|
|
134
|
+
const here = services.filter((s) => Number(s?.port) === Number(port));
|
|
135
|
+
const closed = here.find((s) => s?.status === 'closed');
|
|
136
|
+
if (closed) return { measured: true, basis: `${port}/udp closed in ${runName} (${closed.service ?? closed.protocol} closed)` };
|
|
137
|
+
const open = here.filter((s) => s?.status === 'open');
|
|
138
|
+
if (open.length === 0) {
|
|
139
|
+
return { measured: false, kind: 'not-answered', why: here.length
|
|
140
|
+
? `in ${runName} no UDP service answered on that port (${here.map((s) => `${s.service ?? s.protocol} ${s.status ?? 'no status'}`).join(', ')})`
|
|
141
|
+
: `${runName}'s service set holds no UDP service on that port` };
|
|
142
|
+
}
|
|
143
|
+
if (producer === CVE_MAPPER_PRODUCER) {
|
|
144
|
+
// ⚠️ A VERSION, NOT A PLACEHOLDER: the producers write an absent version as the literal `Unknown`, and the mapper
|
|
145
|
+
// queries a CPE with it, matches nothing and records nothing — so `Unknown` is as absent as null here.
|
|
146
|
+
const id = open.find((s) => identified(s.program) && identified(s.version));
|
|
147
|
+
if (!id) {
|
|
148
|
+
const s = open[0];
|
|
149
|
+
return { measured: false, kind: 'unidentified', why: `in ${runName} the UDP service answered but was not identified (program `
|
|
150
|
+
+ `${identified(s.program) ? s.program : (s.program ?? 'absent')}, version ${identified(s.version) ? s.version : (s.version ?? 'absent')}), so the CVE mapper could not have matched it` };
|
|
151
|
+
}
|
|
152
|
+
return { measured: true, basis: `${port}/udp answered in ${runName} (${id.service ?? id.protocol} open · ${id.program} ${id.version})` };
|
|
153
|
+
}
|
|
154
|
+
const s = open[0];
|
|
155
|
+
const who = identified(s.program) ? ` · ${s.program}${s.version ? ` ${s.version}` : ''}` : '';
|
|
156
|
+
return { measured: true, basis: `${port}/udp answered in ${runName} (${s.service ?? s.protocol} open${who})` };
|
|
157
|
+
}
|
|
158
|
+
// ⚠️ A CVE ROW THAT VANISHED WHILE THE SAME PROGRAM AND VERSION STILL ANSWER WAS NOT FIXED (1.2.0 build 2 — the audit
|
|
159
|
+
// seat's ruling on the build-1 smoke). The CVE mapper attributes a CVE on (program, version) alone, so a row present in
|
|
160
|
+
// one run only while the IDENTIFIED program AND version at that host:port:transport are the same in both runs cannot
|
|
161
|
+
// be a remediation: the vulnerability DATA moved — NVD's answer, the age of a cached answer, an offline store. MEASURED:
|
|
162
|
+
// five dnsmasq 2.78 CVEs NVD stopped matching (all "Awaiting Analysis", no configurations) read RESOLVED across the
|
|
163
|
+
// published 1.1.0 run and 1.2.0 build 1, with `dns · dnsmasq · 2.78 · open` on 53/udp in both. APPEND-ONLY.
|
|
164
|
+
export const VULNERABILITY_DATA_CHANGED_REASON = 'vulnerability-data-changed';
|
|
165
|
+
|
|
166
|
+
const transportOf = (label) => (typeof label === 'string' ? TRANSPORT_OF_LABEL[label.toLowerCase()] ?? null : null);
|
|
167
|
+
/** An identity the CVE mapper could have matched on: a program AND a version, neither absent nor the `Unknown` placeholder. */
|
|
168
|
+
export function identityOf(program, version) {
|
|
169
|
+
return identified(program) && identified(version) ? { program: String(program).trim(), version: String(version).trim() } : null;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* THE SERVICE IDENTITY AT host:port:transport IN ONE RUN — the ONE decision both the delta and Enterprise's MTTR call.
|
|
173
|
+
* `services` is that run's service set, every transport (the loader's `servicesOf`); the row's `protocol` is an
|
|
174
|
+
* application LABEL, mapped to its transport by `TRANSPORT_OF_LABEL` on both sides. Only an OPEN, IDENTIFIED service
|
|
175
|
+
* counts; two different identities on one port and transport are AMBIGUOUS and return null, so the rule stays silent
|
|
176
|
+
* rather than choose one. Null also when no set was recorded — no oracle, never "nothing answered".
|
|
177
|
+
*/
|
|
178
|
+
export function serviceIdentityAt(services, port, protocol) {
|
|
179
|
+
const t = transportOf(protocol);
|
|
180
|
+
if (!Array.isArray(services) || !(Number(port) > 0) || (t !== 'tcp' && t !== 'udp')) return null;
|
|
181
|
+
const ids = new Map();
|
|
182
|
+
for (const s of services) {
|
|
183
|
+
if (Number(s?.port) !== Number(port) || transportOf(s?.protocol) !== t || s?.status !== 'open') continue;
|
|
184
|
+
const id = identityOf(s.program, s.version);
|
|
185
|
+
if (id) ids.set(`${id.program.toLowerCase()}|${id.version}`, { ...id, port: Number(port), transport: t });
|
|
186
|
+
}
|
|
187
|
+
return ids.size === 1 ? [...ids.values()][0] : null;
|
|
188
|
+
}
|
|
189
|
+
/** The same identified program AND version — both present. Program is compared case-insensitively, version exactly. */
|
|
190
|
+
export const sameServiceIdentity = (a, b) => a != null && b != null
|
|
191
|
+
&& a.program.toLowerCase() === b.program.toLowerCase() && a.version === b.version;
|
|
192
|
+
/**
|
|
193
|
+
* WHERE A RUN'S VULNERABILITY DATA CAME FROM, in words, from its run record's `nvdCache` — never a local path, which
|
|
194
|
+
* would carry the operator's directory layout into a client artifact. `state` is the offline store's (`absent` means
|
|
195
|
+
* none was imported, so the NVD API answered, through the response cache when it held the entry).
|
|
196
|
+
*/
|
|
197
|
+
export function vulnerabilityDataSource(nvdCache) {
|
|
198
|
+
if (!nvdCache || typeof nvdCache !== 'object') return 'not recorded';
|
|
199
|
+
if (['fresh', 'stale', 'undated'].includes(nvdCache.state)) return `an offline NVD store (${nvdCache.state})`;
|
|
200
|
+
const rc = nvdCache.responseCache;
|
|
201
|
+
const day = (t) => (typeof t === 'string' ? t.slice(0, 10) : null);
|
|
202
|
+
if (rc?.present === true && day(rc.oldestAt)) {
|
|
203
|
+
const a = day(rc.oldestAt); const b = day(rc.newestAt) ?? a;
|
|
204
|
+
return `NVD answers cached up to ${rc.ttlDays} days, fetched ${a === b ? a : `${a} to ${b}`}`;
|
|
205
|
+
}
|
|
206
|
+
return 'the NVD API, asked live';
|
|
207
|
+
}
|
|
77
208
|
// The gap class Enterprise's service-set input gap stamps (`evidence.raw.gapClass`). Spelled ONCE, here, on
|
|
78
209
|
// the reading side: Enterprise imports it, so the producer and this reader cannot drift to two spellings.
|
|
79
210
|
export const INPUT_GAP_CLASS = 'input_gap';
|
|
80
211
|
|
|
212
|
+
// ⚠️ RUNS ARE NAMED ABSOLUTELY IN EVERY DETAIL (1.1.1 — the audit seat's T1-c fold 1). The executive page prefixes a
|
|
213
|
+
// not-comparable row with its direction in absolute terms ("absent from this run" / "appeared in this run", this run =
|
|
214
|
+
// the CURRENT run), and the details used to name runs RELATIVE to the side holding the row ("in the other run") — which
|
|
215
|
+
// for a row that DISAPPEARED is also the current run, so the assembled line placed a current-run failure in the
|
|
216
|
+
// baseline. The call site knows the frame and passes it; `udpPortMeasurement` keeps "the other run" only as its DEFAULT,
|
|
217
|
+
// because Enterprise's MTTR rewords that phrase for its own frame.
|
|
218
|
+
export const RUN_NAMES = Object.freeze({ current: 'this run', baseline: 'the baseline run' });
|
|
219
|
+
const FRAME_DISAPPEARED = Object.freeze({ mine: RUN_NAMES.baseline, theirs: RUN_NAMES.current });
|
|
220
|
+
const FRAME_APPEARED = Object.freeze({ mine: RUN_NAMES.current, theirs: RUN_NAMES.baseline });
|
|
221
|
+
|
|
81
222
|
export const NOT_COMPARABLE_REASONS = Object.freeze([
|
|
82
223
|
'host-not-scanned', // the other run never wrote this host
|
|
83
224
|
'producer-unknown', // the finding carries no producer identity to adjudicate
|
|
@@ -89,6 +230,7 @@ export const NOT_COMPARABLE_REASONS = Object.freeze([
|
|
|
89
230
|
SCOPE_NOT_SCANNED_REASON, // the finding's coverage unit was outside the OTHER run's recorded scope
|
|
90
231
|
PROBE_NOT_MEASURED_REASON, // a probe ran on the finding's port, open per the port scanner, and did not complete there
|
|
91
232
|
PORT_NOT_MEASURED_REASON, // the finding's port was open in its run and neither open nor closed in the other (filtered / unprobed)
|
|
233
|
+
VULNERABILITY_DATA_CHANGED_REASON, // a CVE row in one run only, the same program AND version identified on its port in both
|
|
92
234
|
]);
|
|
93
235
|
|
|
94
236
|
/**
|
|
@@ -262,11 +404,11 @@ export const VIEW_REFUSAL_REASONS = Object.freeze([
|
|
|
262
404
|
// the input. `tests/delta_boundary_contract.test.mjs` asserts CONSUMED ⊆ EMITTED ∪ DECLARED_ABSENT
|
|
263
405
|
// against the REAL loader, so the fourth instance fails by name instead of shipping.
|
|
264
406
|
export const CONSUMED_FINDING_FIELDS = ['host', 'plugin', 'pluginName', 'producerKind', 'evidenceGap', 'gapClass', 'deferredScope',
|
|
265
|
-
'contentDigest', 'identityQualifier', 'resource', 'region', 'port', 'title', 'severity', 'control'];
|
|
407
|
+
'contentDigest', 'identityQualifier', 'resource', 'region', 'port', 'protocol', 'title', 'severity', 'control'];
|
|
266
408
|
|
|
267
409
|
// Fields this module WRITES onto its output records; they are never read from a loaded finding,
|
|
268
410
|
// 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'];
|
|
411
|
+
export const OUTPUT_ONLY_FINDING_FIELDS = ['reason', 'detail', 'direction', 'from', 'to', 'basisNote'];
|
|
270
412
|
|
|
271
413
|
// A consumed field the loader legitimately cannot produce. NOT a bare allowlist: each entry names
|
|
272
414
|
// the limit that DISCLOSES its absence, and the guard verifies that limit is actually emitted —
|
|
@@ -470,18 +612,33 @@ export function portsNotMeasured(findings) {
|
|
|
470
612
|
function portStateOf(byHost) {
|
|
471
613
|
const out = new Map();
|
|
472
614
|
for (const h of Array.isArray(byHost) ? byHost : []) {
|
|
615
|
+
const k = hostKey(h?.host);
|
|
473
616
|
const ps = h?.portScan;
|
|
474
|
-
if (
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
617
|
+
if (ps && Array.isArray(ps.tcpOpen) && Array.isArray(ps.tcpClosed)) {
|
|
618
|
+
const e = out.get(k) ?? {};
|
|
619
|
+
e.open ??= new Set(); e.closed ??= new Set();
|
|
620
|
+
for (const p of ps.tcpOpen) if (Number(p) > 0) e.open.add(Number(p));
|
|
621
|
+
for (const p of ps.tcpClosed) if (Number(p) > 0) e.closed.add(Number(p));
|
|
622
|
+
out.set(k, e);
|
|
623
|
+
}
|
|
624
|
+
// The UDP oracle (1.1.1): the loader's `udpServices` — present only where the run recorded a service set. Two
|
|
625
|
+
// directories of one host pool their records, as their TCP lists do above.
|
|
626
|
+
if (Array.isArray(h?.udpServices)) {
|
|
627
|
+
const e = out.get(k) ?? {};
|
|
628
|
+
e.udpServices = [...(e.udpServices ?? []), ...h.udpServices];
|
|
629
|
+
out.set(k, e);
|
|
630
|
+
}
|
|
631
|
+
// Every transport's service identities (1.2.0 build 2) — the loader's `servicesOf`, for `serviceIdentityAt`.
|
|
632
|
+
if (Array.isArray(h?.services)) {
|
|
633
|
+
const e = out.get(k) ?? {};
|
|
634
|
+
e.services = [...(e.services ?? []), ...h.services];
|
|
635
|
+
out.set(k, e);
|
|
636
|
+
}
|
|
480
637
|
}
|
|
481
638
|
return out;
|
|
482
639
|
}
|
|
483
640
|
|
|
484
|
-
function scopeNotScanned(f, mine, theirs) {
|
|
641
|
+
function scopeNotScanned(f, mine, theirs, names) {
|
|
485
642
|
const provider = hostKey(f?.host);
|
|
486
643
|
const mineEntry = mine?.scopeScanned?.[provider] ?? null;
|
|
487
644
|
const theirEntry = theirs?.scopeScanned?.[provider] ?? null;
|
|
@@ -510,15 +667,15 @@ function scopeNotScanned(f, mine, theirs) {
|
|
|
510
667
|
} else if (!mineEntry) {
|
|
511
668
|
// Guard (1) already returned when NEITHER side knows, so the other side does know — and this
|
|
512
669
|
// finding's own run does not. Which unit it belongs to is unknowable, so it fails closed.
|
|
513
|
-
return
|
|
514
|
-
+
|
|
670
|
+
return `${names.mine}'s record carries no ${unitName} scope, so which ${unitName} this finding `
|
|
671
|
+
+ `belongs to is not known — it cannot be called fixed or new against ${names.theirs}`;
|
|
515
672
|
} else {
|
|
516
673
|
value = mineEntry.scanned?.[0] ?? null;
|
|
517
674
|
if (value === null) return null; // (2) recorded an empty scope
|
|
518
675
|
}
|
|
519
676
|
|
|
520
677
|
if (!theirEntry) {
|
|
521
|
-
return
|
|
678
|
+
return `${names.theirs}'s record carries no ${unitName} scope, so it is not known whether `
|
|
522
679
|
+ `${unitName} ${value} was covered there — this finding cannot be called fixed or new `
|
|
523
680
|
+ 'against an unknown scope';
|
|
524
681
|
}
|
|
@@ -544,14 +701,14 @@ function scopeNotScanned(f, mine, theirs) {
|
|
|
544
701
|
// and refuse every archived comparison at once.
|
|
545
702
|
const perProducer = theirEntry.byPlugin?.[String(f?.plugin)];
|
|
546
703
|
if (Array.isArray(perProducer) && !perProducer.includes(value)) {
|
|
547
|
-
return `${unitName} ${value} is inside
|
|
704
|
+
return `${unitName} ${value} is inside ${names.theirs}'s overall scope, but producer `
|
|
548
705
|
+ `${f.plugin} only covered ${perProducer.join(', ') || 'no ' + unitName + 's'} there — `
|
|
549
706
|
+ 'the run-level set is the union across producers, so this surface was not looked at by '
|
|
550
707
|
+ 'the producer that would have found it, which is not the same as the finding being fixed';
|
|
551
708
|
}
|
|
552
709
|
return null; // genuinely comparable
|
|
553
710
|
}
|
|
554
|
-
return `${unitName} ${value} was outside
|
|
711
|
+
return `${unitName} ${value} was outside ${names.theirs}'s recorded scope `
|
|
555
712
|
+ `(${[...covered].join(', ') || 'none recorded'}) — the surface was not looked at there, `
|
|
556
713
|
+ 'which is not the same as the finding being fixed';
|
|
557
714
|
}
|
|
@@ -617,10 +774,17 @@ const scopeOf = (side) => {
|
|
|
617
774
|
// What this side's PORT SCANNER recorded, per host (EE 1.1.0 build 12): present only where 003 RAN and wrote
|
|
618
775
|
// both lists (the loader's `portScan`), so an absent entry means "no oracle", never "nothing open".
|
|
619
776
|
portState: portStateOf(side?.pluginStatus),
|
|
777
|
+
engineLookupGaps: engineLookupGapsOf(side?.findings),
|
|
778
|
+
// Where Enterprise's STAGE did not run on a host (1.1.1): the loader's `eeStage` — a failed LOAD or a failed
|
|
779
|
+
// ENRICHMENT recorded on the host's conclusion — keyed by host.
|
|
780
|
+
eeStages: new Map((Array.isArray(side?.pluginStatus) ? side.pluginStatus : [])
|
|
781
|
+
.filter((h) => h?.eeStage && (h.eeStage.loadError || h.eeStage.enrichmentError)).map((h) => [hostKey(h.host), h.eeStage])),
|
|
620
782
|
// The release that WROTE this side. Carried on the scope because the identity-basis
|
|
621
783
|
// declaration is a property of the comparison — which releases the two runs straddle — and
|
|
622
784
|
// `incomparabilityReason` sees only the two scopes.
|
|
623
785
|
eeVersion: rec.eeVersion ?? null,
|
|
786
|
+
// Where this run's vulnerability data came from (1.2.0 build 2) — the run record's `nvdCache`, rendered in words.
|
|
787
|
+
nvdCache: rec.nvdCache ?? null,
|
|
624
788
|
// ⚠️ WHAT THE RUN ACTUALLY COVERED, per provider — never the FLAG. `undefined` means the
|
|
625
789
|
// record predates this field and the scope is UNKNOWN, which is not the same as "covered
|
|
626
790
|
// nothing": the rule below fails closed on unknown and stays silent when BOTH sides are
|
|
@@ -665,10 +829,12 @@ export const SCOPE_NOT_EVALUATED =
|
|
|
665
829
|
// the service key) and NEVER `description`, which carries the program name identity is moving
|
|
666
830
|
// away from — a digest over volatile prose reintroduces the volatility through the digest.
|
|
667
831
|
export const AGENT_SCOPE_FROM_TIER =
|
|
668
|
-
'Agent-produced findings: their scope is derived from the run TIER,
|
|
669
|
-
+ '
|
|
670
|
-
+ '
|
|
671
|
-
+ '
|
|
832
|
+
'Agent-produced findings: their scope is derived from the run TIER, plus the records a run writes when '
|
|
833
|
+
+ 'Enterprise failed on a host, an agent did not run, or a requested plugin it reads did not complete — the '
|
|
834
|
+
+ 'agent set is a function of the licensed capabilities, and a tier difference refuses the comparison outright '
|
|
835
|
+
+ 'rather than narrowing it. What is NOT compared is whether the plugins an analysis agent or the CVE mapper '
|
|
836
|
+
+ 'reads were REQUESTED in both runs: when the two runs requested different --plugins, such a row can read '
|
|
837
|
+
+ 'resolved or new with nothing changed. Keep --plugins identical between compared runs. '
|
|
672
838
|
+ 'Their IDENTITY is also narrower than a plugin finding\'s: an agent finding is keyed on host, '
|
|
673
839
|
+ 'producer, port and title, and on nothing else — it carries no object (resource), no region, '
|
|
674
840
|
+ 'no rule qualifier and NO CONTENT DIGEST. So two agent findings that differ only in text the '
|
|
@@ -697,8 +863,8 @@ export const producerNoun = (f) => (f?.producerKind === 'agent' ? 'producer' : '
|
|
|
697
863
|
|
|
698
864
|
// Why a finding present in ONE run cannot be compared against the other. Order matters only for
|
|
699
865
|
// 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
|
|
866
|
+
function incomparabilityReason(f, mine, theirs, names) {
|
|
867
|
+
if (!theirs.hosts.has(hostKey(f.host))) return { reason: 'host-not-scanned', detail: `host ${f.host} was not scanned in ${names.theirs}` };
|
|
702
868
|
// ⚠️ A NULL IDENTITY MAY NEVER SATISFY A SCOPE CHECK, and it may never be DESCRIBED as one
|
|
703
869
|
// either. Before this leg a finding with no producer fell into `plugin-not-run` and reported
|
|
704
870
|
// "plugin null did not run in the other run" — a sentence that is false about the run, about a
|
|
@@ -706,24 +872,45 @@ function incomparabilityReason(f, mine, theirs) {
|
|
|
706
872
|
// missing. It is checked FIRST because every leg below keys on the producer.
|
|
707
873
|
if (f.plugin == null) {
|
|
708
874
|
return { reason: 'producer-unknown',
|
|
709
|
-
detail:
|
|
875
|
+
detail: `this finding carries no producer identity, so whether it was in scope in ${names.theirs} cannot be established` };
|
|
710
876
|
}
|
|
711
877
|
// ⚠️ THE ORACLE DEPENDS ON THE PRODUCER KIND, and guessing it from the string's SHAPE is what
|
|
712
878
|
// this leg refuses to do. A plugin id is answerable from `pluginsRequested`. An EE analysis
|
|
713
879
|
// agent appears in no such list and never will, so checking it there would bucket every
|
|
714
880
|
// agent-produced finding as `plugin-not-run` for ever — safe, and useless, which is the failure
|
|
715
881
|
// 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`)
|
|
717
|
-
//
|
|
718
|
-
//
|
|
719
|
-
//
|
|
882
|
+
// whole-comparison refusals above (`ee-presence-differs`, `tier-differs`) guarantee that by the
|
|
883
|
+
// time control reaches here both sides carry Enterprise and the SAME tier, so the agent SET is
|
|
884
|
+
// identical on both. ⚠️ THAT DOES NOT MAKE AN AGENT ROW COMPARABLE (1.2.0 limit, boarded for
|
|
885
|
+
// 1.2.1): the agent — and the CVE mapper — derives its rows from other plugins' output, and those
|
|
886
|
+
// can differ at the same tier when `--plugins` was narrowed in one run. Nothing here checks them,
|
|
887
|
+
// so such a row reads resolved or new; AGENT_SCOPE_FROM_TIER says so. That per-finding INPUT check
|
|
888
|
+
// CAN fail through the shipped path — it is owed, not dead code.
|
|
720
889
|
if (f.producerKind !== 'agent' && !theirs.plugins.has(f.plugin)) {
|
|
721
890
|
// ⚠️ THE NOUN HERE IS A LITERAL `plugin` AND THAT IS CORRECT — but only because of the
|
|
722
891
|
// `producerKind !== 'agent'` guard on this very line, which makes the branch unreachable for
|
|
723
892
|
// an agent. Delete that guard and this sentence starts calling agents plugins, which is the
|
|
724
893
|
// defect the identity branch below had to be repaired for. The coupling is written here
|
|
725
894
|
// 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
|
|
895
|
+
return { reason: PLUGIN_NOT_RUN_REASON, detail: `plugin ${producerLabel(f)} did not run in ${names.theirs}` };
|
|
896
|
+
}
|
|
897
|
+
// ⚠️ ENTERPRISE'S STAGE DID NOT RUN ON THIS HOST IN ONE OF THE RUNS (1.1.1 — the audit seat's T1-c ruling). A failed
|
|
898
|
+
// LOAD (utils/ee_load.mjs) or a failed ENRICHMENT leaves the host with no analysis-agent and no CVE-mapper rows, while
|
|
899
|
+
// the run record still names Enterprise's version and the same tier — so the whole-comparison refusals above do not
|
|
900
|
+
// fire, and before this leg every such row the other run held read RESOLVED. A queue-path row (an Enterprise producer)
|
|
901
|
+
// on a host where EITHER run recorded the failure was not looked for there. `evidence-gap`, the reason an individual
|
|
902
|
+
// agent's not-run record already carries; Community's plugins are not affected (their own status governs them).
|
|
903
|
+
// Checked before the identity-basis leg: a stage that did not run is a statement about coverage, and it wins.
|
|
904
|
+
if (f.producerKind === 'agent') {
|
|
905
|
+
const k = hostKey(f.host);
|
|
906
|
+
const stage = theirs.eeStages?.get(k) ?? mine.eeStages?.get(k);
|
|
907
|
+
if (stage) {
|
|
908
|
+
const which = theirs.eeStages?.has(k) ? names.theirs : names.mine;
|
|
909
|
+
const what = stage.loadError ? 'failed to load' : 'failed during enrichment';
|
|
910
|
+
return { reason: 'evidence-gap',
|
|
911
|
+
detail: `Enterprise ${what} on ${f.host} in ${which} — none of its analysis agents or its CVE mapper ran there, so `
|
|
912
|
+
+ `this ${producerNoun(f)}'s rows were not looked for (${String(stage.loadError ?? stage.enrichmentError).slice(0, 200)})` };
|
|
913
|
+
}
|
|
727
914
|
}
|
|
728
915
|
// ⚠️ THE PRODUCER CHANGED WHAT IT NAMES BETWEEN THESE TWO RELEASES, so its two keys for one
|
|
729
916
|
// object cannot be matched and must not be DIFFERENCED either. Checked here, before the
|
|
@@ -757,7 +944,7 @@ function incomparabilityReason(f, mine, theirs) {
|
|
|
757
944
|
// EIGHT unremediated `eu-west-1` findings as RESOLVED — GuardDuty NOT ENABLED, Inspector2
|
|
758
945
|
// DISABLED, default EBS encryption DISABLED — because narrowing a follow-up scan is a normal
|
|
759
946
|
// operator action and nothing in the record could tell it from remediation.
|
|
760
|
-
const scopeMiss = scopeNotScanned(f, mine, theirs);
|
|
947
|
+
const scopeMiss = scopeNotScanned(f, mine, theirs, names);
|
|
761
948
|
if (scopeMiss) return { reason: SCOPE_NOT_SCANNED_REASON, detail: scopeMiss };
|
|
762
949
|
|
|
763
950
|
// ⚠️ `theirs` ONLY, AND THE `?? mine.gaps` THAT USED TO SIT HERE WAS WRONG IN BOTH HALVES.
|
|
@@ -780,9 +967,9 @@ function incomparabilityReason(f, mine, theirs) {
|
|
|
780
967
|
if (gap) {
|
|
781
968
|
return gap.kind === 'recorded-gap'
|
|
782
969
|
? { reason: 'evidence-gap',
|
|
783
|
-
detail:
|
|
970
|
+
detail: `${names.theirs} recorded an evidence gap on ${f.host}/${producerLabel(f)}: ${gap.reason}` }
|
|
784
971
|
: { reason: PLUGIN_NOT_MEASURED_REASON,
|
|
785
|
-
detail: `${f.host}/${producerLabel(f)} was not measured in
|
|
972
|
+
detail: `${f.host}/${producerLabel(f)} was not measured in ${names.theirs} — ${gap.reason}` };
|
|
786
973
|
}
|
|
787
974
|
// ⚠️ THE FINDING'S PORT WAS NOT MEASURED IN THE OTHER RUN (EE 1.1.0 build 9, F6). Any producer's row: a
|
|
788
975
|
// probe that could not complete on an OPEN port starves every consumer of that port's service. Placed
|
|
@@ -790,24 +977,92 @@ function incomparabilityReason(f, mine, theirs) {
|
|
|
790
977
|
const portGap = f.port != null && Number(f.port) > 0 ? theirs.portGaps?.get(portGapKey(f.host, f.port)) : null;
|
|
791
978
|
if (portGap) {
|
|
792
979
|
return { reason: PROBE_NOT_MEASURED_REASON,
|
|
793
|
-
detail: `port ${f.port} on ${f.host} was not measured in
|
|
980
|
+
detail: `port ${f.port} on ${f.host} was not measured in ${names.theirs} — a probe ran there and did not `
|
|
794
981
|
+ `complete its connection (${portGap}). It is NOT reported as fixed or as new — rescan to compare.` };
|
|
795
982
|
}
|
|
983
|
+
// ⚠️ THE CVE MAPPER COULD NOT LOOK THIS SERVICE UP IN THE OTHER RUN (1.1.1, T1b). Its own lookup gap on the same
|
|
984
|
+
// (host, port, transport) says the CVE rows were not looked for, whatever the port's state — so it answers before
|
|
985
|
+
// the port rules, and before the UDP exemption, whose "answered and identified" cannot say the lookup worked.
|
|
986
|
+
// ⚠️ AND A LOOKUP-GAP ROW ON ONE SIDE ONLY IS ITSELF NEVER NEW OR RESOLVED (condition 4): it is a record that the
|
|
987
|
+
// mapper could not look a service up, so its appearing is not a new exposure and its vanishing is not a remediation
|
|
988
|
+
// (the lookup worked again, or the service changed). It stays visible, with its reason, rather than leaving the table.
|
|
989
|
+
if (isEngineLookupGap(f)) {
|
|
990
|
+
return { reason: 'evidence-gap',
|
|
991
|
+
detail: `this is the CVE mapper's own record that it could not look up the service on ${f.port}/`
|
|
992
|
+
+ `${TRANSPORT_OF_LABEL[String(f.protocol ?? '').toLowerCase()] ?? f.protocol} (${f.gapClass}), present in only one of the `
|
|
993
|
+
+ 'two runs — a coverage gap that opened or cleared, not an exposure that appeared or was fixed.' };
|
|
994
|
+
}
|
|
995
|
+
if (f.plugin === CVE_MAPPER_PRODUCER && Number(f.port) > 0) {
|
|
996
|
+
const lookupGap = theirs.engineLookupGaps?.get(engineLookupGapKey(f.host, f.port, f.protocol));
|
|
997
|
+
if (lookupGap) {
|
|
998
|
+
return { reason: 'evidence-gap',
|
|
999
|
+
detail: `the CVE mapper recorded in ${names.theirs} that it could not look up the service on ${f.port}/`
|
|
1000
|
+
+ `${TRANSPORT_OF_LABEL[String(f.protocol ?? '').toLowerCase()] ?? f.protocol} on ${f.host} (${lookupGap}), so its CVE `
|
|
1001
|
+
+ 'rows there were not looked for. It is NOT reported as fixed or as new — rescan once the lookup succeeds.' };
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
796
1004
|
// ⚠️ THE FINDING'S PORT STOPPED ANSWERING (EE 1.1.0 build 12). Open in the run that HOLDS the finding, and in the
|
|
797
1005
|
// other run the port scanner ran and put it in NEITHER list: no answer inside its timeout, or not probed. After
|
|
798
1006
|
// F6, so a probe-not-measured verdict does not move. Keyed on the HOLDING run's tcpOpen, so a UDP service or a
|
|
799
1007
|
// port no TCP list ever named is untouched; and only where the other run's scanner RAN on this host — with no
|
|
800
1008
|
// oracle there the rule is silent and the legs above govern (a stated limit, not an inference).
|
|
1009
|
+
//
|
|
1010
|
+
// ⚠️ GATED TO NON-UDP ROWS (1.1.1): the port scanner's lists are TCP, and a udp/53 row judged by TCP/53 read
|
|
1011
|
+
// RESOLVED when TCP/53 closed and printed this TCP-only sentence when TCP/53 was filtered. A UDP-transport row goes
|
|
1012
|
+
// to the leg below whatever TCP says about its port number.
|
|
801
1013
|
const port = Number(f.port);
|
|
802
|
-
|
|
1014
|
+
const udpRow = isUdpTransport(f.protocol);
|
|
1015
|
+
if (port > 0 && !udpRow) {
|
|
803
1016
|
const held = mine.portState?.get(hostKey(f.host));
|
|
804
1017
|
const other = theirs.portState?.get(hostKey(f.host));
|
|
805
|
-
|
|
1018
|
+
// `other?.open` — the TCP ORACLE, present only where 003 ran; a host entry may carry the UDP oracle alone (1.1.1).
|
|
1019
|
+
if (held?.open?.has(port) && other?.open && other?.closed && !other.open.has(port) && !other.closed.has(port)) {
|
|
806
1020
|
return { reason: PORT_NOT_MEASURED_REASON,
|
|
807
|
-
detail: `port ${port} on ${f.host} was open in
|
|
1021
|
+
detail: `port ${port} on ${f.host} was open in ${names.mine}, and ${names.theirs}'s port `
|
|
808
1022
|
+ 'scanner recorded it neither open nor closed — no answer inside its timeout (filtered), or not probed — so '
|
|
809
1023
|
+ '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-
|
|
1024
|
+
+ 'reads TCP ports the port scanner saw open; a UDP-transport finding is judged by its own rule, and a '
|
|
1025
|
+
+ 'host-wide finding by neither.)' };
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
// ⚠️ A UDP-TRANSPORT ROW PRESENT IN ONE RUN ONLY (1.1.1). No UDP producer in this release records a port as
|
|
1029
|
+
// CLOSED, so the other run's silence on this port is not a measurement: never RESOLVED, never NEW. Its precondition
|
|
1030
|
+
// is the finding's PRODUCER having run in the other run, which the producer-wide legs above already own — a gap or
|
|
1031
|
+
// a not-measured producer answers first. It never reads the port scanner, so it does not depend on 003 running.
|
|
1032
|
+
// ⚠️ THE ORACLE IS THE OTHER RUN'S UDP SERVICE RECORD (`udpPortMeasurement`, shared with MTTR): where it says the
|
|
1033
|
+
// port was measured for this producer the verdict stands — a changed SNMP community resolves, a dnsmasq version
|
|
1034
|
+
// bump moves its CVE set; everywhere else the row is not comparable, and the detail names which absence it was.
|
|
1035
|
+
// With no service set recorded the rule FIRES: a UDP row with no oracle has no measurement at all (deliberately
|
|
1036
|
+
// unlike the TCP rule's silence without 003). A PORTLESS UDP row (mdns/0) stays outside, as a host-wide row does.
|
|
1037
|
+
if (port > 0 && udpRow) {
|
|
1038
|
+
const m = udpPortMeasurement(port, f.plugin, theirs.portState?.get(hostKey(f.host))?.udpServices ?? null, names.theirs);
|
|
1039
|
+
if (!m.measured) {
|
|
1040
|
+
// The explanation follows the BRANCH: a service that answered but was not identified was not silent (the verifier
|
|
1041
|
+
// of the first draft found it told the reader both), and rescanning will not change it while its version is hidden.
|
|
1042
|
+
const why = m.kind === 'unidentified'
|
|
1043
|
+
? 'An unidentified service gives the CVE mapper nothing to match, so the absence of a CVE row there is not a '
|
|
1044
|
+
+ 'measurement. It is NOT reported as fixed or as new — rescan once the service\'s version can be read.'
|
|
1045
|
+
: 'UDP silence is not a measurement: no producer in this release records an unanswered UDP port as closed, so a '
|
|
1046
|
+
+ 'UDP service that stopped answering cannot be told apart from one that was fixed. It is NOT reported as fixed '
|
|
1047
|
+
+ 'or as new — rescan, and confirm the service directly.';
|
|
1048
|
+
return { reason: PORT_NOT_MEASURED_REASON,
|
|
1049
|
+
detail: `${port}/udp on ${f.host} (${f.protocol}) carries a finding in only one of the two runs, and ${m.why}. ${why}` };
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
// ⚠️ THE SAME PROGRAM AND VERSION ANSWERED IN BOTH RUNS, AND ONE OF THEM HOLDS A CVE ROW THE OTHER DOES NOT (1.2.0
|
|
1053
|
+
// build 2). DISAPPEARED only: a newly attributed CVE is new knowledge about an exposure that is real now, so it stays
|
|
1054
|
+
// NEW and carries a basis note instead (`withNewBasis`). After the port and UDP legs, so a port that was not measured
|
|
1055
|
+
// keeps its own reason; the mapper's CVE rows only — its gap and note rows carry a `gapClass` and are not attributions.
|
|
1056
|
+
if (f.plugin === CVE_MAPPER_PRODUCER && !f.gapClass && port > 0 && names.mine === RUN_NAMES.baseline) {
|
|
1057
|
+
const was = serviceIdentityAt(mine.portState?.get(hostKey(f.host))?.services, port, f.protocol);
|
|
1058
|
+
const now = serviceIdentityAt(theirs.portState?.get(hostKey(f.host))?.services, port, f.protocol);
|
|
1059
|
+
if (sameServiceIdentity(was, now)) {
|
|
1060
|
+
return { reason: VULNERABILITY_DATA_CHANGED_REASON,
|
|
1061
|
+
detail: `the same ${now.program} ${now.version} answered on ${port}/${now.transport} on ${f.host} in both runs, and `
|
|
1062
|
+
+ 'the CVE mapper attributes a CVE on the program and version alone — so this row\'s absence is a change in the '
|
|
1063
|
+
+ 'vulnerability data it was matched against, not a remediation. Vulnerability data — '
|
|
1064
|
+
+ `${names.mine}: ${vulnerabilityDataSource(mine.nvdCache)}; ${names.theirs}: ${vulnerabilityDataSource(theirs.nvdCache)}. `
|
|
1065
|
+
+ 'It is NOT reported as fixed — confirm against the vendor\'s advisory.' };
|
|
811
1066
|
}
|
|
812
1067
|
}
|
|
813
1068
|
// ⚠️ NO SILENT SHORT-CIRCUIT. This used to read `mine.frameworks && theirs.frameworks &&
|
|
@@ -826,6 +1081,70 @@ function incomparabilityReason(f, mine, theirs) {
|
|
|
826
1081
|
return null;
|
|
827
1082
|
}
|
|
828
1083
|
|
|
1084
|
+
// ── THE CVE MAPPER'S OWN LOOKUP GAPS, PORT-SCOPED (1.1.1, T1b) ────────────────────────────────────────────────────
|
|
1085
|
+
// Enterprise's intelligence engine records a service it could not look up — the NVD lookup failed, the response was
|
|
1086
|
+
// not an array, the offline store could not be read or held no entry for the CPE, the program has no CPE alias, the
|
|
1087
|
+
// banner carried no version — as `[COVERAGE GAP] <gapClass> — <protocol>/<service>` with a `gapClass` and NO
|
|
1088
|
+
// `evidenceGap` flag. So since 0.2.55 a failed lookup made the baseline's CVE rows on that service read RESOLVED (TCP
|
|
1089
|
+
// and UDP alike). Flagging the record `evidenceGap` would not do: the recorded-gap leg keys PRODUCER-wide, so one
|
|
1090
|
+
// cpe_map_miss — every run has some — would set aside every engine row on the host. So it is PORT-SCOPED, on
|
|
1091
|
+
// (host, port, transport) from the gap record's own target, and it applies to the CVE mapper's rows alone. The gap
|
|
1092
|
+
// rows stay paired like findings (unchanged across runs); one on a single side is refused, never NEW or RESOLVED.
|
|
1093
|
+
//
|
|
1094
|
+
// ⚠️ THE TABLE CLASSIFIES EVERY GAP CLASS THE ENGINE DECLARES, and Enterprise's test holds it in two-way equality with
|
|
1095
|
+
// the engine's GAP_CLASSES, so a class the engine adds and nobody classified fails the build — incompleteness costs
|
|
1096
|
+
// noise, never silence. `offline_store_no_match` is a lookup failure too: a CPE absent from the store is
|
|
1097
|
+
// indistinguishable from an incomplete store, and a baseline that HAD rows for that CPE means the store moved.
|
|
1098
|
+
export const ENGINE_GAP_CLASS_KIND = Object.freeze({
|
|
1099
|
+
nvd_lookup_failure: 'lookup-failed',
|
|
1100
|
+
nvd_response_not_array: 'lookup-failed',
|
|
1101
|
+
offline_store_read_error: 'lookup-failed',
|
|
1102
|
+
offline_store_no_match: 'lookup-failed',
|
|
1103
|
+
cpe_map_miss: 'lookup-failed',
|
|
1104
|
+
no_version_detected: 'lookup-failed',
|
|
1105
|
+
truncated_low_severity_cves: 'note', // a COVERAGE NOTE: the lookup worked and some LOW rows were folded
|
|
1106
|
+
input_gap: 'input-gap', // the engine's input gap — an evidence gap, handled by the recorded-gap leg
|
|
1107
|
+
});
|
|
1108
|
+
/** A CVE-mapper lookup-gap row, port-scoped. Written as plain `f.` reads so the boundary contract sees them. */
|
|
1109
|
+
export const isEngineLookupGap = (f) => f != null && f.plugin === CVE_MAPPER_PRODUCER
|
|
1110
|
+
&& ENGINE_GAP_CLASS_KIND[f.gapClass] === 'lookup-failed' && Number(f.port) > 0;
|
|
1111
|
+
/** The key a lookup gap and the CVE rows it covers share: host (as `hostKey`), port, and the TRANSPORT of the label. */
|
|
1112
|
+
export const engineLookupGapKey = (host, port, protocol) =>
|
|
1113
|
+
`${hostKey(host)}|${Number(port)}|${TRANSPORT_OF_LABEL[String(protocol ?? '').toLowerCase()] ?? `label:${String(protocol ?? '').toLowerCase()}`}`;
|
|
1114
|
+
function engineLookupGapsOf(findings) {
|
|
1115
|
+
const out = new Map();
|
|
1116
|
+
for (const f of Array.isArray(findings) ? findings : []) {
|
|
1117
|
+
if (isEngineLookupGap(f)) out.set(engineLookupGapKey(f.host, f.port, f.protocol), f.gapClass);
|
|
1118
|
+
}
|
|
1119
|
+
return out;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// A UDP row that RESOLVED or APPEARED did so because the other run MEASURED its port (1.1.1) — so the row shows that
|
|
1123
|
+
// work, in the client's own report: the Pro page sells a resolved column that says why it was trusted. `basisNote` is
|
|
1124
|
+
// output-only; nothing here reads it back.
|
|
1125
|
+
function withUdpBasis(f, theirs, theirName) {
|
|
1126
|
+
const port = Number(f.port);
|
|
1127
|
+
if (!(port > 0) || !isUdpTransport(f.protocol)) return f;
|
|
1128
|
+
const m = udpPortMeasurement(port, f.plugin, theirs.portState?.get(hostKey(f.host))?.udpServices ?? null, theirName);
|
|
1129
|
+
return m.measured ? { ...f, basisNote: m.basis } : f;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// A CVE row that APPEARED on a service identified the same in both runs (1.2.0 build 2 — MANDATORY, the audit seat's
|
|
1133
|
+
// ruling): it stays NEW, and says the service did not change, because "new exposure on my estate" and "new knowledge
|
|
1134
|
+
// about an old service" route to different work. A changed or unidentified service keeps today's basis.
|
|
1135
|
+
function withNewBasis(f, mine, theirs) {
|
|
1136
|
+
const port = Number(f.port);
|
|
1137
|
+
if (f.plugin === CVE_MAPPER_PRODUCER && !f.gapClass && port > 0) {
|
|
1138
|
+
const now = serviceIdentityAt(mine.portState?.get(hostKey(f.host))?.services, port, f.protocol);
|
|
1139
|
+
const was = serviceIdentityAt(theirs.portState?.get(hostKey(f.host))?.services, port, f.protocol);
|
|
1140
|
+
if (sameServiceIdentity(was, now)) {
|
|
1141
|
+
return { ...f, basisNote: `newly attributed — the same ${was.program} ${was.version} answered on ${port}/${was.transport} `
|
|
1142
|
+
+ `in ${RUN_NAMES.baseline}; the service is unchanged, the vulnerability data is not` };
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
return withUdpBasis(f, theirs, RUN_NAMES.baseline);
|
|
1146
|
+
}
|
|
1147
|
+
|
|
829
1148
|
// The oracle test, separated from the per-finding check so its ABSENCE has somewhere to be
|
|
830
1149
|
// declared. gate:cascade's LEG (ii) is the precedent this repo already set: when the oracle is
|
|
831
1150
|
// missing, print NOT EVALUATED and never pass silently.
|
|
@@ -1025,6 +1344,8 @@ export function buildScanDelta({ baseline, current }) {
|
|
|
1025
1344
|
// A declared scope boundary is set aside the same way (read into `scopeOf` as a statement); a row
|
|
1026
1345
|
// flagged as both is a gap, which the line above already removes.
|
|
1027
1346
|
const isScopeStatement = (f) => f.deferredScope === true;
|
|
1347
|
+
// (The CVE mapper's lookup-gap rows stay PAIRED — unchanged when both runs carry one, which the mapper-gap corpus
|
|
1348
|
+
// leg pins — and one present on ONE side only is refused in `incomparabilityReason`, never NEW or RESOLVED.)
|
|
1028
1349
|
const bFind = (baseline?.findings ?? []).filter((f) => !isGap(f) && !isScopeStatement(f));
|
|
1029
1350
|
const cFind = (current?.findings ?? []).filter((f) => !isGap(f) && !isScopeStatement(f));
|
|
1030
1351
|
if ((baseline?.findings ?? []).some(isGap) || (current?.findings ?? []).some(isGap)) {
|
|
@@ -1087,17 +1408,17 @@ export function buildScanDelta({ baseline, current }) {
|
|
|
1087
1408
|
else unchanged.push(f);
|
|
1088
1409
|
continue;
|
|
1089
1410
|
}
|
|
1090
|
-
const why = incomparabilityReason(f, bScope, cScope);
|
|
1411
|
+
const why = incomparabilityReason(f, bScope, cScope, FRAME_DISAPPEARED);
|
|
1091
1412
|
if (why) notComparable.push({ ...f, direction: 'disappeared', ...why });
|
|
1092
|
-
else resolved.push(f);
|
|
1413
|
+
else resolved.push(withUdpBasis(f, cScope, RUN_NAMES.current));
|
|
1093
1414
|
}
|
|
1094
1415
|
for (const [k, f] of cMap) {
|
|
1095
1416
|
if (bMap.has(k)) continue;
|
|
1096
1417
|
// The mirror, and it is not symmetric in danger: a finding "new" only because its host or
|
|
1097
1418
|
// plugin was out of scope last time is NEW COVERAGE, not a new exposure.
|
|
1098
|
-
const why = incomparabilityReason(f, cScope, bScope);
|
|
1419
|
+
const why = incomparabilityReason(f, cScope, bScope, FRAME_APPEARED);
|
|
1099
1420
|
if (why) notComparable.push({ ...f, direction: 'appeared', ...why });
|
|
1100
|
-
else newFindings.push(f);
|
|
1421
|
+
else newFindings.push(withNewBasis(f, cScope, bScope));
|
|
1101
1422
|
}
|
|
1102
1423
|
|
|
1103
1424
|
return {
|
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
//
|
|
3
3
|
// ⚠️ THE BASELINE SELECTION IS ITSELF A CLAIM, which is why this module names it rather than
|
|
4
4
|
// silently diffing against it. If the selected run was narrow — a `--plugins` subset, one host,
|
|
5
|
-
// an interrupted run — the delta is
|
|
6
|
-
//
|
|
7
|
-
//
|
|
5
|
+
// an interrupted run — the delta is practically a wall of NOT-COMPARABLE, and an operator who
|
|
6
|
+
// meets that twice stops reading the feature. So the baseline's id, timestamp and scope are
|
|
7
|
+
// printed BEFORE any verdict. ⚠️ Not for an AGENT row (1.2.0 limit): an analysis agent's or the
|
|
8
|
+
// CVE mapper's row whose input plugins a `--plugins` subset dropped reads resolved or new instead
|
|
9
|
+
// of NOT-COMPARABLE — see AGENT_SCOPE_FROM_TIER.
|
|
8
10
|
//
|
|
9
11
|
// ⚠️ AND REFUSING IS NOT STONEWALLING. When the selected baseline cannot support a verdict, this
|
|
10
12
|
// says which earlier records exist and which of them are chain-verified — but it NEVER
|