nsauditor-ai 0.2.51 → 0.2.53

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 CHANGED
@@ -17,6 +17,16 @@ NSAuditor AI is the open-source core of a privacy-first security intelligence pl
17
17
 
18
18
  ## What's New
19
19
 
20
+ **Latest: CE 0.2.53 + Enterprise 0.46.0 — a documentation release: the PCI DSS claims in this README were wrong.** The Enterprise cycle derived the PCI Customized-Approach eligibility set from PCI DSS v4.0.1 itself rather than from a transcribed list, and this README carried the transcribed one: a stale triple (now **19 covered / 9 partial / 44 OOS across 72**), a retired `MVP-67` density label, an "Appendix E" attribution that names the wrong appendix, and a sampling citation that belongs to Section 6. No scanner change and no plugin change — Community stays at 27 plugins. **npm freezes a README at publish time, which is why a doc-only correction gets a version.**
21
+ is deleted: it validated any reply of at least 48 bytes with a non-null transmit timestamp and nothing
22
+ else — no mode byte, no stratum, no originate echo — so over unauthenticated UDP a datagram of the right
23
+ shape produced a false clean reading, and it measured the SCANNER's own clock rather than your estate.
24
+ It had never been reachable from any shipped entry point.
25
+ RFC 3161 trusted timestamping is untouched, opt-in, and is the capability that answers the question —
26
+ off unless you configure a Time-Stamp Authority, and an outbound call to a third party when you do. On the Community side this cycle changes no behaviour: the
27
+ withdrawn-claim guard over `license --capabilities` descriptions learns the wording the product actually
28
+ published, so the class cannot recur silently here. See [CHANGELOG.md](./CHANGELOG.md).
29
+
20
30
  **The Pro `report` subcommand, over a run record that states its own coverage (CE 0.2.51 / Enterprise 0.44.0).**
21
31
  `nsauditor-ai report --from <dir> --format executive|jira` turns a completed scan run into a
22
32
  client-facing deliverable — a self-contained, print-ready HTML report a consultant sends to their
@@ -140,7 +150,7 @@ Scan → Analyze → Prioritize → Track → Act
140
150
  - **Smart result fusion** — the Result Concluder merges all plugin outputs into a normalized view with OS detection, service fingerprinting, and evidence linking
141
151
  - **Structured finding format** — all findings use a common schema with category, severity, evidence, and remediation — enabling consistent SARIF export and MCP integration
142
152
  - **AI-powered analysis** — send redacted scan results to OpenAI or Claude (your keys, your choice) for vulnerability assessments and remediation guidance
143
- - **Risk-scored prioritization (Pro)** — findings carry a composite risk score (CVSS weighted by verification status, with an uplift for initial-access techniques) and a status field, and an operator suppression workflow (accepted-risk / false-positive with expiry) keeps triaged findings out of the report until they expire
153
+ - **Risk-scored prioritization (Pro/Enterprise)** — findings carry a composite risk score (CVSS weighted by verification status, with an uplift for initial-access techniques) and a status field, and an operator suppression workflow (accepted-risk / false-positive with expiry) keeps triaged findings out of the report until they expire
144
154
  - **Continuous monitoring (CTEM)** — watch mode rescans on a schedule, diffs against previous results, and fires webhook alerts on changes
145
155
  - **MCP integration** — expose scanning tools to AI assistants like Claude Code via Model Context Protocol
146
156
  - **CI/CD ready** — SARIF output with `--fail-on` severity gating for pipeline integration
@@ -158,7 +168,7 @@ If you're heading into a **SOC 2, HIPAA, NIST CSF 2.0, PCI DSS, ISO 27001, CIS C
158
168
  - **SOC 2** (AICPA TSC 2017) — 10 fully-covered + 4 partial controls
159
169
  - **HIPAA Security Rule §164.312** — 7 covered + 3 partial Technical Safeguards; **Zero BAA required** (ePHI never leaves your infrastructure)
160
170
  - **NIST CSF 2.0 Core** (NIST CSWP 29, Feb 2024) — 13 covered + 10 partial Subcategories across 106 of CSF 2.0's 107 Subcategories; Subcategory-level mapping (auditor-canonical, not high-level Function/Category claims)
161
- - **PCI DSS v4.0.1** (PCI SSC, June 2024 errata; v3.2.1 retired March 31, 2024) — **19 covered + 9 partial + 39 OOS sub-requirements across 67 of ~250 (MVP-67)**; sub-requirement-level mapping for QSA Report on Compliance workflow; Defined-vs-Customized Approach discipline per Appendix E (15 Defined-only sub-requirements enforced at schema layer); CHD Scope operator-attested via CDE Data Flow Diagram per Req 1.2.4; Card-brand AOC enforcement priority view (Visa CISP / Mastercard SDP / Amex DSOP / Discover DISC)
171
+ - **PCI DSS v4.0.1** (PCI SSC, June 2024 errata; v3.2.1 retired March 31, 2024) — **19 covered + 9 partial + 44 OOS sub-requirements across 72 of ~250**; sub-requirement-level mapping for QSA Report on Compliance workflow; Defined-vs-Customized Approach discipline enforced at the schema layer, derived from the standard rather than from a list: PCI DSS v4.0.1 states ineligibility in each requirement's own Customized Approach Objective cell, not in an appendix, and no sub-requirement mapped here is ineligible; CHD Scope operator-attested via CDE Data Flow Diagram per Req 1.2.4; Card-brand AOC enforcement priority view (Visa CISP / Mastercard SDP / Amex DSOP / Discover DISC)
162
172
  - **ISO/IEC 27001:2022** (ISO + IEC, Oct 2022; 2013 edition retired Oct 31, 2025) — **17 covered + 14 partial + 62 OOS across 93 Annex A controls** (the complete Annex A universe); per-Annex-A-code mapping auditor-canonical for ISO/IEC 17021-1 certification body assessors; Statement of Applicability per Clause 6.1.3.d discipline + ISMS Clauses 4-10 OOS-by-design with 7 Major Nonconformity classes
163
173
  - **CIS Critical Security Controls v8** (CIS, May 2021; v8.1 errata June 2024) — **17 covered + 23 partial + 113 OOS across 153 Safeguards / 18 Controls**; per-Safeguard mapping with the **Implementation Group cumulative discipline** (IG1=56 cyber-insurance baseline / IG2 cumulative=130 / IG3 cumulative=153); no-certification-body attestation discipline (INPUT to your CSAT / CIS-CAT Pro self-attestation, never "CIS certified"); Cloud Companion Guide v8 shared-responsibility + CIS-Hardened-Image substrate-evidence credit (4.1/4.2/4.6)
164
174
  - **GDPR Article 32 (Security of Processing)** (Regulation (EU) 2016/679) — **4 covered + 5 partial + 2 OOS across 11 Art. 32 sub-measure units**; **GDPR Article 32 infrastructure substrate only — NOT GDPR compliance** (GDPR is a 99-article legal regime; Art. 32 security-of-processing is the only article an infrastructure scanner can substrate-evidence; the rest is operator-side, out of scope by design). Four-factor proportionality (substrate *for* your "appropriate to the risk" determination, never an absolute pass/fail); personal-data-scope attestation (pair with your Art. 30 records of processing); **Art. 83(4) lower fine tier** (€10M/2%, not the €20M/4% headline tier); Art. 32(3)/Art. 42 cloud-provider certification-inheritance
@@ -205,7 +215,7 @@ How Marketplace fulfillment works (ZDE and air-gap preserved — no runtime AWS
205
215
  | SOC 2 (AICPA TSC 2017) — 10 covered + 4 partial controls | — | — | ✅ |
206
216
  | HIPAA Security Rule §164.312 — Zero BAA required | — | — | ✅ |
207
217
  | NIST CSF 2.0 Core — Subcategory-level mapping (106 of 107 Subcategories) | — | — | ✅ |
208
- | PCI DSS v4.0.1 — Sub-requirement-level mapping for QSA RoC (MVP-67) | — | — | ✅ |
218
+ | PCI DSS v4.0.1 — Sub-requirement-level mapping for QSA RoC | — | — | ✅ |
209
219
  | ISO/IEC 27001:2022 — per-Annex-A-code mapping + SoA discipline (93 Annex A controls) | — | — | ✅ |
210
220
  | CIS Critical Security Controls v8 — per-Safeguard mapping + IG-cumulative discipline (153 Safeguards / 18 Controls) | — | — | ✅ |
211
221
  | **GDPR Article 32 (Security of Processing)** — Art. 32 infrastructure substrate (4 covered + 5 partial + 2 OOS / 11 sub-measure units); **not GDPR compliance** · Art. 83(4) lower fine tier | — | — | ✅ |
@@ -411,7 +421,7 @@ Results land in `./out/<host>_<timestamp>/`:
411
421
  | — | SOC 2 Compliance Engine | Enterprise | AICPA TSC 2017 mapping (10 covered + 4 partial controls), SHA-256 chain-of-custody, opt-in RFC 3161 timestamps, exercised against a live TSA on both the npm path and the `:0.33.0` container image, and the suppression workflow. (Ed25519 SIGNING of suppressions is reachable from EE 0.35.0 and PROVEN at EE 0.36.0, verified per approver holding **key material**.) |
412
422
  | — | **HIPAA Compliance Engine (EE 0.9.0)** | Enterprise | HIPAA Security Rule §164.312 Technical Safeguards mapping (7 covered + 3 partial + 45 OOS within §164.312 + entire §164.308 + entire §164.310). HHS Required/Addressable discipline per control. Same evidence infrastructure as SOC 2 (SHA-256 chain-of-custody; opt-in RFC 3161 timestamps; suppression signing reachable from EE 0.35.0, proven at EE 0.36.0 and verified per approver holding **key material**). Use `--compliance hipaa` or `--compliance soc2,hipaa` for dual-framework reports from a single scan. **Zero BAA required** — Zero Data Exfiltration architecture means ePHI never leaves customer infrastructure. |
413
423
  | — | **NIST CSF 2.0 Compliance Engine (EE 0.10.0)** | Enterprise | NIST Cybersecurity Framework 2.0 Core mapping at the auditor-canonical Subcategory level — 13 covered + 10 partial + 83 OOS across 106 of CSF 2.0's 107 Subcategories. Govern function OOS-by-design (GV.SC-04 partial as substrate exception); Respond function OOS-entirely; Implementation Tiers 1-4 OOS as organizational-maturity claims. NIST SP 800-53 Rev. 5 + CIS Critical Security Controls v8 cross-references baked into `informativeReferences`. Use `--compliance nist-csf` or `--compliance soc2,hipaa,nist-csf` for triple-framework reports from a single scan. |
414
- | — | **PCI DSS v4.0.1 Compliance Engine (EE 0.11.0)** | Enterprise | PCI DSS v4.0.1 (PCI SSC, June 2024 errata; supersedes v4.0 March 2022; v3.2.1 retired March 31, 2024) mapping at the auditor-canonical sub-requirement level for QSA Report on Compliance workflow — **19 covered + 9 partial + 39 OOS across 67 of ~250 sub-requirements (MVP-67 density)** (Req 7.2.2 down-rated covered→partial in EE 0.19.4 — access-by-job-classification is process/HR-gated). Req 12 Information Security Program OOS-by-design entirely. Req 5 anti-malware + Req 9 physical OOS-entirely. **Defined-vs-Customized Approach discipline per Appendix E** — 15 Defined-only sub-requirements enforced at schema layer. **Cardholder Data Environment (CDE) scope operator-attested** via CDE Data Flow Diagram per Req 1.2.4 + Req 12.5.1. **Card-brand AOC enforcement priority view** (Visa CISP / Mastercard SDP / Amex DSOP / Discover DISC). **4 load-bearing schema enrichments** per control: `controlType` + `approachEligibility` + `cloudProviderAttestation` (AWS / Azure / GCP currently-named AOCs) + `cdeScope`. CAO MVP-deferred to EE 0.11.1. Use `--compliance pci-dss` or `--compliance soc2,hipaa,nist-csf,pci-dss` for quad-framework reports from a single scan. |
424
+ | — | **PCI DSS v4.0.1 Compliance Engine (EE 0.11.0)** | Enterprise | PCI DSS v4.0.1 (PCI SSC, June 2024 errata; supersedes v4.0 March 2022; v3.2.1 retired March 31, 2024) mapping at the auditor-canonical sub-requirement level for QSA Report on Compliance workflow — **19 covered + 9 partial + 39 OOS across 67 of ~250 sub-requirements (MVP-67 density)** (Req 7.2.2 down-rated covered→partial in EE 0.19.4 — access-by-job-classification is process/HR-gated). Req 12 Information Security Program OOS-by-design entirely. Req 5 anti-malware + Req 9 physical OOS-entirely. **Defined-vs-Customized Approach discipline per Appendix E** — 15 Defined-only sub-requirements enforced at schema layer. ⚠️ **That doctrine is CORRECTED and the figures above are left as the 0.11.0 record:** PCI DSS v4.0.1 states ineligibility in each requirement's own Customized Approach Objective cell, never in an appendix, and the enumeration this line described was wrong about most of its members. **Cardholder Data Environment (CDE) scope operator-attested** via CDE Data Flow Diagram per Req 1.2.4 + Req 12.5.1. **Card-brand AOC enforcement priority view** (Visa CISP / Mastercard SDP / Amex DSOP / Discover DISC). **4 load-bearing schema enrichments** per control: `controlType` + `approachEligibility` + `cloudProviderAttestation` (AWS / Azure / GCP currently-named AOCs) + `cdeScope`. CAO MVP-deferred to EE 0.11.1. Use `--compliance pci-dss` or `--compliance soc2,hipaa,nist-csf,pci-dss` for quad-framework reports from a single scan. |
415
425
  | — | **ISO/IEC 27001:2022 Compliance Engine (EE 0.12.0)** | Enterprise | ISO/IEC 27001:2022 (ISO + IEC, October 2022; 2013 edition retired October 31, 2025) Annex A mapping at the auditor-canonical per-Annex-A-code level for ISO/IEC 17021-1 certification body assessors — **17 covered + 14 partial + 62 OOS across 93 Annex A controls** (the complete Annex A universe across 4 themes: A.5 Organizational 37 + A.6 People 8 + A.7 Physical 14 + A.8 Technological 34). **Statement of Applicability per Clause 6.1.3.d discipline** — engine produces substrate for INCLUDED controls; SoA inclusion/exclusion is operator-side. **ISMS Clauses 4-10 OOS-by-design** with 7 Major Nonconformity classes (absence of internal audit per Clause 9.2 OR management review per Clause 9.3 = auto-fail Stage 2). 11 NEW 2022 controls + 5-attribute taxonomy (cybersecurityConcepts 5 categories, NOT 6 like NIST CSF) + 2013-to-2022 transition discipline + Cloud-Provider Certificate Inheritance Matrix. Use `--compliance iso-27001` or any combination for multi-framework reports from a single scan. |
416
426
  | — | **CIS Critical Security Controls v8 Compliance Engine (EE 0.13.0)** | Enterprise | CIS Controls v8 (Center for Internet Security, May 2021; v8.1 errata June 2024) mapping at the per-Safeguard level (the atomic, attestable unit; coverage claimed at the SAFEGUARD level, never the Control level) — **17 covered + 23 partial + 113 OOS across 153 Safeguards / 18 Controls**. **Implementation Group cumulative discipline** — IG1=56 (cyber-insurance baseline; ~50-70% of mid-market policies require IG1 attestation), IG2 cumulative=130, IG3 cumulative=153; smallest-IG-membership tagging (NEVER report IG2 as 74-of-74 in isolation). **No-certification-body attestation discipline** — engine output is INPUT to CSAT / CIS-CAT Pro self-attestation OR a SOC 2 auditor cross-validating CIS scope, never "CIS certified." Cloud Companion Guide v8 shared-responsibility-model boundary + CIS-Hardened-Image substrate-evidence credit (Safeguards 4.1/4.2/4.6) + 5 Security Functions (NOT 6 — no Govern) + 6 Asset Types + MS-ISAC/EI-ISAC/H-ISAC sector baselines + v7.1-to-v8 cross-reference. Use `--compliance cis-v8` or `--compliance soc2,hipaa,nist-csf,pci-dss,iso-27001,cis-v8,gdpr` for hepta-framework reports from a single scan. |
417
427
  | — | **GDPR Article 32 Compliance Engine (EE 0.20.0)** | Enterprise | **GDPR Article 32 (Security of Processing)** infrastructure substrate (Regulation (EU) 2016/679) — **4 covered + 5 partial + 2 OOS across 11 Art. 32 sub-measure units** (the 7th framework). **This is GDPR Article 32 infrastructure substrate ONLY — NOT GDPR compliance.** GDPR is a 99-article legal regime; Art. 32 security-of-processing is the only article whose evidence is technical infrastructure state, so the rest of GDPR (lawful basis, consent, DSARs, records of processing, DPIAs, transfers) is operator-side and out of scope by design. **Four-factor proportionality** — Art. 32 measures are "appropriate to the risk" taking into account state-of-the-art / cost / nature-scope-context-purposes / risk; nothing is an absolute pass/fail, the engine produces substrate *for* the operator's determination. **Personal-data-scope attestation** — the scanner reads configuration, not data classification; a finding is an Art. 32 concern only if the resource processes personal data (pair with your Art. 30 records of processing). Controller-vs-processor role applicability + Art. 28 processor agreements. **Art. 83(4) lower fine tier** — Art. 32 infringements cap at €10M or 2% of turnover, NOT the €20M / 4% Art. 83(5) headline tier (which is for the basic principles + data-subject rights). Art. 32(3)/Art. 42 cloud-provider certification-inheritance (ISO 27001 / SOC 2 / C5 / EU Cloud CoC adherence as an element of demonstrable compliance, not a substitute). Use `--compliance gdpr` or any combination for multi-framework reports from a single scan. |
@@ -782,7 +792,7 @@ nsauditor-ai validate
782
792
  nsauditor-ai feed bundle --from <dir-of-NVD-feeds> --out <bundle.json.gz> [--kev <f>] [--epss <f>]
783
793
  nsauditor-ai feed import --file <feed-or-bundle> [--cache-dir <d>] [--extras-dir <d>] [--append] # the feeds you downloaded
784
794
  nsauditor-ai compliance <attest|suppress|review|renew|keygen|sign-pack|verify-pack> (Enterprise)
785
- nsauditor-ai report --from <dir> --format executive|jira [--run <id>] [--brand <brand.json>] [--out <path>] [--allow-partial] (Pro)
795
+ nsauditor-ai report --from <dir> --format executive|jira [--run <id>] [--brand <brand.json>] [--out <path>] [--allow-partial] (Pro/Enterprise)
786
796
  nsauditor-ai mcp
787
797
  nsauditor-ai --help (or -h, or `help`)
788
798
  nsauditor-ai --version (or -v, or `version`)
@@ -876,7 +886,7 @@ HEALTHCHECK --interval=60s --timeout=5s --start-period=10s --retries=3 \
876
886
  CMD nsauditor-ai validate --json | grep -q '"overall": "ok"' || exit 1
877
887
  ```
878
888
 
879
- ### `report` command (Pro)
889
+ ### `report` command (Pro/Enterprise)
880
890
 
881
891
  `nsauditor-ai report --from <dir> --format executive|jira` turns a completed scan run under `--from` into a client-facing deliverable — an HTML report a consultant sends to their customer, or a Jira-importer CSV.
882
892
 
@@ -907,6 +917,51 @@ nsauditor-ai report --from out/2026-08-26 --format executive --brand brand.json
907
917
  nsauditor-ai report --from out/2026-08-26 --format jira
908
918
  ```
909
919
 
920
+ #### The `brand.json` file
921
+
922
+ `--brand` takes a small JSON object. Every field is optional; omit the file entirely and the report
923
+ renders unbranded with the default title.
924
+
925
+ ```json
926
+ {
927
+ "title": "Q3 External Perimeter Review",
928
+ "companyName": "Acme Manufacturing GmbH",
929
+ "preparedBy": "J. Rivera, Security Consulting LLC",
930
+ "contact": "security@example.com",
931
+ "logoPath": "acme-logo.png"
932
+ }
933
+ ```
934
+
935
+ | field | type | default | notes |
936
+ |---|---|---|---|
937
+ | `title` | string | `Network Scan Report` | Replaces the report's headline. |
938
+ | `companyName` | string | *(empty)* | The client the report is prepared **for**. |
939
+ | `preparedBy` | string | *(empty)* | The consultant or firm preparing it. |
940
+ | `contact` | string | *(empty)* | Free text — an address, an email, a phone number. |
941
+ | `logoPath` | string | *(none)* | A **local** PNG or JPEG, resolved relative to the brand file's own directory. Embedded into the HTML as a data URI. |
942
+
943
+ **`logoPath` is deliberately restrictive, and each refusal protects the report's core promise —
944
+ that it opens with no external network reference of any kind:**
945
+
946
+ - **A URL is refused and never fetched.** A remote logo is egress at render time and a tracking
947
+ pixel every time your client opens the file.
948
+ - **A UNC share (`\\host\share\...`) is refused** for the same reason: it is a network reference.
949
+ - **SVG is refused.** SVG is markup and can carry a script, which would break the report's
950
+ no-script guarantee from inside the image. Use PNG or JPEG.
951
+ - **The path must stay inside the brand file's own directory** — `../` escapes are refused, checked
952
+ both as typed and again after symlinks are resolved.
953
+ - **2 MB maximum**, and the format is detected by **magic bytes, not by file extension** — renaming
954
+ `logo.svg` to `logo.png` does not get it past the check.
955
+ - **An empty string is read as "no logo"**, the same as omitting the key, so a generated
956
+ `brand.json` that emits `""` is not an error.
957
+
958
+ A brand file that cannot be read, is not valid JSON, is not a JSON object, or whose `logoPath` is
959
+ refused is a **fatal error** — the report is not written. A flag you passed is never silently
960
+ dropped, because that is how a report goes out unbranded without anyone noticing.
961
+
962
+ ⚠️ `--brand` applies to `--format executive` only. Passing it with `--format jira` is **refused**,
963
+ not ignored: a CSV has nowhere to put a cover page.
964
+
910
965
  ---
911
966
 
912
967
  ## Configuration
@@ -957,7 +1012,7 @@ NSAUDITOR_PLUGIN_PATH= # Additional plugin directories (colon-
957
1012
  **Longitudinal compliance evidence (Enterprise CLI, new alongside the variables below):**
958
1013
 
959
1014
  A single scan shows configuration at an instant. A SOC 2 Type II auditor — and the ISO
960
- surveillance cadence, PCI Appendix E sampling, HIPAA §164.312(b) and GDPR Art. 32(1)(d)
1015
+ surveillance cadence, PCI DSS v4.0.1 Section 6 sampling (the assessor's documented determination; the standard prescribes no size), HIPAA §164.312(b) and GDPR Art. 32(1)(d)
961
1016
  equivalents — asks whether controls operated over a *period*. Every scan has always written
962
1017
  a per-framework `scan_attestation_<framework>.json`, so a history you already have is
963
1018
  aggregatable today:
package/cli.mjs CHANGED
@@ -1452,7 +1452,7 @@ export async function main(testHooks = {}) {
1452
1452
  Usage:
1453
1453
  nsauditor-ai [scan] --host <ip|cidr|hostname> [options]
1454
1454
  nsauditor-ai [scan] --host-file <path> [options]
1455
- nsauditor-ai report --from <dir> --format executive|jira [options] (Pro)
1455
+ nsauditor-ai report --from <dir> --format executive|jira [options] (Pro/Enterprise)
1456
1456
  nsauditor-ai license <subcommand>
1457
1457
  nsauditor-ai security <subcommand>
1458
1458
  nsauditor-ai validate
@@ -1644,7 +1644,7 @@ Examples:
1644
1644
  nsauditor-ai license install enterprise_eyJhbGciOiJFUzI1NiIs...
1645
1645
  nsauditor-ai license --status
1646
1646
 
1647
- Report subcommand (Pro):
1647
+ Report subcommand (Pro/Enterprise):
1648
1648
  nsauditor-ai report --from <dir> --format executive|jira [--run <id>]
1649
1649
  [--brand <brand.json>] [--out <path>] [--allow-partial]
1650
1650
  Renders a client-facing deliverable from a completed scan run under --from.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nsauditor-ai",
3
- "version": "0.2.51",
4
- "description": "Modular AI-assisted network security audit platform \u2014 Community Edition",
3
+ "version": "0.2.53",
4
+ "description": "Modular AI-assisted network security audit platform — Community Edition",
5
5
  "type": "module",
6
6
  "private": false,
7
7
  "scripts": {
@@ -350,9 +350,25 @@ function renderCoverageSummary(model) {
350
350
  return `${escapeHtml(String(u.host))}: ${names}`;
351
351
  });
352
352
  const total = unread.reduce((a, u) => a + Object.values(u.unread).reduce((x, y) => x + y, 0), 0);
353
- lines.push(`<p class="caveat">⚠️ ${total} recorded finding-like object(s) live in containers `
354
- + `this report does not read, and are NOT included above — ${parts.join(' · ')}. `
355
- + 'Treat this report as incomplete for those surfaces.</p>');
353
+ // ⚠️ TWO REASONS, TWO SENTENCES — and conflating them put a FALSE QUANTITY in front of a
354
+ // client. A container nobody reads is genuinely absent from this document. A carve-out whose
355
+ // PREMISE BROKE is different: its objects ARE rendered above except for the break, so saying
356
+ // they "are NOT included above" contradicts the body of the same report. Plugin 1150 emits
357
+ // `data: findings`, so one orphan among sixteen mirrors used to announce that seventeen
358
+ // objects were missing from a report containing sixteen of them.
359
+ const broken = unread.some((u) => Object.keys(u.premiseFailures ?? {}).length);
360
+ if (broken) {
361
+ const why = unread.flatMap((u) => Object.values(u.premiseFailures ?? {}));
362
+ lines.push(`<p class="caveat">⚠️ ${total} recorded finding-like object(s) are NOT accounted `
363
+ + `for by this report — ${parts.join(' · ')}. Some containers are not read at all; others `
364
+ + 'are summaries whose link to a rendered finding could not be re-derived on this run, so '
365
+ + 'their objects may appear above under a different heading. '
366
+ + `${escapeHtml(why.join(' · '))}</p>`);
367
+ } else {
368
+ lines.push(`<p class="caveat">⚠️ ${total} recorded finding-like object(s) live in containers `
369
+ + `this report does not read, and are NOT included above — ${parts.join(' · ')}. `
370
+ + 'Treat this report as incomplete for those surfaces.</p>');
371
+ }
356
372
  }
357
373
  if (!model.findings.length) {
358
374
  lines.push(`<p>No findings were recorded for the ${c.written} hosts scanned in this run.</p>`);
@@ -549,7 +565,54 @@ function renderFooter(model) {
549
565
  return `<footer>Generated by NSAuditor AI ${escapeHtml(tier)} ${escapeHtml(String(ceVersion))}${eeSuffix}</footer>`;
550
566
  }
551
567
 
568
+ // Names what `brand` actually was, for the refusal below — never called on a plain object.
569
+ // Same voice and job as brand.mjs's own `describeLogoPathType`: a caller told only "must be an
570
+ // object" over a value it cannot see has to open this module to learn what it did wrong.
571
+ function describeBrand(v) {
572
+ if (v === null) return 'null';
573
+ if (v === undefined) return 'undefined';
574
+ if (Array.isArray(v)) return 'an array';
575
+ return `a ${typeof v}`;
576
+ }
577
+
578
+ // ⚠️ REFUSES rather than defaulting, and the choice is the opposite of what "make it total"
579
+ // usually means: this renders a CLIENT-FACING deliverable, so silently normalising an absent
580
+ // brand into an unbranded report is the WORSE failure — it hands the caller a complete,
581
+ // plausible document at exit 0 with the consultant's identity missing, and that is the copy
582
+ // that reaches the client.
583
+ //
584
+ // That is the CC-4 ruling one layer down. `tests/report_command.test.mjs` already pins "a
585
+ // value-less --brand is FATAL, not a silent unbranded default", recorded there because a shell
586
+ // glob that swallowed the path "produced the IDENTICAL unbranded default report, at exit 0, as
587
+ // never asking for a brand at all — silently". An absent argument here is indistinguishable
588
+ // from a lost one for exactly the same reason, and refusing costs the honest caller nothing:
589
+ // "no branding wanted" stays expressible as `{}`, which renders precisely what it rendered
590
+ // before (measured: byte-identical to loadBrand(null)'s defaults, 4579 bytes on the fixture).
591
+ //
592
+ // WHAT WAS MEASURED, over eight brand shapes, before this guard existed: null / undefined / an
593
+ // omitted second argument all threw `TypeError: Cannot read properties of null (reading
594
+ // 'title')` — the loud half. A string, a number, a boolean and an array all RENDERED, 4579
595
+ // bytes, titled "Network Scan Report", with no company line — the quiet half, and the one a
596
+ // caller passing its company NAME where the brand OBJECT belongs would have shipped.
597
+ //
598
+ // A DEFAULTED PARAMETER (`brand = {}`) was rejected: it fires only on `undefined`, so it fixes
599
+ // the omitted-argument call and leaves an explicit `null` — the shape a caller that lost its
600
+ // brand actually produces — crashing exactly as before. The guard is a normalisation check at
601
+ // the single public entry point instead. `renderCover` and `renderBrandBlock` dereference
602
+ // `brand` freely — DERIVED, not counted by hand: 11 dereferences in this module, 7 in
603
+ // renderBrandBlock + 3 in renderCover + this one — and are reachable ONLY through here; that
604
+ // premise is not
605
+ // left to a reading — `tests/executive_report_brand_absent.test.mjs` pins this module's export
606
+ // surface, so exporting either helper fails the build rather than silently opening an
607
+ // unguarded path.
552
608
  export function renderExecutiveReport(model, brand, { renderedAt = new Date() } = {}) {
609
+ if (brand === null || typeof brand !== 'object' || Array.isArray(brand)) {
610
+ throw new TypeError(
611
+ 'renderExecutiveReport: `brand` must be an object — pass the `brand` field of '
612
+ + "loadBrand()'s result, or `{}` for a deliberately unbranded report. It is "
613
+ + `${describeBrand(brand)}. Refusing rather than rendering a client-facing report with `
614
+ + 'the branding silently missing.');
615
+ }
553
616
  const title = escapeHtml(brand.title || 'Network Scan Report');
554
617
 
555
618
  return `<!DOCTYPE html>
@@ -25,6 +25,16 @@
25
25
  // because the network path's failure is a SOURCE the consumer never opens. A fixture
26
26
  // drawn from a producer's field names cannot catch a producer whose output lives in a
27
27
  // different artifact.
28
+ //
29
+ // ⚠️ AND THEN THE ALLOWLIST BECAME THE NEXT INSTANCE OF THE SAME CLASS. Its entries were
30
+ // PROSE, and two of them asserted a FACT about one run rather than a property of a shape —
31
+ // the cisAlarmCoverage entry closed with its own instruction, "re-derive, do not re-assert",
32
+ // and NOTHING RE-DERIVED IT. A carve-out that outlives the fact justifying it excludes real
33
+ // findings from a client deliverable while the census still prints `unread: {}`: a false
34
+ // clean wearing the exit code of a measurement. So an entry is now `{reason, verify}`, and a
35
+ // premise that FAILS or THROWS withdraws the carve-out — the container is counted UNREAD and
36
+ // reported in `premiseFailures` with the detail, because "nobody wrote a reader" and "the
37
+ // reason we wrote no reader stopped being true" need different repairs.
28
38
 
29
39
  /** Containers the report READS. Adding a reader means adding its key here. */
30
40
  export const READ_CONTAINERS = Object.freeze([
@@ -36,38 +46,304 @@ export const READ_CONTAINERS = Object.freeze([
36
46
  ]);
37
47
 
38
48
  /**
39
- * Containers that carry a `severity` but are NOT findings. Each needs a written reason —
40
- * an allowlist entry with no reason is how a real door gets closed by accident.
49
+ * THE CENSUS'S SUBJECT, in one place, because a verifier that vouches for a different set of
50
+ * objects than the census counted is vouching for nothing. Both the walk below and every
51
+ * `verify` use this predicate: it is the DEFINITION of what is being carved out, not a
52
+ * second opinion about it.
53
+ */
54
+ export function isCensusSubject(node) {
55
+ return !!node && typeof node === 'object' && !Array.isArray(node) && typeof node.severity === 'string';
56
+ }
57
+
58
+ const resultsOf = (raw) => (Array.isArray(raw?.results) ? raw.results : []);
59
+ const arrayAt = (o, k) => (Array.isArray(o?.[k]) ? o[k] : []);
60
+
61
+ /** Key-sorted serialisation, so two rows compare by CONTENT and not by key order. */
62
+ function canonical(v) {
63
+ if (Array.isArray(v)) return `[${v.map(canonical).join(',')}]`;
64
+ if (v && typeof v === 'object') {
65
+ return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canonical(v[k])}`).join(',')}}`;
66
+ }
67
+ return JSON.stringify(v ?? null);
68
+ }
69
+
70
+ // ── The premises, one per carve-out ──────────────────────────────────────────
71
+
72
+ /**
73
+ * (i) `result.summary.cisAlarmCoverage.uncovered[]` — the mirror is still 1:1.
74
+ *
75
+ * Derived from the real records, never invented: an uncovered entry is
76
+ * `{id:'cis-3.1', title, severity, coverageMode}` and its paired rendered finding carries
77
+ * BOTH `details.cisId` (with `details.category` = 'cis-alarm-coverage-no-filter') AND an
78
+ * `issues[]` string reading "CloudWatch alarm missing: … (CIS AWS Foundations Benchmark
79
+ * cis-3.1)". Measured over the 152 sample records: 48 plugin results carry an alarm-coverage
80
+ * summary, 624 uncovered entries in total, and BOTH channels independently pair 100% of them.
81
+ * Either channel is therefore accepted — a class the reader receives is not a lost finding,
82
+ * whichever field carried it — and the details channel is scoped to alarm-coverage findings
83
+ * so a `cisId` on an unrelated finding cannot stand in for the mirror.
84
+ */
85
+ function verifyAlarmCoverageStillMirrorsItsFindings(raw) {
86
+ const broken = [];
87
+ for (const e of resultsOf(raw)) {
88
+ const res = e?.result ?? {};
89
+ const uncovered = arrayAt(res?.summary?.cisAlarmCoverage, 'uncovered').filter(isCensusSubject);
90
+ if (!uncovered.length) continue;
91
+
92
+ const rendered = new Set();
93
+ for (const f of arrayAt(res, 'findings')) {
94
+ if (!f || typeof f !== 'object') continue;
95
+ for (const issue of arrayAt(f, 'issues')) {
96
+ if (typeof issue !== 'string' || !/alarms?\s+missing/i.test(issue)) continue;
97
+ for (const m of issue.matchAll(/\bcis-\d+(?:\.\d+)*\b/gi)) rendered.add(m[0].toLowerCase());
98
+ }
99
+ const d = f.details;
100
+ if (d && typeof d.cisId === 'string' && typeof d.category === 'string' && /alarm/i.test(d.category)) {
101
+ rendered.add(d.cisId.toLowerCase());
102
+ }
103
+ }
104
+ for (const u of uncovered) {
105
+ if (typeof u.id !== 'string' || !u.id) {
106
+ broken.push(`plugin ${e?.id ?? '?'}: an uncovered entry with no \`id\` — nothing can pair it`);
107
+ } else if (!rendered.has(u.id.toLowerCase())) {
108
+ broken.push(`plugin ${e?.id ?? '?'}: ${u.id}`);
109
+ }
110
+ }
111
+ }
112
+ return broken.length
113
+ ? { ok: false,
114
+ unreadCount: broken.length,
115
+ detail: `the 1:1 mirror has BROKEN — ${broken.length} uncovered alarm class(es) have no rendered `
116
+ + `"alarm missing" finding, so they reach no reader: ${broken.join(' · ')}` }
117
+ : { ok: true, detail: 'every uncovered alarm class pairs with a rendered finding' };
118
+ }
119
+
120
+ /**
121
+ * (ii) `result.findings[].policyAnalyses[].issues[]` — the parent really is rendered.
122
+ *
123
+ * The carve-out calls the nested issues EVIDENCE FOR a finding that IS rendered. That is
124
+ * true only while the parent carries content of its own: `shapeFinding` titles a finding
125
+ * from its `issues[]` (falling back to `detail`/`description`), and with none of those it
126
+ * renders "(untitled finding)" — utils/report_inputs.mjs:139-151. A parent with no issue of
127
+ * its own is an empty row whose only real content is the nested detail nobody reads, which
128
+ * is the carve-out's own negation. Measured: 104 parents across the sample corpus carry
129
+ * counted policy-analysis issues, and 0 of them lack their own.
130
+ */
131
+ function verifyPolicyAnalysisBacksARenderedFinding(raw) {
132
+ const orphans = [];
133
+ for (const e of resultsOf(raw)) {
134
+ const findings = arrayAt(e?.result, 'findings');
135
+ for (let i = 0; i < findings.length; i += 1) {
136
+ const f = findings[i];
137
+ if (!f || typeof f !== 'object') continue;
138
+ const nested = arrayAt(f, 'policyAnalyses')
139
+ .flatMap((pa) => arrayAt(pa, 'issues'))
140
+ .filter(isCensusSubject);
141
+ if (!nested.length) continue;
142
+ // Presence is not content: an `issues: ['']` renders as no text at all.
143
+ const own = arrayAt(f, 'issues')
144
+ .filter((x) => (typeof x === 'string' ? x.trim().length > 0 : !!x));
145
+ if (!own.length) {
146
+ orphans.push(`plugin ${e?.id ?? '?'} findings[${i}] carries ${nested.length} policy-analysis `
147
+ + 'issue(s) and NO issue of its own');
148
+ }
149
+ }
150
+ }
151
+ return orphans.length
152
+ ? { ok: false,
153
+ unreadCount: orphans.length,
154
+ detail: 'the nested detail is no longer EVIDENCE FOR a rendered finding — the parent renders as '
155
+ + `"(untitled finding)" with nothing in it: ${orphans.join(' · ')}` }
156
+ : { ok: true, detail: 'every policy-analysis parent carries its own issue text' };
157
+ }
158
+
159
+ /**
160
+ * (iii) `result.data[]` — on the cloud path it still MIRRORS `findings[]`.
161
+ *
162
+ * ⚠️ THE PREDICATE IS DELIBERATELY SCOPED TO THE CENSUS'S SUBJECT, AND THAT IS WHAT MAKES
163
+ * THE NETWORK SHAPE PASS WITHOUT AN EXEMPTION. A network host legitimately has
164
+ * `findings[] = 0` and `data[] = 85` — measured on the 192.168.1.1 run, 84 of them
165
+ * `{probe_protocol, probe_port, probe_info}`. Not one carries a `severity`, so not one is a
166
+ * census subject and there is nothing to mirror: the acceptance is STRUCTURAL, not a list of
167
+ * telemetry field names that a producer could quietly step outside. The corollary is
168
+ * intended: a telemetry row that GAINS a severity is a severity-bearing object in a container
169
+ * nobody reads, and it fails.
170
+ *
171
+ * Matching is CONTENT-DEEP within one plugin result. Measured across the sample corpus: 86
172
+ * plugin results carry severity-bearing data rows (1150 and 1170 only, 1075 rows), and every
173
+ * row is deep-equal to a row in the same result's findings[]. A laxer key of
174
+ * severity+resource+issues was measured too — also 0 unmatched — and REJECTED: it collapses
175
+ * to a constant on rows that carry neither `resource` nor `issues`, which is a false-clean
176
+ * generator on exactly the network-ish shape this entry has to keep accepting.
177
+ *
178
+ * One stated limit: the mirror is looked for in `findings[]` in its ARRAY form only. A
179
+ * producer that emitted the 060 DICT-of-categories shape AND severity-bearing data[] would
180
+ * FAIL this premise rather than be excused by it. Nothing does today; failing is the
181
+ * noise-costing direction, and a lax read here would be a carve-out granted on a shape
182
+ * nobody has looked at.
183
+ */
184
+ function verifyDataMirrorsFindings(raw) {
185
+ const unmirrored = [];
186
+ for (const e of resultsOf(raw)) {
187
+ const res = e?.result ?? {};
188
+ const rows = arrayAt(res, 'data').filter(isCensusSubject);
189
+ if (!rows.length) continue;
190
+ const rendered = new Set(arrayAt(res, 'findings')
191
+ .filter((f) => f && typeof f === 'object').map(canonical));
192
+ for (const r of rows) {
193
+ if (!rendered.has(canonical(r))) {
194
+ // Locate it well enough to act on: `resource` alone is not distinguishing — on some
195
+ // producers it holds the REGION, so a dozen rows share it.
196
+ const excerpt = arrayAt(r, 'issues').map((x) => (typeof x === 'string' ? x : x?.detail ?? ''))
197
+ .find((t) => t && t.trim()) ?? '';
198
+ unmirrored.push(`plugin ${e?.id ?? '?'}: ${r.severity.toUpperCase()} `
199
+ + `${r.resource ?? '(no resource)'}${excerpt ? ` — "${excerpt.slice(0, 80)}"` : ''}`);
200
+ }
201
+ }
202
+ }
203
+ return unmirrored.length
204
+ ? { ok: false,
205
+ unreadCount: unmirrored.length,
206
+ detail: `result.data[] is no longer a mirror — ${unmirrored.length} severity-bearing row(s) have no `
207
+ + `content-equal finding in the same plugin result, so nothing renders them: ${unmirrored.join(' · ')}` }
208
+ : { ok: true, detail: 'every severity-bearing data row is mirrored by a rendered finding' };
209
+ }
210
+
211
+ /**
212
+ * (iv) `result.portResults[]` — the roll-up really is a roll-up.
213
+ *
214
+ * The reason claims the entry's severity "summarises the port's own issues[], which ARE
215
+ * read". That is checkable in both directions, and both directions matter:
216
+ * · a severity over an EMPTY issues[] summarises nothing — it is a finding nothing renders,
217
+ * which is the exact hazard the carve-out denies;
218
+ * · a severity ABSENT from its own issues' severities is a roll-up of something the port
219
+ * does not hold.
220
+ * The empty case is not simply forbidden, because 040 seeds the roll-up at SEVERITY.PASS and
221
+ * raises it to the max of the port's issues (plugins/040_tls_cert_auditor.mjs:491-496), so a
222
+ * clean port emits exactly `{severity:'pass', issues:[]}`. PASS is the identity element of
223
+ * this roll-up; failing it would flag every healthy HTTPS port and teach an operator to read
224
+ * past the warning. Measured over 232 records (samples + both out/ trees): 7 portResults
225
+ * entries carry a severity, all 7 hold it among their own issue severities, and none has an
226
+ * empty issues[].
227
+ */
228
+ const ROLLUP_IDENTITY_SEVERITY = 'pass';
229
+
230
+ function verifyPortRollUpSummarisesItsOwnIssues(raw) {
231
+ const bad = [];
232
+ for (const e of resultsOf(raw)) {
233
+ const ports = arrayAt(e?.result, 'portResults');
234
+ for (let i = 0; i < ports.length; i += 1) {
235
+ const pr = ports[i];
236
+ if (!isCensusSubject(pr)) continue;
237
+ const where = `plugin ${e?.id ?? '?'} port ${pr.port ?? `[${i}]`}`;
238
+ const issueSeverities = arrayAt(pr, 'issues').filter(isCensusSubject).map((x) => x.severity);
239
+ if (!issueSeverities.length) {
240
+ if (pr.severity !== ROLLUP_IDENTITY_SEVERITY) {
241
+ bad.push(`${where}: severity "${pr.severity}" over an EMPTY issues[] — it summarises nothing, `
242
+ + 'so nothing renders it');
243
+ }
244
+ continue;
245
+ }
246
+ if (!issueSeverities.includes(pr.severity)) {
247
+ bad.push(`${where}: roll-up severity "${pr.severity}" is not among its own issue severities `
248
+ + `(${[...new Set(issueSeverities)].join(', ')})`);
249
+ }
250
+ }
251
+ }
252
+ return bad.length
253
+ ? { ok: false, unreadCount: bad.length, detail: `the roll-up no longer summarises its own issues[]: ${bad.join(' · ')}` }
254
+ : { ok: true, detail: 'every port roll-up is carried by its own issues' };
255
+ }
256
+
257
+ /**
258
+ * Containers that carry a `severity` but are NOT findings. Each needs a written reason AND,
259
+ * where the reason rests on a checkable fact, a `verify(raw, queue) => {ok, detail}` that
260
+ * re-derives it on every run — an allowlist entry with no reason is how a real door gets
261
+ * closed by accident, and one whose reason nothing re-derives is how a closed door stays
262
+ * shut after the reason has gone.
41
263
  */
42
264
  export const ALLOWLISTED_CONTAINERS = Object.freeze({
43
- 'result.portResults[]':
44
- 'A per-port ROLL-UP, not a finding: its severity summarises the port\'s own '
45
- + '`issues[]`, which ARE read. Rendering both would double-count every TLS issue.',
46
- 'result.summary.cisAlarmCoverage.uncovered[]':
47
- 'A 1:1 SUMMARY MIRROR of 1040\'s own alarm-coverage findings, not a second source. '
48
- + 'Adjudicated on the 0.44.0 cloud run: 13 uncovered classes, 13 "alarm missing" '
49
- + 'findings, and the set difference of their cis-N.N ids is EMPTY. If that ever stops '
50
- + 'holding the mirror has broken and this entry is wrong — re-derive, do not re-assert.',
51
- 'result.findings[].policyAnalyses[].issues[]':
52
- 'Per-statement EVIDENCE nested inside a finding that IS rendered — e.g. "Full admin '
53
- + 'grant: Action \'*\' in policy X" backing a parent whose own issues[] already say '
54
- + '"SHADOW ADMIN: User has full wildcard (*) permissions". Not a missed finding. '
55
- + 'RESIDUAL, stated rather than hidden: the per-statement detail is not surfaced in the '
56
- + 'report at all, which is a DEPTH choice nobody has ruled on, not a coverage gap.',
57
- 'result.data[]':
58
- 'NOT a findings container, and the report must never read it. On a CLOUD host it '
59
- + 'MIRRORS result.findings (verified 0.44.0: 1150 16/16, 1170 9/9). On a NETWORK host '
60
- + 'it is PROBE TELEMETRY — measured on the 192.168.1.1 run, findings[]=0 while '
61
- + 'data[]=85, 84 of them {probe_protocol, probe_port, probe_info} ("Connect refused '
62
- + '(ECONNREFUSED)", "No UDP response"). ⚠️ A `findings ?? data` fallback was written '
63
- + 'here and REVERTED: it put scan telemetry into a client deliverable, hidden because '
64
- + 'the telemetry rows collided on one content hash and collapsed to two. '
65
- + 'cloud_finding_summary\'s findingsOf may use that precedence — it only ever sees '
66
- + 'cloud producers; this consumer sees both paths.',
265
+ 'result.portResults[]': Object.freeze({
266
+ reason:
267
+ 'A per-port ROLL-UP, not a finding: its severity summarises the port\'s own '
268
+ + '`issues[]`, which ARE read. Rendering both would double-count every TLS issue.',
269
+ verify: verifyPortRollUpSummarisesItsOwnIssues,
270
+ }),
271
+ 'result.summary.cisAlarmCoverage.uncovered[]': Object.freeze({
272
+ reason:
273
+ 'A 1:1 SUMMARY MIRROR of 1040\'s own alarm-coverage findings, not a second source. '
274
+ + 'Adjudicated on the 0.44.0 cloud run: 13 uncovered classes, 13 "alarm missing" '
275
+ + 'findings, and the set difference of their cis-N.N ids is EMPTY. That was a fact '
276
+ + 'about one run, so `verify` re-derives it on every run rather than re-asserting it.',
277
+ verify: verifyAlarmCoverageStillMirrorsItsFindings,
278
+ }),
279
+ 'result.findings[].policyAnalyses[].issues[]': Object.freeze({
280
+ reason:
281
+ 'Per-statement EVIDENCE nested inside a finding that IS rendered — e.g. "Full admin '
282
+ + 'grant: Action \'*\' in policy X" backing a parent whose own issues[] already say '
283
+ + '"SHADOW ADMIN: User has full wildcard (*) permissions". Not a missed finding. '
284
+ + 'RESIDUAL, stated rather than hidden: the per-statement detail is not surfaced in the '
285
+ + 'report at all, which is a DEPTH choice nobody has ruled on, not a coverage gap.',
286
+ verify: verifyPolicyAnalysisBacksARenderedFinding,
287
+ }),
288
+ 'result.data[]': Object.freeze({
289
+ reason:
290
+ 'NOT a findings container, and the report must never read it. On a CLOUD host it '
291
+ + 'MIRRORS result.findings (verified 0.44.0: 1150 16/16, 1170 9/9). On a NETWORK host '
292
+ + 'it is PROBE TELEMETRY — measured on the 192.168.1.1 run, findings[]=0 while '
293
+ + 'data[]=85, 84 of them {probe_protocol, probe_port, probe_info} ("Connect refused '
294
+ + '(ECONNREFUSED)", "No UDP response"). ⚠️ A `findings ?? data` fallback was written '
295
+ + 'here and REVERTED: it put scan telemetry into a client deliverable, hidden because '
296
+ + 'the telemetry rows collided on one content hash and collapsed to two. '
297
+ + 'cloud_finding_summary\'s findingsOf may use that precedence — it only ever sees '
298
+ + 'cloud producers; this consumer sees both paths.',
299
+ verify: verifyDataMirrorsFindings,
300
+ }),
67
301
  });
68
302
 
69
303
  const QUEUE_CONTAINER = 'findingQueue[]';
70
304
 
305
+ /**
306
+ * Adjudicate ONE allowlist entry against a record.
307
+ *
308
+ * ⚠️ FAILS CLOSED IN EVERY DIRECTION THAT IS NOT AN EXPLICIT `ok: true`. A verifier that
309
+ * throws has not established the premise; neither has one that returns a shape nobody can
310
+ * read. Both are treated as a broken premise, because the alternative — a thrown TypeError
311
+ * silently restoring the carve-out — is the false clean this whole mechanism exists to
312
+ * close, and it would arrive precisely when a producer changed shape.
313
+ *
314
+ * @returns {string|null} null when the carve-out stands (premise held, or none declared);
315
+ * otherwise the operator-facing detail of why it does not.
316
+ */
317
+ export function adjudicateAllowlistEntry(entry, raw, queue) {
318
+ // An entry that declares no premise keeps its carve-out: a shape nobody can check yet is
319
+ // better carved out loudly, with its reason readable, than checked wrongly. The ratchet
320
+ // that stops "undeclared" becoming the default lives in the tests, not here.
321
+ if (typeof entry?.verify !== 'function') return null;
322
+ let verdict;
323
+ try {
324
+ verdict = entry.verify(raw, queue);
325
+ } catch (err) {
326
+ return { detail: `premise check THREW (${err?.message ?? String(err)}) — a check that cannot `
327
+ + 'run has established nothing, so the carve-out is withdrawn' };
328
+ }
329
+ if (!verdict || typeof verdict !== 'object' || typeof verdict.ok !== 'boolean') {
330
+ return { detail: 'premise check returned no verdict — treated as FAILED, because a check that '
331
+ + 'cannot answer has not established anything' };
332
+ }
333
+ if (verdict.ok) return null;
334
+ // ⚠️ THE COUNT TRAVELS WITH THE DETAIL. This used to return the detail STRING alone, so the
335
+ // caller had a sentence saying "1 row has no mirror" and no number to act on — and fell back to
336
+ // the container's whole population, which is exactly the false quantity the consumer renders.
337
+ // A failure is `{detail, unreadCount}`; `unreadCount` is whatever the verifier claimed and is
338
+ // VALIDATED downstream by resolveUnreadCount, never trusted here.
339
+ return {
340
+ detail: typeof verdict.detail === 'string' && verdict.detail.trim()
341
+ ? verdict.detail
342
+ : 'premise FAILED, with no detail given',
343
+ unreadCount: verdict.unreadCount,
344
+ };
345
+ }
346
+
71
347
  /**
72
348
  * Classify where a severity-bearing object lives. Path is the dotted walk from `result`.
73
349
  * @returns {string} a container key
@@ -91,7 +367,7 @@ function* severityObjects(node, path = '') {
91
367
  if (Array.isArray(node)) {
92
368
  for (let i = 0; i < node.length; i += 1) yield* severityObjects(node[i], `${path}[${i}]`);
93
369
  } else if (node && typeof node === 'object') {
94
- if (typeof node.severity === 'string') yield { path, obj: node };
370
+ if (isCensusSubject(node)) yield { path, obj: node };
95
371
  for (const [k, v] of Object.entries(node)) yield* severityObjects(v, `${path}.${k}`);
96
372
  }
97
373
  }
@@ -102,22 +378,61 @@ function* severityObjects(node, path = '') {
102
378
  * @param {Array} queue the host's finding queue (scan_finding_queue.json, or the
103
379
  * eeEnrichment fallback) — passed in because it is a SIBLING
104
380
  * artifact, not part of `raw`, which is the whole reason it was missed.
105
- * @returns {{byContainer: Object<string, number>, unread: Object<string, number>, total: number}}
381
+ * @returns {{byContainer: Object<string, number>, unread: Object<string, number>,
382
+ * premiseFailures: Object<string, string>, total: number}}
383
+ * `unread` is the operator's answer to "what does this report not read?" and a container
384
+ * lands there for one of TWO reasons; `premiseFailures` says which, keyed on the same
385
+ * container, because the repairs are opposite — an unknown container needs a reader
386
+ * written, a withdrawn carve-out needs its justification re-argued or removed.
387
+ */
388
+ /**
389
+ * HOW MANY OBJECTS A BROKEN PREMISE ACTUALLY LEAVES UNREAD.
390
+ *
391
+ * ⚠️ THE REPAIR FOR ONE FALSE CLEAN INTRODUCED A FALSE QUANTITY, and the number reaches a
392
+ * CUSTOMER. `unread[k]` used to be the container's WHOLE population, and `executive_report.mjs`
393
+ * renders it as "N recorded finding-like object(s) … are NOT included above". For a MIRROR
394
+ * carve-out that breaks PARTIALLY, most of those objects ARE rendered above — plugin 1150 emits
395
+ * `data: findings`, so 16 mirrored rows and one orphan told a client that 17 objects were missing
396
+ * from a report containing 16 of them. **A caveat that contradicts the body of its own document.**
397
+ *
398
+ * ⚠️ AND `unreadCount ?? population` IS NOT ENOUGH — that only catches null/undefined. A verifier
399
+ * returning `0` on a real break, or `"3"`, or a count ABOVE the population, would put a garbage
400
+ * number into a client-facing sentence. Every one of those degrades to the population instead:
401
+ * **over-report, never emit a silent or impossible number.** `0` is deliberately refused rather
402
+ * than trusted — a failure claiming nothing is unread is a verifier bug silencing itself.
106
403
  */
404
+ export function resolveUnreadCount(failure, population) {
405
+ const n = failure?.unreadCount;
406
+ if (typeof n !== 'number' || !Number.isInteger(n) || n <= 0 || n > population) return population;
407
+ return n;
408
+ }
409
+
107
410
  export function censusFindingContainers(raw, queue = []) {
108
411
  const byContainer = {};
109
412
  const bump = (k) => { byContainer[k] = (byContainer[k] || 0) + 1; };
110
413
 
111
- for (const e of (Array.isArray(raw?.results) ? raw.results : [])) {
414
+ for (const e of resultsOf(raw)) {
112
415
  for (const { path } of severityObjects(e?.result ?? {}, '')) bump(containerOf(path));
113
416
  }
114
417
  if (Array.isArray(queue) && queue.length) byContainer[QUEUE_CONTAINER] = queue.length;
115
418
 
116
419
  const unread = {};
420
+ const premiseFailures = {};
117
421
  for (const [k, n] of Object.entries(byContainer)) {
118
422
  if (READ_CONTAINERS.includes(k)) continue;
119
- if (Object.prototype.hasOwnProperty.call(ALLOWLISTED_CONTAINERS, k)) continue;
120
- unread[k] = n;
423
+ if (!Object.prototype.hasOwnProperty.call(ALLOWLISTED_CONTAINERS, k)) { unread[k] = n; continue; }
424
+ // The premise is only adjudicated where something is actually being carved out: running
425
+ // it over a container this record does not carry would report on nothing.
426
+ const failure = adjudicateAllowlistEntry(ALLOWLISTED_CONTAINERS[k], raw, queue);
427
+ if (failure === null) continue;
428
+ // The BREAK, not the container — see resolveUnreadCount for why the count is validated
429
+ // rather than trusted, and why the population stays in `byContainer` where it belongs.
430
+ unread[k] = resolveUnreadCount(failure, n);
431
+ // `premiseFailures` stays a map of container -> DETAIL STRING: it is the operator- and
432
+ // reader-facing half, and the count already lives in `unread`. Putting the verdict OBJECT
433
+ // here would change a published shape for no gain and hand two numbers to a consumer that
434
+ // needs one.
435
+ premiseFailures[k] = failure.detail;
121
436
  }
122
- return { byContainer, unread, total: Object.values(byContainer).reduce((a, b) => a + b, 0) };
437
+ return { byContainer, unread, premiseFailures, total: Object.values(byContainer).reduce((a, b) => a + b, 0) };
123
438
  }
@@ -384,7 +384,14 @@ async function finishLoadingRecord(outRoot, rec, allowPartial) {
384
384
  // container announces itself on the first render, which is how all three of the doors
385
385
  // found tonight SHOULD have been found.
386
386
  const census = censusFindingContainers(raw, findingQueue);
387
- if (Object.keys(census.unread).length) unreadByHost.push({ host, unread: census.unread });
387
+ // ⚠️ `premiseFailures` TRAVELS WITH THE COUNTS, because the two reasons a container is
388
+ // unread need DIFFERENT sentences. A container nobody wrote a reader for is genuinely NOT
389
+ // IN the report; a carve-out whose premise BROKE has most of its objects rendered and only
390
+ // the break unread. Handing the consumer one number and no reason is what let the report
391
+ // tell a client that 17 objects were missing from a document containing 16 of them.
392
+ if (Object.keys(census.unread).length) {
393
+ unreadByHost.push({ host, unread: census.unread, premiseFailures: census.premiseFailures });
394
+ }
388
395
  hosts.push(shapeHost(host, dir, { ...raw, __findingQueue: findingQueue }));
389
396
  }
390
397