nsauditor-ai 0.2.53 → 0.2.55
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +176 -565
- package/cli.mjs +271 -37
- package/mcp_server.mjs +27 -16
- package/package.json +2 -2
- package/plugin_manager.mjs +105 -13
- package/plugins/040_tls_cert_auditor.mjs +48 -0
- package/plugins/mcp_scanner.mjs +12 -0
- package/plugins/port_scanner.mjs +27 -23
- package/plugins/upnp_scanner.mjs +88 -9
- package/utils/capabilities.mjs +10 -8
- package/utils/cloud_finding_summary.mjs +1 -1
- package/utils/cloud_providers.mjs +90 -0
- package/utils/delta_reporter.mjs +8 -1
- package/utils/executive_report.mjs +100 -1
- package/utils/finding_identity.mjs +127 -0
- package/utils/host_iterator.mjs +8 -5
- package/utils/report_inputs.mjs +273 -17
- package/utils/run_chain.mjs +296 -0
- package/utils/run_record.mjs +103 -3
- package/utils/scan_delta.mjs +1122 -0
- package/utils/scan_delta_view.mjs +218 -0
- package/utils/scan_history.mjs +92 -2
package/utils/report_inputs.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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 —
|