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.
@@ -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 (!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);
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 `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';
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 `the other run's record carries no ${unitName} scope, so it is not known whether `
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 the other run's overall scope, but producer `
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 the other run's recorded scope `
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, 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. '
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 the other run` };
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: 'this finding carries no producer identity, so whether it was in scope in the other run cannot be established' };
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`) 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.
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 the other run` };
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: `the other run recorded an evidence gap on ${f.host}/${producerLabel(f)}: ${gap.reason}` }
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 the other run — ${gap.reason}` };
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 the other run — a probe ran there and did not `
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
- if (port > 0) {
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
- if (held?.open.has(port) && other && !other.open.has(port) && !other.closed.has(port)) {
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 the run that holds this finding, and the other run's port `
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-only and host-wide findings are not judged by it.)' };
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 technically correct and practically a wall of NOT-COMPARABLE,
6
- // and an operator who meets that twice stops reading the feature. So the baseline's id, timestamp
7
- // and scope are printed BEFORE any verdict.
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