nsauditor-ai 0.2.53 → 0.2.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,15 +4,27 @@
4
4
  // report that renders confidently over a partial or mismatched run is the false clean this
5
5
  // feature exists to prevent: a correct report about the wrong (or incomplete) run reads
6
6
  // identically to a correct one without that caveat.
7
+ // ⚠️ `fs` IS IMPORTED BECAUSE IT WAS BEING USED WITHOUT BEING IMPORTED. The queue read below
8
+ // called `fs.readFileSync` while this module bound only `fsp`, so it threw
9
+ // `ReferenceError: fs is not defined` on EVERY call and the bare `catch` around it swallowed the
10
+ // throw — the primary artifact path was dead code and 100% of finding-queue entries arrived
11
+ // through the `eeEnrichment` fallback the comment there calls a legacy shim. It read clean under
12
+ // every hand probe written with `node -e`, because `node -e` exposes built-in modules as globals
13
+ // and a real module file does not: THE PROBE HABITAT, NOT THE CODE, IS WHAT WAS PASSING.
14
+ // Behaviour-preserving on the whole corpus: all 12 real `scan_finding_queue.json` files under
15
+ // audit-evidence-samples are byte-identical to their record's `eeEnrichment.queue`.
16
+ import fs from 'node:fs';
7
17
  import fsp from 'node:fs/promises';
8
18
  import path from 'node:path';
9
19
  import crypto from 'node:crypto';
10
20
  import { describeFinding } from './cloud_finding_summary.mjs';
21
+ import { canonicaliseResource } from './finding_identity.mjs';
11
22
  import { censusFindingContainers } from './report_finding_census.mjs';
12
23
  import {
13
24
  RUN_RECORD_SCHEMA, UNPARSEABLE, runRecordPath, listRunRecords, readRunRecord,
14
25
  } from './run_record.mjs';
15
26
  import { CE_RETENTION_MS } from './scan_history.mjs';
27
+ import { canonicalHost } from './cloud_providers.mjs';
16
28
 
17
29
  const refuse = (reason, message) => ({ ok: false, reason, message });
18
30
 
@@ -117,9 +129,17 @@ function issueText(i) {
117
129
  return String(i);
118
130
  }
119
131
 
120
- export function shapeFinding(host, f) {
132
+ // The envelope's plugin id, in the vocabulary `cli.mjs` writes into `pluginsRequested`
133
+ // (`String(p.id)`). MEASURED across 152 real run records: 7,962 members, not one a display name.
134
+ const envId = (e) => (e?.id != null ? String(e.id) : null);
135
+
136
+ export function shapeFinding(host, f, plugin = null, pluginName = null) {
121
137
  const severity = f?.severity != null ? String(f.severity).toUpperCase() : 'INFO';
122
138
  const port = f?.port ?? null;
139
+ // The finding's OWN region, from its own field — never a default and never inferred. Nothing
140
+ // can be decoration without it, and guessing one would strip a legitimately region-named
141
+ // object. `null` for every non-regional producer, which is most of them.
142
+ const region = typeof f?.region === 'string' && f.region.length > 0 ? f.region : null;
123
143
  // ⚠️ THE REPORT USED TO READ `f.title` ALONE, AND NO SHIPPED PLUGIN EMITS IT.
124
144
  // Measured at Gate 3-B on the installed 0.44.0 trio: 27 of 29 EE plugins carry no
125
145
  // `title:` field, no CE plugin carries one, and exactly three `findings.push` sites
@@ -147,8 +167,20 @@ export function shapeFinding(host, f) {
147
167
  // what every other caller titles. `description` in particular is NOT in REASON_KEYS, so
148
168
  // without this a zero-trust finding titles itself "MEDIUM finding (no description)".
149
169
  const contentIssues = issueTexts.length ? issueTexts : (explicitDetail ? [String(explicitDetail)] : []);
170
+ // ⚠️ THE CANONICAL RESOURCE IS COMPUTED HERE, ABOVE THE TITLE, BECAUSE THE TITLE EMBEDS IT —
171
+ // and canonicalising only the `resource` FIELD leaves the decoration alive in a second place
172
+ // that identity keys on. `describeFinding` renders the resource into the title it derives, so
173
+ // a pre-E1 baseline titled a finding `backup:account [eu-west-2] — …` while a post-E1 run
174
+ // titles the same finding `backup:account — …`. `title` is a component of the delta's identity
175
+ // key and of `id`, so the pair would read as resolved+new across the upgrade: the exact
176
+ // fabricated churn the canonicaliser exists to prevent, surviving in the field nobody looked
177
+ // at. Found by a leg that rendered the row and READ it, not by reasoning about the field.
178
+ const canonicalResource = canonicaliseResource(
179
+ f?.resource ?? f?.target ?? f?.details?.resource ?? null, region);
150
180
  const title = f?.title
151
- ?? (contentIssues.length ? describeFinding({ ...f, issues: contentIssues }) : null);
181
+ ?? (contentIssues.length
182
+ ? describeFinding({ ...f, resource: canonicalResource, issues: contentIssues })
183
+ : null);
152
184
  const cves = Array.isArray(f?.cves) ? f.cves.map(String)
153
185
  : Array.isArray(f?.cve) ? f.cve.map(String) : [];
154
186
  // CE ships no KEV/EPSS store of its own (utils/scan_history.mjs comment at cli.mjs:2780):
@@ -160,6 +192,80 @@ export function shapeFinding(host, f) {
160
192
  .digest('hex').slice(0, 16);
161
193
  return {
162
194
  host, port, severity, title,
195
+ // ⚠️ CARRIED FOR IDENTITY, not for display. Cloud issue strings name the DEFECT and not the
196
+ // resource ("No public access block configured…"), so without this every bucket on one host
197
+ // normalises to the same {host, port, severity, title} and a cross-run delta collapses them —
198
+ // masking a NEW exposure behind a surviving one. Dropping it here would defeat the delta's
199
+ // identity key from outside the delta, with the delta's own tests still green.
200
+ // ⚠️ CANONICALISED, NOT RAW (board E1). EE's `utils/aws_region_scan.mjs::_stampRegion`
201
+ // rewrote this field on every finding routed through `forEachRegion`: a finding with no
202
+ // resource got the REGION as its object identity, and a finding with one got ` [<region>]`
203
+ // appended. E1 stops both at the producer, but the PUBLISHED CE 0.2.54 already wrote
204
+ // baselines full of the decorated values and `RUN_RECORD_SCHEMA` is 1 on both sides — so
205
+ // those baselines compare as commensurable. Canonicalising here is what makes that true:
206
+ // the same object computes the same key whichever side of the upgrade wrote it. Shared with
207
+ // EE's MTTR fingerprint so the two comparison channels cannot disagree about identity.
208
+ resource: canonicalResource,
209
+ // ⚠️ THE REGION AS ITS OWN FIELD, which it has never been on this shape. Before E1 the
210
+ // region reached both identity and the READER only through the decoration above — this file
211
+ // mentioned `region` zero times — so removing the suffix without adding the field would take
212
+ // the region out of an assessor's view and out of the delta's key at the same time. It is a
213
+ // FIELD and never a suffix: a suffix is a value change that fabricates churn across an
214
+ // upgrade, a field is recomputed identically on both sides of any comparison.
215
+ region,
216
+ // ⚠️ AN EVIDENCE GAP IS SCOPE, NOT A FINDING, and the delta cannot tell them apart without
217
+ // this. A gap record says "the scanner could not read this surface" — so a finding that
218
+ // vanished behind one was not fixed, nobody looked. It is carried on the finding rather than
219
+ // in a side-channel because that is where the producer already writes it (`details.evidenceGap`
220
+ // on 7 AWS and 3 Azure findings of the real 1.1.0 run), which means the READ side needs no
221
+ // writer change to see it.
222
+ evidenceGap: f?.details?.evidenceGap === true,
223
+ // WHICH gap, when the producer names one (EE 1.1.0 build 9). The delta scopes an `input_gap` that
224
+ // carries a PORT to that port alone (`probe-not-measured`); every other gap stays producer-wide.
225
+ gapClass: typeof f?.details?.gapClass === 'string' ? f.details.gapClass : null,
226
+ // ⚠️ A DECLARED SCOPE BOUNDARY IS NOT A FINDING EITHER (EE 1.1.0 build 5, CFN-3's consumer-first
227
+ // route). Seventeen plugins emit one INFO row stating what they do NOT examine, flagged
228
+ // `details.deferredScope`; it routes to no control and is not an exposure. Paired like a finding,
229
+ // a correction to its WORDING read as one finding resolved and another appearing. Carried on the
230
+ // finding for the same reason as `evidenceGap`: that is where the producer already writes it.
231
+ deferredScope: f?.details?.deferredScope === true,
232
+ // ⚠️ IDENTITY MUST NEVER BE COMPUTED OVER A TRUNCATED STRING, and these two exist because it
233
+ // was. Plugin 1170 emits no `title`, so one is synthesised from `issues` and CUT AT 160 CHARS
234
+ // — and on a live estate three ingress rules on ONE security group (PostgreSQL 5432, SSH 22,
235
+ // Redis 6379, every one open to 0.0.0.0/0) synthesised to the SAME 158-character string,
236
+ // because the port list is the only token that differs and it falls just past the cut.
237
+ // `resource` is the REGION for that plugin and `port` is null, so neither discriminated.
238
+ // Three real CRITICAL exposures held ONE identity: remediate the SSH rule, acquire a new
239
+ // 0.0.0.0/0 rule on the same group, and the delta reports UNCHANGED.
240
+ //
241
+ // `contentDigest` is taken over the UNTRUNCATED content this function already holds, so the
242
+ // fix covers EVERY producer whose text differs only past 160 chars, not just 1170.
243
+ contentDigest: crypto.createHash('sha256')
244
+ .update(JSON.stringify([f?.title ?? null, contentIssues])).digest('hex').slice(0, 16),
245
+ // And the PRODUCER-EMITTED discriminators, never ones invented here: a rule identity the
246
+ // plugin already carries in `details`. Absent on producers that emit none, which is why the
247
+ // key falls back rather than requiring it.
248
+ identityQualifier: [f?.details?.groupId, f?.details?.protocol, f?.details?.fromPort, f?.details?.toPort]
249
+ .filter((v) => v != null).join('/') || null,
250
+ // WHICH ORACLE adjudicates this producer's scope. A plugin id is answerable from the record's
251
+ // `pluginsRequested`; an EE analysis agent is NOT — it appears in no such list, because
252
+ // `agents/agent_runner.mjs` derives the agent set from CAPABILITIES and the envelope persists
253
+ // no per-agent run status. Deriving this in the delta would mean guessing from the shape of
254
+ // the string; the loader KNOWS, because it knows which container the finding came out of.
255
+ producerKind: (plugin ?? f?.plugin) != null ? 'plugin' : null,
256
+ // ⚠️ CARRIED FOR COMPARABILITY, and for the same reason as `resource`. A cross-run delta can
257
+ // only call a finding resolved if the plugin that produced it RAN in both scans; without the
258
+ // producing plugin on the finding, every comparison falls to "plugin-not-run" — never a false
259
+ // "resolved" (the safe direction) but a wall of NOT-COMPARABLE, which is a feature nobody reads.
260
+ plugin: plugin ?? f?.plugin ?? null,
261
+ // ⚠️ IDENTITY AND DISPLAY ARE TWO FIELDS, and splitting them is not tidiness. `plugin` is
262
+ // compared against `record.pluginsRequested`, which `cli.mjs` writes as plugin IDs, so it must
263
+ // BE an id or the comparison is false on every real record. But `scan_delta.mjs` interpolates
264
+ // the producing plugin into its `plugin-not-run` detail and `executive_report.mjs` renders
265
+ // that detail into the CLIENT HTML's basis cell — so stamping the id alone would put
266
+ // "plugin 003 did not run in the other run" into a branded deliverable. One field cannot be
267
+ // both the join key and the prose.
268
+ pluginName: pluginName ?? f?.pluginName ?? null,
163
269
  // ALL issues, not just the lead clause the title took: a report that shows one of a
164
270
  // finding's four issues silently drops three the scan actually recorded.
165
271
  detail: explicitDetail ?? (issueTexts.length ? issueTexts.join(' · ') : null),
@@ -198,6 +304,42 @@ function shapeQueueEntry(host, q) {
198
304
  epss: typeof q?.epssScore === 'number' && Number.isFinite(q.epssScore) ? q.epssScore
199
305
  : (typeof q?.epss === 'number' && Number.isFinite(q.epss) ? q.epss : null),
200
306
  exploitPriority: q?.exploitPriority ?? null,
307
+ // ⚠️ THE QUEUE IS A SIBLING ARTIFACT WITH ITS OWN PRODUCER VOCABULARY, and emitting NO
308
+ // producer at all was the fifth instance of the loader-boundary class: every queue finding
309
+ // reached the delta as `plugin: undefined` and bucketed `plugin-not-run` with the sentence
310
+ // "plugin undefined did not run in the other run". Its real producer is an EE ANALYSIS AGENT,
311
+ // not a CE plugin — measured across 350 real queue entries: intelligence_engine 302 ·
312
+ // crypto_agent 46 · exposure_agent 2, and ZERO entries carrying anything id-shaped. So this
313
+ // identity is NOT a member of `pluginsRequested` and never will be; deciding whether an agent
314
+ // was in scope on the other side needs its own oracle, which is why a null producer must
315
+ // still refuse rather than fall through. Absence here is a per-finding fact, not a class.
316
+ plugin: q?.evidence?.source ?? null,
317
+ pluginName: q?.evidence?.source ?? null,
318
+ producerKind: q?.evidence?.source ? 'agent' : null,
319
+ // ⚠️ THIS WAS THE CONSTANT `false`, UNDER A COMMENT THAT SAID "a queue entry is never a gap
320
+ // record: gaps are emitted by plugins into the host envelope". Enterprise's CPE mapper emits
321
+ // SIX classes of coverage-gap record straight into this queue, and one of them exists purely
322
+ // to say "my discovery upstreams did not run" — so the premise was false about the code, and
323
+ // the seam discarded the only fact that record carried. Measured consequence on two real
324
+ // scans: three `[COVERAGE GAP] cpe_map_miss` rows that vanished because ten discovery plugins
325
+ // TIMED OUT were reported to the reader as REMEDIATED.
326
+ //
327
+ // The queue's vocabulary is `evidence.raw`, not `details` — a queue entry has no `details`.
328
+ // Exactly `true`, never merely truthy: a producer writing something else has a bug, and
329
+ // reading it as a declaration would make that producer's whole output un-differenceable.
330
+ evidenceGap: q?.evidence?.raw?.evidenceGap === true,
331
+ // WHICH gap (EE 1.1.0 build 9). EE's service-set input gap names the port a probe could not
332
+ // measure: a probe that RAN on a port the port scanner saw open and did not complete its connection
333
+ // there. The delta reads `input_gap` + a port as "that port was not measured in this run", so a
334
+ // baseline row on it reads `probe-not-measured`, never RESOLVED. Carried verbatim; the queue's
335
+ // vocabulary is `evidence.raw`, as for `evidenceGap`.
336
+ gapClass: typeof q?.evidence?.raw?.gapClass === 'string' ? q.evidence.raw.gapClass : null,
337
+ // ⚠️ NO QUEUE PRODUCER EMITS ONE TODAY — measured on the build-4 corpus, the only queue (38
338
+ // entries, intelligence_engine 33 + crypto_agent 5) carries no `deferredScope` at either depth,
339
+ // and `evidence.raw` there is an intel record, not a finding. This read is SHAPE PARITY with the
340
+ // envelope path, so a queue producer that starts declaring a boundary is set aside on the day it
341
+ // does, rather than paired. Exactly `true`, as above; pinned in both directions by test.
342
+ deferredScope: q?.evidence?.raw?.deferredScope === true,
201
343
  id: q?.id ?? null,
202
344
  };
203
345
  }
@@ -214,6 +356,78 @@ function shapeQueueEntry(host, q) {
214
356
  * carrying severity-bearing objects that is neither read here nor allowlisted there fails
215
357
  * loudly. Incompleteness costs NOISE, never SILENCE.
216
358
  */
359
+ // ⚠️ ONE DEFINITION OF A FINDING, SHARED BY BOTH COMPARISON CHANNELS (board C10).
360
+ //
361
+ // `scan_history.jsonl` used to carry a count `cli.mjs` computed for itself — service-level
362
+ // attributes plus plugin `result.findings` — while `report --since` counted whatever THIS module
363
+ // shapes. On the real estate the two disagreed completely: the 192.168.1.1 Gate-2 run recorded
364
+ // `findingsCount: 0` while its queue held 37 entries, 21 carrying CVEs. The history is a
365
+ // COMPARISON channel (`computeDiff` derives `newFindings` and `findingsDelta` from that number,
366
+ // and `delta_reporter` gates a webhook on it), so the host reported "no change" on every scan.
367
+ //
368
+ // ⚠️ AND THE FIX IS NOT A THIRD SUM. The previous repair of this same channel added
369
+ // `cloudFindingsCount` after cloud plugins recorded 0 over a 201-finding scan — a per-producer
370
+ // patch, which closes only the producers it enumerates, which is how the channel came to be
371
+ // wrong twice. Deriving the count from the shaping the delta already uses is what makes the two
372
+ // channels incapable of drifting apart: a producer this module learns to read is counted by both,
373
+ // on the same day, without anyone remembering to add it here.
374
+ //
375
+ // A GAP IS SCOPE, NOT A FINDING, and is excluded — exactly as the delta excludes gaps from its
376
+ // buckets. Counting one would make an AccessDenied look like a vulnerability appearing and a
377
+ // fixed permission look like a remediation.
378
+ // ⚠️ AND SO IS A DECLARED SCOPE STATEMENT (`deferredScope`), since the delta began setting those
379
+ // aside before pairing (CE 0.2.55, CFN-3). Leaving them in this count would make the sentence
380
+ // above false the day that landed: the delta and the history would disagree about the 12 scope
381
+ // rows of the test estate's aws run. A row carrying both flags is a gap and is excluded either way.
382
+ export function countHostFindings(host, raw, queue = []) {
383
+ return shapeHostFindings(host, raw, queue)
384
+ .filter((f) => f?.evidenceGap !== true && f?.deferredScope !== true).length;
385
+ }
386
+
387
+ // ⚠️ THE VALUE CHANGED UNDER AN UNCHANGED KEY, which contract-v1 §5.3 records as the shape that
388
+ // once reported a mass remediation that never happened. A history line written before this fix
389
+ // counted the old way; one written after counts the new way, and subtracting across them would
390
+ // report a fabricated "+N new" on the first scan after an upgrade. The basis rides the line so
391
+ // `computeDiff` can REFUSE the comparison instead of computing it.
392
+ // `loader-shaped-v2` (CE 0.2.55): scope statements left the count. A v1 line and a v2 line are
393
+ // refused like any basis change — the number moved under the same key again, by the number of
394
+ // scope statements the run carried.
395
+ export const FINDINGS_COUNT_BASIS = 'loader-shaped-v2';
396
+
397
+ // ⚠️ THE COUNT AND ITS BASIS COME FROM ONE CALL, DELIBERATELY. They were two statements, and a
398
+ // mutant that reverted the COUNT to the old per-producer sum kept the BASIS stamp — so the
399
+ // history line claimed to have been counted the new way while holding an old-way number, and the
400
+ // end-to-end leg could not tell. A line's basis is a claim about HOW it was produced, so only the
401
+ // thing that produces it may assert it. Returns null when the artifacts cannot be read, and the
402
+ // caller then records an UN-BASED line rather than a mislabelled one.
403
+ export async function deriveFindingsCount(outDir, host) {
404
+ try {
405
+ const raw = JSON.parse(await fsp.readFile(path.join(outDir, 'scan_conclusion_raw.json'), 'utf8'));
406
+ // ⚠️ THE QUEUE IS RESOLVED EXACTLY AS `loadRun` RESOLVES IT, and the first draft of this did
407
+ // not. `loadRun` normalises a non-array file (`{queue:[…]}` / `{findings:[…]}`) and falls back
408
+ // to the in-envelope `eeEnrichment.queue` for a run written before the file existed. Reading
409
+ // the same PATH is not the same as reading the same SOURCE: with a wrapped queue this counted
410
+ // zero while the report counted every entry — "one definition" nominally, two in fact, which
411
+ // is the whole defect C10 exists to close. Found by reading the consumer, not by a fixture.
412
+ return { count: countHostFindings(host, raw, resolveFindingQueue(outDir, raw)),
413
+ basis: FINDINGS_COUNT_BASIS };
414
+ } catch {
415
+ return null;
416
+ }
417
+ }
418
+
419
+ // The one resolver both channels use. Kept beside `countHostFindings` so a future change to the
420
+ // queue's shape cannot reach one channel and miss the other.
421
+ export function resolveFindingQueue(dir, raw) {
422
+ try {
423
+ const qRaw = JSON.parse(fs.readFileSync(path.join(dir, 'scan_finding_queue.json'), 'utf8'));
424
+ return Array.isArray(qRaw) ? qRaw : (qRaw?.queue ?? qRaw?.findings ?? []);
425
+ } catch {
426
+ const q = raw?.conclusion?.result?.eeEnrichment?.queue;
427
+ return Array.isArray(q) ? q : [];
428
+ }
429
+ }
430
+
217
431
  export function shapeHostFindings(host, raw, queue = []) {
218
432
  const out = [];
219
433
  const seen = new Set();
@@ -247,17 +461,17 @@ export function shapeHostFindings(host, raw, queue = []) {
247
461
  // producers; the report sees BOTH paths.
248
462
  const rf = res.findings;
249
463
  if (Array.isArray(rf)) {
250
- for (const f of rf) push(shapeFinding(host, f));
464
+ for (const f of rf) push(shapeFinding(host, f, envId(e), e?.name ?? null));
251
465
  } else if (rf && typeof rf === 'object') {
252
466
  // 060 DNS Security Auditor emits a DICT OF CATEGORIES ({spf:[…], dmarc:[…]}), which
253
467
  // an `Array.isArray` guard skips in complete silence.
254
468
  for (const arr of Object.values(rf)) {
255
- if (Array.isArray(arr)) for (const f of arr) push(shapeFinding(host, f));
469
+ if (Array.isArray(arr)) for (const f of arr) push(shapeFinding(host, f, envId(e), e?.name ?? null));
256
470
  }
257
471
  }
258
472
  if (res.zeroTrust && typeof res.zeroTrust === 'object') {
259
473
  for (const dim of Object.values(res.zeroTrust)) {
260
- if (Array.isArray(dim?.findings)) for (const f of dim.findings) push(shapeFinding(host, f));
474
+ if (Array.isArray(dim?.findings)) for (const f of dim.findings) push(shapeFinding(host, f, envId(e), e?.name ?? null));
261
475
  }
262
476
  }
263
477
  if (Array.isArray(res.portResults)) {
@@ -265,7 +479,7 @@ export function shapeHostFindings(host, raw, queue = []) {
265
479
  // The port result's own `severity` is a ROLL-UP of these issues, never a finding —
266
480
  // rendering both would double-count every TLS issue.
267
481
  if (Array.isArray(pr?.issues)) {
268
- for (const i of pr.issues) push(shapeFinding(host, { ...i, port: i?.port ?? pr?.port ?? null }));
482
+ for (const i of pr.issues) push(shapeFinding(host, { ...i, port: i?.port ?? pr?.port ?? null }, envId(e), e?.name ?? null));
269
483
  }
270
484
  }
271
485
  }
@@ -277,6 +491,18 @@ export function shapeHostFindings(host, raw, queue = []) {
277
491
  return out;
278
492
  }
279
493
 
494
+ // What the PORT SCANNER recorded on this host (EE 1.1.0 build 12) — the delta's port-state oracle. Present only when
495
+ // 003's status on the host reads exactly `ran` AND its result carries both TCP lists; otherwise null, which the delta
496
+ // reads as "no oracle", never as "nothing open". tcpFiltered is not carried: the delta's rule is "neither open nor
497
+ // closed", which a filtered port and an unprobed port satisfy alike.
498
+ export function portScanOf(raw) {
499
+ const st = (Array.isArray(raw?.pluginStatus) ? raw.pluginStatus : []).find((p) => String(p?.id) === '003');
500
+ const res = (Array.isArray(raw?.results) ? raw.results : []).find((e) => String(e?.id) === '003')?.result;
501
+ if (st?.status !== 'ran' || !Array.isArray(res?.tcpOpen) || !Array.isArray(res?.tcpClosed)) return null;
502
+ const ports = (l) => l.map(Number).filter((n) => Number.isInteger(n) && n > 0);
503
+ return { tcpOpen: ports(res.tcpOpen), tcpClosed: ports(res.tcpClosed) };
504
+ }
505
+
280
506
  function shapeHost(host, dir, raw) {
281
507
  const envelopes = Array.isArray(raw.results) ? raw.results : [];
282
508
  const up = envelopes.some((e) => e?.result?.up === true);
@@ -284,6 +510,16 @@ function shapeHost(host, dir, raw) {
284
510
  return {
285
511
  host, dir, up, findings,
286
512
  pluginStatus: Array.isArray(raw.pluginStatus) ? raw.pluginStatus : [],
513
+ // ⚠️ THE DEFAULT ABOVE ERASES THE ONE DISTINCTION `scan_delta.mjs` INSISTS ON. Its `scopeOf`
514
+ // says in as many words that `[]` means "measured, no gaps" while MISSING means nothing was
515
+ // measured, and that the two must not render alike — and then reads `Array.isArray(...)` over
516
+ // a value this loader guarantees is an array, so the question is always true and the
517
+ // `scope-not-evaluated` disclosure could never fire. The default stays (every consumer
518
+ // iterates it), and the answer travels BESIDE it. Same loader-boundary class as the dropped
519
+ // `plugin` / `resource` / `control` fields: the distinction was preserved in the consumer and
520
+ // destroyed here, before the consumer ever saw it.
521
+ pluginStatusRecorded: Array.isArray(raw.pluginStatus),
522
+ portScan: portScanOf(raw),
287
523
  };
288
524
  }
289
525
 
@@ -300,7 +536,8 @@ function buildModel(rec, hosts, counts) {
300
536
  // (`--host 10.0.0.7,10.0.0.7`, a repeated --host-file line, an overlapping range — none of
301
537
  // which utils/host_iterator.mjs de-duplicates). A name-keyed Map is last-write-wins and
302
538
  // silently drops every same-named host's own plugin table but the final one's.
303
- plugins.byHost.push({ host: h.host, dir: h.dir, status: h.pluginStatus });
539
+ plugins.byHost.push({ host: h.host, dir: h.dir, status: h.pluginStatus,
540
+ pluginStatusRecorded: h.pluginStatusRecorded, portScan: h.portScan });
304
541
  for (const ps of h.pluginStatus) {
305
542
  if (ps?.status === 'ran') plugins.ran += 1;
306
543
  else if (ps?.status === 'skipped') plugins.skipped += 1;
@@ -345,7 +582,10 @@ async function finishLoadingRecord(outRoot, rec, allowPartial) {
345
582
 
346
583
  const hosts = [];
347
584
  const unreadByHost = [];
348
- for (const { host, dir } of rec.hostsWritten ?? []) {
585
+ // ⚠️ THE HOST IS READ CANONICAL (census G6, CE half): a record written before the parse-time fold
586
+ // carries `AWS` as typed, and a model that kept it would compare unequal to a later `aws` run.
587
+ for (const { host: recordedHost, dir } of rec.hostsWritten ?? []) {
588
+ const host = canonicalHost(recordedHost);
349
589
  const rawPath = path.join(outRoot, dir, 'scan_conclusion_raw.json');
350
590
  let raw;
351
591
  try { raw = JSON.parse(await fsp.readFile(rawPath, 'utf8')); }
@@ -369,14 +609,11 @@ async function finishLoadingRecord(outRoot, rec, allowPartial) {
369
609
  // fallback covers a record written before the file existed. A queue that cannot be read
370
610
  // yields [] and the host simply renders its plugin findings — never an exception, and
371
611
  // never a silent substitution of one source for another.
372
- let findingQueue = [];
373
- try {
374
- const qRaw = JSON.parse(fs.readFileSync(path.join(outRoot, dir, 'scan_finding_queue.json'), 'utf8'));
375
- findingQueue = Array.isArray(qRaw) ? qRaw : (qRaw?.queue ?? qRaw?.findings ?? []);
376
- } catch {
377
- const q = raw?.conclusion?.result?.eeEnrichment?.queue;
378
- if (Array.isArray(q)) findingQueue = q;
379
- }
612
+ // ⚠️ ONE RESOLVER, SHARED WITH `deriveFindingsCount` (board C10). This logic was duplicated
613
+ // there and the copy was already wrong — it handled only a bare array, so a wrapped queue
614
+ // counted zero in the history channel and every entry in the report. Two copies of "what the
615
+ // queue is" is the same defect one layer down from two copies of "what a finding is".
616
+ const findingQueue = resolveFindingQueue(path.join(outRoot, dir), raw);
380
617
  // ⚠️ THE CENSUS RUNS ON EVERY REAL RUN, NOT ONLY IN TESTS — this is what makes it a
381
618
  // guard rather than a fixture. Its reconcile leg's corpus is synthetic, and the two real
382
619
  // runs were reconciled BY HAND; a new plugin inventing a new container would be silent
@@ -397,10 +634,29 @@ async function finishLoadingRecord(outRoot, rec, allowPartial) {
397
634
 
398
635
  const requested = (rec.hostsRequested ?? []).length;
399
636
  const written = hosts.length;
400
- const { missingNamed, missingUnparseable } = computeMissing(rec.hostsRequested ?? [], hosts.map((h) => h.host));
637
+ const { missingNamed, missingUnparseable } = computeMissing((rec.hostsRequested ?? []).map(canonicalHost), hosts.map((h) => h.host));
401
638
  const incomplete = !rec.finishedAt;
402
639
  const anyMissing = missingNamed.length > 0 || missingUnparseable > 0;
403
640
 
641
+ // ⚠️ AN ABORTED RUN IS REFUSED BY NAME, WITH ITS RECORDED REASON (board item 2). It is finalised and
642
+ // sealed now, so `finishedAt` is set and the incomplete check below would pass it — and the partial-
643
+ // hosts message would say what is missing without saying the scan was stopped. Checked before both.
644
+ // Refused, never skipped: `--since prior` still selects it ("disclose, never substitute").
645
+ if (rec.status === 'aborted' && !allowPartial) {
646
+ // Disclose, never substitute — and never leave the user without the fix: name the nearest FINISHED
647
+ // run before it, so it can be passed explicitly. A record with no `status` predates the field and
648
+ // is a finished run, never an aborted one.
649
+ const prior = (await listRunRecords(outRoot))
650
+ .filter((r) => r?.runId !== rec.runId && r?.finishedAt && r?.status !== 'aborted' && String(r?.startedAt) < String(rec.startedAt))
651
+ .sort((a, b) => String(b.startedAt).localeCompare(String(a.startedAt)))[0] ?? null;
652
+ return refuse('aborted-run',
653
+ `Refusing to report: this run was ABORTED before it completed (${rec.abortReason ?? 'no reason was recorded'}). `
654
+ + `${written} of ${requested} requested hosts were written. `
655
+ + (prior ? `The nearest finished run before it is \`${prior.runId}\` — pass it with \`--run\` or \`--since\` to use it explicitly. `
656
+ : 'No finished run precedes it. ')
657
+ + 'Re-run the scan, or pass `--allow-partial` to report on what was recorded.');
658
+ }
659
+
404
660
  // ORDER MATTERS (mutant-proven — see the task report): the incomplete case is checked FIRST.
405
661
  // A crash after the LAST host directory is written leaves hostsWritten === hostsRequested with
406
662
  // no finishedAt, so `anyMissing` is false and the partial-hosts message below would be FALSE —