@clear-capabilities/agentic-security-scanner 0.143.0 → 0.145.0

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.
Files changed (135) hide show
  1. package/CHANGELOG.md +555 -0
  2. package/bin/agentic-security.js +770 -49
  3. package/dist/1.index.js +223 -0
  4. package/dist/113.index.js +117 -18
  5. package/dist/144.index.js +163 -0
  6. package/dist/178.index.js +24 -6
  7. package/dist/238.index.js +3 -2
  8. package/dist/265.index.js +191 -0
  9. package/dist/271.index.js +165 -0
  10. package/dist/384.index.js +1 -1
  11. package/dist/435.index.js +187 -52
  12. package/dist/444.index.js +11 -2
  13. package/dist/449.index.js +76 -12
  14. package/dist/526.index.js +117 -18
  15. package/dist/552.index.js +97 -0
  16. package/dist/637.index.js +27 -5
  17. package/dist/730.index.js +311 -0
  18. package/dist/736.index.js +301 -0
  19. package/dist/824.index.js +7 -0
  20. package/dist/905.index.js +88 -22
  21. package/dist/920.index.js +491 -0
  22. package/dist/970.index.js +173 -0
  23. package/dist/agentic-security.mjs +13 -13
  24. package/dist/agentic-security.mjs.sha256 +1 -1
  25. package/dist/calibration-seed.json +2 -0
  26. package/package.json +23 -9
  27. package/src/compare.js +6 -1
  28. package/src/dataflow/CLAUDE.md +1 -1
  29. package/src/dataflow/index.js +18 -0
  30. package/src/dataflow/privacy-catalog.js +290 -0
  31. package/src/dataflow/privacy-deep-walker.js +515 -0
  32. package/src/dataflow/privacy-governance.js +126 -0
  33. package/src/dataflow/privacy-inventory.js +154 -0
  34. package/src/dataflow/privacy-sink-policy.js +125 -0
  35. package/src/dataflow/privacy-taint.js +115 -54
  36. package/src/dataflow/privacy-taxonomy.js +233 -0
  37. package/src/discovery/disprove.js +7 -3
  38. package/src/discovery/hunter.js +9 -5
  39. package/src/discovery/index.js +2 -2
  40. package/src/discovery/llm-invoke.js +69 -13
  41. package/src/egress/audit.js +147 -0
  42. package/src/egress/policy.js +313 -0
  43. package/src/egress/redact.js +180 -0
  44. package/src/engine.js +1048 -302
  45. package/src/fix/apply-fix-service.js +404 -0
  46. package/src/fix/approver-registry.js +157 -0
  47. package/src/history-scan.js +22 -5
  48. package/src/ir/CLAUDE.md +1 -1
  49. package/src/llm-validator/index.js +86 -9
  50. package/src/llm-validator/model-status.js +66 -0
  51. package/src/lsp/server.js +49 -2
  52. package/src/mcp/tools.js +177 -50
  53. package/src/pipeline/analyzer-supervisor.js +93 -0
  54. package/src/pipeline/analyzer-worker.js +26 -0
  55. package/src/pipeline/annotator-runner.js +33 -0
  56. package/src/pipeline/assurance-mode.js +154 -0
  57. package/src/pipeline/cascade-worker-pool.js +172 -0
  58. package/src/pipeline/cascade-worker.js +43 -0
  59. package/src/pipeline/coverage-ledger.js +0 -0
  60. package/src/pipeline/detector-runner.js +51 -0
  61. package/src/pipeline/enrichment-completion.js +58 -0
  62. package/src/pipeline/evidence-provenance.js +91 -0
  63. package/src/pipeline/finding-schema.js +108 -0
  64. package/src/pipeline/legacy-compat.js +101 -0
  65. package/src/pipeline/producer-collector.js +48 -0
  66. package/src/pipeline/producer-registry.js +112 -0
  67. package/src/pipeline/scan-health.js +144 -0
  68. package/src/posture/CLAUDE.md +123 -0
  69. package/src/posture/accuracy-scorecard.js +156 -1
  70. package/src/posture/adversary-agent.js +15 -3
  71. package/src/posture/artifact-registry.js +241 -0
  72. package/src/posture/auditor-walkthrough.js +186 -21
  73. package/src/posture/calibration-feedback.js +201 -0
  74. package/src/posture/calibration-seed.json +2 -0
  75. package/src/posture/calibration.js +25 -0
  76. package/src/posture/compliance-evidence-signing.js +131 -0
  77. package/src/posture/compliance-policy.js +324 -17
  78. package/src/posture/cross-repo-memory.js +7 -2
  79. package/src/posture/custom-rules.js +36 -0
  80. package/src/posture/deterministic.js +8 -1
  81. package/src/posture/encryption-provider.js +205 -0
  82. package/src/posture/evidence-grade-wording.js +71 -0
  83. package/src/posture/fix-history.js +137 -20
  84. package/src/posture/fix-honesty-gate.js +47 -6
  85. package/src/posture/fix-verify.js +65 -8
  86. package/src/posture/fleet.js +0 -0
  87. package/src/posture/flow-narration.js +7 -2
  88. package/src/posture/git-history.js +13 -5
  89. package/src/posture/legal-hold.js +140 -0
  90. package/src/posture/llm-redteam.js +10 -1
  91. package/src/posture/material-change.js +111 -2
  92. package/src/posture/mttr.js +75 -12
  93. package/src/posture/policy-bundle.js +274 -0
  94. package/src/posture/pre-incident-archaeology.js +39 -7
  95. package/src/posture/privacy-framework.js +47 -6
  96. package/src/posture/production-feedback.js +179 -0
  97. package/src/posture/provenance/ai-authorship.js +68 -0
  98. package/src/posture/provenance/branch-entry.js +80 -0
  99. package/src/posture/provenance/cache.js +143 -0
  100. package/src/posture/provenance/confidence.js +36 -0
  101. package/src/posture/provenance/coordinator.js +786 -0
  102. package/src/posture/provenance/dag-walk.js +249 -0
  103. package/src/posture/provenance/evidence-attribution.js +59 -0
  104. package/src/posture/provenance/git-evidence.js +310 -0
  105. package/src/posture/provenance/lifecycle.js +208 -0
  106. package/src/posture/provenance/missing-control-resolver.js +137 -0
  107. package/src/posture/provenance/origin-resolver.js +342 -0
  108. package/src/posture/provenance/predicate-replay.js +133 -0
  109. package/src/posture/provenance/providers/config.js +39 -0
  110. package/src/posture/provenance/providers/github.js +62 -0
  111. package/src/posture/provenance/providers/gitlab.js +58 -0
  112. package/src/posture/provenance/repo-lineage.js +74 -0
  113. package/src/posture/provenance/sca-origin.js +139 -0
  114. package/src/posture/provenance/schema.js +255 -0
  115. package/src/posture/provenance/transitive-sca.js +147 -0
  116. package/src/posture/provenance/validate.js +30 -0
  117. package/src/posture/provenance-evidence-bundle.js +144 -0
  118. package/src/posture/retention-policy.js +132 -0
  119. package/src/posture/risk-dollars.js +216 -26
  120. package/src/posture/sbom-diff.js +15 -2
  121. package/src/posture/scan-checkpoint.js +176 -31
  122. package/src/posture/secret-history.js +10 -2
  123. package/src/posture/state-dir.js +64 -5
  124. package/src/posture/state-lifecycle-report.js +77 -0
  125. package/src/posture/suppressions.js +59 -3
  126. package/src/posture/vuln-archaeology.js +8 -2
  127. package/src/pr-delta.js +25 -4
  128. package/src/privacy/ir-adapter.js +380 -0
  129. package/src/report/index.js +248 -5
  130. package/src/report/oscal.js +7 -2
  131. package/src/runScan.js +34 -5
  132. package/src/sast/cpp.js +3 -14
  133. package/src/sast/rate-limit.js +33 -3
  134. package/src/sca/llm-function-extract.js +6 -0
  135. package/src/util/git-hardening.js +128 -0
@@ -25,6 +25,7 @@
25
25
  // `unverified-no-llm-endpoint` and the transcript records only the seed input.
26
26
 
27
27
  import * as crypto from 'node:crypto';
28
+ import { evaluateEgress } from '../egress/policy.js';
28
29
 
29
30
  const MAX_CALLS_DEFAULT = 50;
30
31
  const MAX_WALL_MS_DEFAULT = 15 * 60 * 1000;
@@ -170,12 +171,23 @@ export async function runAgent(finding, opts = {}) {
170
171
  const transcript = startTranscript(finding, opts.target);
171
172
  const budget = { maxCalls: opts.maxCalls, maxWallMs: opts.maxWallMs };
172
173
 
173
- const llmInvoke = opts.llmInvoke || (process.env.AGENTIC_SECURITY_LLM_ENDPOINT ? defaultLlmInvoke : null);
174
+ // FR-601: evaluated before defaultLlmInvoke is even selected, so a denial
175
+ // means its transcript-to-prompt construction (inside defaultLlmInvoke,
176
+ // called later in the loop below) never runs at all.
177
+ let egressDecision = null;
178
+ let llmInvoke = opts.llmInvoke || null;
179
+ if (!llmInvoke && process.env.AGENTIC_SECURITY_LLM_ENDPOINT) {
180
+ egressDecision = evaluateEgress({ scanRoot: opts.scanRoot, purpose: 'adversary-agent', endpoint: process.env.AGENTIC_SECURITY_LLM_ENDPOINT });
181
+ if (egressDecision.allowed) llmInvoke = defaultLlmInvoke;
182
+ }
174
183
  const executeTool = opts.executeTool || (transcript.target ? (call) => defaultExecuteTool(call, transcript) : null);
175
184
 
176
185
  if (typeof llmInvoke !== 'function' || typeof executeTool !== 'function') {
177
- appendEntry(transcript, { phase: 'init', reason: 'no llmInvoke/executeTool supplied and AGENTIC_SECURITY_LLM_ENDPOINT not set' });
178
- return { transcript, outcome: 'unverified-no-llm-endpoint' };
186
+ const reason = (egressDecision && !egressDecision.allowed)
187
+ ? `egress policy denied this call: ${egressDecision.reason}`
188
+ : 'no llmInvoke/executeTool supplied and AGENTIC_SECURITY_LLM_ENDPOINT not set';
189
+ appendEntry(transcript, { phase: 'init', reason, egressDecision: egressDecision || undefined });
190
+ return { transcript, outcome: 'unverified-no-llm-endpoint', egressDecision };
179
191
  }
180
192
 
181
193
  let outcome = null;
@@ -0,0 +1,241 @@
1
+ // State artifact registry (assurance-hardening PRD, Milestone 0/1, FR-701/FR-703).
2
+ //
3
+ // `bin/agentic-security.js`'s `cmdReset` used to delete from two hardcoded
4
+ // Sets (WIPE / WIPE_DIRS) — an enumeration that had drifted badly behind the
5
+ // state artifacts this codebase actually writes under `.agentic-security/`.
6
+ // This module is the registry FR-701 asks for: every known artifact, with an
7
+ // explicit classification, built by auditing every `statePath(...)` call
8
+ // site in `src/` and `bin/` (not guessed from filenames — several looked
9
+ // like generated output by name but turned out, on reading their actual
10
+ // read/write call sites, to be operator- or agent-authored INPUT).
11
+ //
12
+ // Two classifications:
13
+ // - 'generated': written by the scanner itself, safe to delete — the next
14
+ // scan/command regenerates it. `cmdReset` removes these by default.
15
+ // - 'operator-config': hand-authored (or agent-authored, for
16
+ // logic-claims.json) input the scanner only reads. Deleting it on reset
17
+ // would be data loss, not cleanup — `cmdReset` always preserves these.
18
+ //
19
+ // Corrections this audit made to the assurance-hardening PRD's own evidence
20
+ // table (A-10), which had assumed these were straightforwardly "missing from
21
+ // the wipe list, therefore should be added": `logic-claims.json` is read-only
22
+ // from engine.js (an external reviewing agent authors it); `current-intent.md`
23
+ // has no writer anywhere in src/ or bin/ (developer-authored);
24
+ // `exploit-history.jsonl`'s own header comment calls it an "operator-curated
25
+ // record"; `cve-alerts.json`'s own header comment calls it "Configuration...
26
+ // read from"; `network-policy.json` is documented as an Inputs-section
27
+ // artifact in network-policy-import.js. All five are classified
28
+ // 'operator-config' here — the registry closes the reset-completeness gap
29
+ // without introducing a NEW data-loss bug in the process.
30
+ //
31
+ // This module implements FR-701 (this registry) and FR-703 (registry-driven
32
+ // reset). FR-702 (TTL by artifact class) is implemented here too, as an
33
+ // additive `retentionClass` field: 'cache' | 'scan' | 'evidence' | 'ticket'
34
+ // | 'backup' | undefined, matching the acceptance criterion's own named
35
+ // list verbatim ("expired caches, scans, evidence, tickets, and backups").
36
+ // DELIBERATELY CONSERVATIVE: only entries that unambiguously fit one of
37
+ // those five categories carry a class. Ongoing accumulated state whose
38
+ // deletion would be a real loss rather than cleanup — calibration data
39
+ // (validator-metrics.json, triage-feedback.json), the continual-learning
40
+ // memory file (AGENTS.md), daemon dedup state (cve-alerts-state.json), the
41
+ // gamification streak counter, an operator-set regression baseline
42
+ // (baseline.json, set via --set-baseline — functionally closer to
43
+ // operator intent than scanner output even though it is written by the
44
+ // scanner) — are left with NO retention class rather than forced into the
45
+ // nearest-sounding bucket. A class-less 'generated' artifact is completely
46
+ // unaffected by FR-702's enforcement; it is still deleted unconditionally
47
+ // by an ordinary `reset` (FR-703's own behavior, unchanged).
48
+ //
49
+ // See retention-policy.js for the default/max TTL values per class, the
50
+ // optional operator-override file, and the actual expiry check — kept in
51
+ // a separate module rather than grown into this one, the same "registry
52
+ // vs. policy" separation this session's egress/policy.js and
53
+ // compliance-policy.js already establish.
54
+ //
55
+ // FR-706 (export/deletion manifests) is implemented in
56
+ // posture/state-lifecycle-report.js, consuming this registry directly (an
57
+ // export walks the FULL registry, not just the 'generated' half `reset`
58
+ // acts on — see that module's header).
59
+ //
60
+ // FR-705 (encryption of confidential state classes): an additive
61
+ // `confidential: true` field, enforced by posture/encryption-provider.js.
62
+ // DELIBERATELY CONSERVATIVE, Phase 1 of a staged rollout (see that
63
+ // module's own header for the full rationale): only `compliance-
64
+ // evidence.json`/`.md` are marked in this pass — both have exactly one,
65
+ // well-isolated writer function (compliance-policy.js's emitEvidenceJsonLd/
66
+ // emitEvidenceMarkdown) and zero other production readers besides that
67
+ // same module and the verify-attestation CLI path, both already updated to
68
+ // transparently decrypt. Candidates explicitly DEFERRED, with reasons:
69
+ // `mcp-audit.log`/`egress-audit.log` (hash-CHAINED NDJSON — each entry's
70
+ // hash covers the previous entry, so per-write whole-file encryption would
71
+ // need the chain to be computed over plaintext before encrypting, a real
72
+ // design question left for its own pass), `dpia.md`/`ropa.md`/`data-
73
+ // inventory.json` (written via engine.js's own internal helper, a larger
74
+ // blast radius to verify safely), `last-scan.json`/`findings.json` (read
75
+ // directly as plain JSON by dozens of commands — encrypting these needs a
76
+ // decrypt-on-read hook at every one of those call sites, a much larger,
77
+ // separate migration in the same spirit as E2's own deferred scope).
78
+
79
+ export const ARTIFACT_REGISTRY = [
80
+ // ── Generated: scan output, caches, and system-maintained ledgers ──────
81
+ { name: 'validator-metrics.json', kind: 'file', classification: 'generated' },
82
+ { name: 'triage-feedback.json', kind: 'file', classification: 'generated' },
83
+ { name: 'scan-history.json', kind: 'file', classification: 'generated', retentionClass: 'scan' },
84
+ { name: 'last-scan.json', kind: 'file', classification: 'generated', retentionClass: 'scan' },
85
+ { name: 'last-scan.json.sig', kind: 'file', classification: 'generated', retentionClass: 'scan' },
86
+ { name: 'shadow-findings.json', kind: 'file', classification: 'generated', retentionClass: 'scan' },
87
+ { name: 'mcp-audit.log', kind: 'file', classification: 'generated', retentionClass: 'evidence' },
88
+ { name: 'egress-audit.log', kind: 'file', classification: 'generated', retentionClass: 'evidence', note: "FR-604 per-call egress audit log — hash-chained NDJSON written by egress/audit.js's recordEgressCall, never read as config" },
89
+ { name: 'hook-throttle.json', kind: 'file', classification: 'generated', retentionClass: 'cache' },
90
+ { name: 'tickets.json', kind: 'file', classification: 'generated', retentionClass: 'ticket' },
91
+ { name: 'streak.json', kind: 'file', classification: 'generated' },
92
+ { name: 'findings.json', kind: 'file', classification: 'generated', retentionClass: 'scan' },
93
+ { name: 'findings.sarif', kind: 'file', classification: 'generated', retentionClass: 'scan' },
94
+ { name: 'findings.csv', kind: 'file', classification: 'generated', retentionClass: 'scan' },
95
+ { name: 'llm-cache', kind: 'dir', classification: 'generated', retentionClass: 'cache' },
96
+ { name: 'fix-history', kind: 'dir', classification: 'generated', retentionClass: 'backup' },
97
+ { name: 'fix-plans', kind: 'dir', classification: 'generated', retentionClass: 'scan' },
98
+ // The following were confirmed missing from the old hardcoded WIPE/
99
+ // WIPE_DIRS sets (A-10) and confirmed GENERATED by reading their write
100
+ // call sites (engine.js, or the module named in `source`).
101
+ { name: 'dpia.md', kind: 'file', classification: 'generated', retentionClass: 'evidence', source: 'engine.js (_safeWriteState)' },
102
+ { name: 'ropa.md', kind: 'file', classification: 'generated', retentionClass: 'evidence', source: 'engine.js (_safeWriteState) — FR-407 RoPA scaffold, dataflow/privacy-governance.js' },
103
+ { name: 'data-inventory.json', kind: 'file', classification: 'generated', retentionClass: 'evidence', source: 'engine.js (_safeWriteState) — FR-406 code-derived data inventory, dataflow/privacy-inventory.js' },
104
+ { name: 'data-flow-graph.md', kind: 'file', classification: 'generated', retentionClass: 'evidence', source: 'engine.js (_safeWriteState) — FR-406 mermaid flow graph, dataflow/privacy-inventory.js' },
105
+ { name: 'privacy-framework.json', kind: 'file', classification: 'generated', retentionClass: 'evidence', source: 'posture/privacy-framework.js' },
106
+ { name: 'privacy-framework.md', kind: 'file', classification: 'generated', retentionClass: 'evidence', source: 'posture/privacy-framework.js' },
107
+ { name: 'ifds-summaries.json', kind: 'file', classification: 'generated', retentionClass: 'cache', source: 'dataflow/ifds-precise.js (cache)' },
108
+ { name: 'exploit-bundles.json', kind: 'file', classification: 'generated', retentionClass: 'scan', source: 'engine.js (_safeWriteState)' },
109
+ { name: 'cve-alerts-state.json', kind: 'file', classification: 'generated', source: 'posture/cve-alert-daemon.js' },
110
+ { name: 'compliance-evidence.json', kind: 'file', classification: 'generated', retentionClass: 'evidence', confidential: true, source: 'posture/compliance-policy.js' },
111
+ { name: 'compliance-evidence.md', kind: 'file', classification: 'generated', retentionClass: 'evidence', confidential: true, source: 'posture/compliance-policy.js' },
112
+ { name: 'ATTRIBUTIONS.md', kind: 'file', classification: 'generated', retentionClass: 'scan', source: 'posture/license-attributions.js' },
113
+ { name: 'NOTICE', kind: 'file', classification: 'generated', retentionClass: 'scan', source: 'posture/license-attributions.js' },
114
+ { name: 'accepted.json', kind: 'file', classification: 'generated', source: 'posture/suppressions.js (soft-accept save path)', note: 'already self-managing per-entry expiry (FR-1004-adjacent) — no additional class-level TTL' },
115
+ { name: 'triage.json', kind: 'file', classification: 'generated', source: 'posture/triage.js (_save)' },
116
+ { name: 'pqc-migration-plan.json', kind: 'file', classification: 'generated', retentionClass: 'scan', source: 'posture/pqc-migration-plan.js' },
117
+ { name: 'pqc-migration-plan.md', kind: 'file', classification: 'generated', retentionClass: 'scan', source: 'posture/pqc-migration-plan.js' },
118
+ // controls.json lives under compliance/<framework>/ — registering the
119
+ // parent directory covers it and any other per-framework artifact.
120
+ { name: 'compliance', kind: 'dir', classification: 'generated', retentionClass: 'evidence', source: 'posture/auditor-walkthrough.js' },
121
+ { name: 'attestations', kind: 'dir', classification: 'generated', retentionClass: 'evidence', source: 'posture/evidence-bundle.js' },
122
+ { name: 'auditor-walkthroughs', kind: 'dir', classification: 'generated', retentionClass: 'evidence' },
123
+ { name: 'incremental', kind: 'dir', classification: 'generated', retentionClass: 'cache', source: 'dataflow/incremental.js (cache)' },
124
+ { name: 'model-rescan', kind: 'dir', classification: 'generated', retentionClass: 'scan' },
125
+ { name: 'sca-upgrade-history', kind: 'dir', classification: 'generated', retentionClass: 'scan' },
126
+ { name: 'scan-baselines', kind: 'dir', classification: 'generated', retentionClass: 'scan', source: 'posture/pr-augment.js' },
127
+ { name: 'agent-scratchpad', kind: 'dir', classification: 'generated', retentionClass: 'cache', source: 'mcp/tools.js (append_scratchpad)' },
128
+ // Finding Provenance (M0/M1, split per PRD Section 8 retention task). Used
129
+ // to be one directory with two writers sharing it, which meant they could
130
+ // not get different retention treatment — `cmdReset`/`findExpiredArtifacts`
131
+ // only ever operate on exact TOP-LEVEL `.agentic-security/` directory
132
+ // names, never on a `/`-qualified sub-path. Now physically split:
133
+ // - posture/provenance/cache.js writes provenance-cache/<hash>.json — a
134
+ // pure HEAD-keyed memo of resolved origins, safely regenerable, no
135
+ // correctness dependency on being preserved. Gets retentionClass:
136
+ // 'cache' (7-day default / 30-day max TTL, RETENTION_DEFAULTS.cache).
137
+ // - posture/provenance/lifecycle.js writes provenance/lifecycle.json +
138
+ // .lock — the introduce/remediate/reintroduce ledger. Deliberately NO
139
+ // retentionClass: this is permanent history, not a cache; auto-expiring
140
+ // it would silently lose lifecycle events a report may already have
141
+ // cited. `reset` (without `--expired`) still clears it, which is
142
+ // explicit operator action, unlike TTL-driven auto-expiry.
143
+ // Old-location cache files (`provenance/cache/*.json`, written before this
144
+ // split) are DELIBERATELY NOT migrated — see cache.js's own comment and the
145
+ // commit that introduced this split. They are simply orphaned: invisible to
146
+ // this registry, un-swept by reset/retention, silently ignored by the new
147
+ // code, and harmless to leave until a human deletes them by hand.
148
+ { name: 'provenance-cache', kind: 'dir', classification: 'generated', retentionClass: 'cache', source: 'posture/provenance/cache.js -- pure HEAD-keyed memo, safely regenerable, no correctness dependency on being preserved' },
149
+ { name: 'provenance', kind: 'dir', classification: 'generated', source: 'posture/provenance/lifecycle.js -- the introduce/remediate/reintroduce ledger. Deliberately NO retentionClass: this is permanent history, not a cache; auto-expiring it would silently lose lifecycle events a report may already have cited.' },
150
+ { name: 'AGENTS.md', kind: 'file', classification: 'generated', source: 'posture/agents-memory.js' },
151
+ { name: 'AGENTS.md.archive', kind: 'file', classification: 'generated', source: 'posture/agents-memory.js' },
152
+ { name: 'baseline.json', kind: 'file', classification: 'generated', source: 'bin/agentic-security.js (--set-baseline)', note: 'operator-set intent, functionally closer to operator-config than scan output — no auto-expiry' },
153
+ // These two were found by the completeness guard (test/artifact-registry-
154
+ // completeness.test.js) to have a read call site (leaderboard.js,
155
+ // posture/findings-memory.js respectively) but NO writer anywhere in src/
156
+ // or bin/ — likely dead/aspirational read paths from a scan-history
157
+ // storage scheme that was refactored away. Registered as 'generated'
158
+ // rather than left unclassified: nothing about a per-scan history log/
159
+ // directory suggests hand-authored config, so if a future change starts
160
+ // writing either, the safe default (delete on reset, like scan-history.json)
161
+ // is already in place rather than accidentally falling to operator-config.
162
+ { name: 'scan-history.jsonl', kind: 'file', classification: 'generated', retentionClass: 'scan', note: 'no current writer found — see completeness-guard test comment' },
163
+ { name: 'scan-history', kind: 'dir', classification: 'generated', retentionClass: 'scan', note: 'no current writer found — see completeness-guard test comment' },
164
+
165
+ // FR-706: the last-action proof artifacts `reset` and `export` write —
166
+ // see posture/state-lifecycle-report.js's header for why each is a single
167
+ // overwritten "last action" file rather than an ever-growing log.
168
+ { name: 'deletion-report.json', kind: 'file', classification: 'generated', retentionClass: 'evidence', source: 'posture/state-lifecycle-report.js (via bin/agentic-security.js cmdReset)' },
169
+ { name: 'export-report.json', kind: 'file', classification: 'generated', retentionClass: 'evidence', source: 'posture/state-lifecycle-report.js (via bin/agentic-security.js cmdExport)' },
170
+
171
+ // ── Operator-config: hand-authored (or agent-authored) input, never wiped ──
172
+ { name: 'rules.yml', kind: 'file', classification: 'operator-config' },
173
+ { name: 'rules', kind: 'dir', classification: 'operator-config' },
174
+ { name: 'rules-proposed', kind: 'dir', classification: 'operator-config', note: 'proposed rules awaiting human review — not yet approved config, but not scanner-regenerable either' },
175
+ { name: 'license-policy.yml', kind: 'file', classification: 'operator-config' },
176
+ { name: 'trusted-keys.json', kind: 'file', classification: 'operator-config' },
177
+ { name: 'ruleset-version.json', kind: 'file', classification: 'operator-config', note: 'pinning intent, hand-set' },
178
+ { name: 'risk-config.yml', kind: 'file', classification: 'operator-config' },
179
+ { name: 'egress-policy.yml', kind: 'file', classification: 'operator-config', note: 'FR-601 egress policy (mode: allow/deny/local-only, allowedProviders/deniedProviders) — read by egress/policy.js, never written by the scanner' },
180
+ { name: 'integrations.yml', kind: 'file', classification: 'operator-config' },
181
+ { name: 'profile.yml', kind: 'file', classification: 'operator-config' },
182
+ { name: 'sca-policy.yml', kind: 'file', classification: 'operator-config' },
183
+ { name: 'suppressions.yml', kind: 'file', classification: 'operator-config', note: 'audit-tier suppression config; only ever loaded, never saved, by posture/suppressions.js' },
184
+ { name: 'network-policy.json', kind: 'file', classification: 'operator-config', note: 'documented as an Inputs-section artifact in posture/network-policy-import.js, not a scanner-written digest' },
185
+ { name: 'privacy-taxonomy.json', kind: 'file', classification: 'operator-config', note: 'FR-402 privacy data-classification taxonomy overrides/additions — read by dataflow/privacy-taxonomy.js, never written by the scanner' },
186
+ { name: 'privacy-policy.json', kind: 'file', classification: 'operator-config', note: 'FR-404 privacy sink policy (which class-to-sink flows are explicitly permitted) — read by dataflow/privacy-sink-policy.js, never written by the scanner' },
187
+ { name: 'privacy-governance.json', kind: 'file', classification: 'operator-config', note: 'FR-407 DPIA/RoPA governance field overrides (purpose, lawful basis, retention, etc.) — read by dataflow/privacy-governance.js, never written by the scanner' },
188
+ { name: 'compliance-severity-policy.json', kind: 'file', classification: 'operator-config', note: 'FR-502 per-framework/default open-finding severity threshold override — read by posture/auditor-walkthrough.js, never written by the scanner' },
189
+ { name: 'authorized-approvers.json', kind: 'file', classification: 'operator-config', note: 'FR-1002 identity/role registry for high-impact fix approvals — read by fix/approver-registry.js, never written by the scanner' },
190
+ { name: 'policy-bundles', kind: 'dir', classification: 'operator-config', note: 'FR-1001 signed organization/repository/environment policy bundles (organization.json/repository.json/environment.json) — distributed by an org and placed by the operator, read by posture/policy-bundle.js, never written by the scanner' },
191
+ { name: 'policy-bundle-public-key.pem', kind: 'file', classification: 'operator-config', note: 'FR-1001 public key an operator installs to verify org-distributed policy bundles — read by posture/policy-bundle.js, never written by the scanner' },
192
+ { name: 'retention-policy.yml', kind: 'file', classification: 'operator-config', note: 'FR-702 per-retention-class TTL overrides (clamped to a built-in per-class maximum) — read by posture/retention-policy.js, never written by the scanner' },
193
+ { name: 'legal-holds.json', kind: 'file', classification: 'operator-config', note: 'FR-707 legal holds ({artifact, owner, reason, expires_at}) — read by posture/retention-policy.js and bin/agentic-security.js cmdReset; WRITTEN by the CLI (legal-hold add/remove), but classified operator-config (not generated) deliberately: a plain `reset` must never be able to delete the very record protecting other artifacts from deletion' },
194
+ { name: 'calibration-feedback.jsonl', kind: 'file', classification: 'operator-config', note: 'FR-806 opt-in calibration ground truth ({at, findingId, outcome: accept-risk|realized-incident, predicted*, note}) — WRITTEN by the CLI (calibration-feedback record), but classified operator-config like exploit-history.jsonl: real, hard-to-recreate customer-reported ground truth, never scanner-regenerable, so a routine reset must never delete it' },
195
+ { name: 'encryption-policy.yml', kind: 'file', classification: 'operator-config', note: 'FR-705 encryption provider/required opt-in policy ({provider: local-key, required: true|false}) — read by posture/encryption-provider.js, never written by the scanner' },
196
+ { name: 'provenance-providers.yml', kind: 'file', classification: 'operator-config', note: 'Finding Provenance M3 §3.4 GitHub/GitLab provider enrichment opt-in ({token} or provider-scoped tokens) — read by posture/provenance/providers/config.js, never written by the scanner; env vars (AGENTIC_SECURITY_GITHUB_TOKEN/AGENTIC_SECURITY_GITLAB_TOKEN) take precedence when set' },
197
+ { name: 'repo-lineage.json', kind: 'file', classification: 'operator-config', note: 'Finding Provenance M4 §4.2 cross-repository lineage declaration ({linkedFrom: {path, atCommit}}) — read by posture/provenance/repo-lineage.js, never written by the scanner; the linked path is verified as a real local git repo before use, no remote fetch' },
198
+ { name: 'logic-claims.json', kind: 'file', classification: 'operator-config', note: 'authored by an external reviewing agent; engine.js only ever reads it (fs.readFileSync, never written)' },
199
+ { name: 'current-intent.md', kind: 'file', classification: 'operator-config', note: 'developer-authored; no writer exists anywhere in src/ or bin/' },
200
+ { name: 'exploit-history.jsonl', kind: 'file', classification: 'operator-config', note: 'own header comment: "operator-curated record of past confirmed exploits"' },
201
+ { name: 'cve-alerts.json', kind: 'file', classification: 'operator-config', note: 'own header comment: "Configuration is read from"; state lives in the separate cve-alerts-state.json, which IS generated' },
202
+ ];
203
+
204
+ export function listGeneratedArtifacts() {
205
+ return ARTIFACT_REGISTRY.filter(a => a.classification === 'generated');
206
+ }
207
+
208
+ export function listOperatorConfigArtifacts() {
209
+ return ARTIFACT_REGISTRY.filter(a => a.classification === 'operator-config');
210
+ }
211
+
212
+ export function isRegisteredArtifact(name) {
213
+ return ARTIFACT_REGISTRY.some(a => a.name === name);
214
+ }
215
+
216
+ export function classificationOf(name) {
217
+ return ARTIFACT_REGISTRY.find(a => a.name === name)?.classification ?? null;
218
+ }
219
+
220
+ // FR-705: is this artifact marked as containing sensitive content an
221
+ // operator may want encrypted at rest? Deliberately a SEPARATE flag from
222
+ // classification/retentionClass — confidentiality is about content
223
+ // sensitivity, not about who writes it or how long it lives. See
224
+ // encryption-provider.js for the enforcement side (the fail-closed gate
225
+ // this flag feeds) and its own header for which artifacts are marked here
226
+ // in this first, deliberately conservative pass, and why others are not.
227
+ export function confidentialOf(name) {
228
+ return ARTIFACT_REGISTRY.find(a => a.name === name)?.confidential === true;
229
+ }
230
+
231
+ // FR-702: which retention class (if any) governs this artifact's TTL. Only
232
+ // 'generated' artifacts can carry one — an 'operator-config' entry is never
233
+ // auto-expired regardless of what this returns (retention-policy.js's own
234
+ // caller enforces that ordering, not this function).
235
+ export function retentionClassOf(name) {
236
+ return ARTIFACT_REGISTRY.find(a => a.name === name)?.retentionClass ?? null;
237
+ }
238
+
239
+ export function listArtifactsWithRetentionClass() {
240
+ return ARTIFACT_REGISTRY.filter(a => a.classification === 'generated' && a.retentionClass);
241
+ }
@@ -27,19 +27,95 @@
27
27
  // User supplies their own control mapping in the same shape as the
28
28
  // bundled ones. The auditor-walkthrough renders evidence against it.
29
29
  //
30
- // Disclaimer: this module organizes scanner evidence into a narrative.
31
- // It does not certify compliance. A licensed assessor (CPA / auditor /
32
- // DPO) is responsible for the final attestation.
30
+ // Disclaimer: this module organizes scanner evidence into a narrative. It
31
+ // does not certify compliance. See evidence-grade-wording.js for why the
32
+ // emitted disclaimer names all three assurance tiers explicitly (this
33
+ // module's own is one of the ones that used to get the terminology
34
+ // backwards — "a licensed assessor is responsible for the final
35
+ // attestation" describes independent certification, not attestation).
33
36
 
34
37
  import * as fs from 'node:fs';
35
38
  import * as path from 'node:path';
36
39
 
37
40
  import { statePath, stateWritesEnabled } from './state-dir.js';
41
+ import { EVIDENCE_GRADE_DISCLAIMER_SHORT } from './evidence-grade-wording.js';
38
42
  import { COMPLIANCE_FAMILY_ALIAS, resolveFamilyKeys } from './family-resolve.js';
39
43
  import { strengthOfControl as _strengthOfControl } from './coverage-strength.js';
44
+ // FR-PROV-026: earliestOrigin.authorName below is untrusted git commit
45
+ // metadata. renderWalkthrough()'s output is console.log'd verbatim by
46
+ // bin/agentic-security.js's `compliance --walkthrough` — the ONLY live
47
+ // consumer today (persistWalkthrough below is exported and tested but has
48
+ // zero callers anywhere in the CLI/command surface; no code in this repo
49
+ // ever runs this text through a real Markdown renderer). sanitizeForTerminal
50
+ // is therefore the correct sanitizer here, not a Markdown-escaping one — a
51
+ // backslash-escaping sibling was tried and reverted (see schema.js's header
52
+ // comment): printed raw or read as plain text, `Jean-Luc Picard` rendering
53
+ // as `Jean\-Luc Picard` and `dependabot[bot]` as `dependabot\[bot\]` is a
54
+ // visible regression on common real-world author names, not a fix.
55
+ import { sanitizeForTerminal, pseudonymizeAuthor, PROVENANCE_COMPLIANCE_DISCLAIMER } from './provenance/schema.js';
56
+
57
+ // Fix-round item 4b: this renderer had `sanitizeForTerminal` (injection
58
+ // safety) but never honoured `--pseudonymize-authors` at all — an operator
59
+ // who set that policy still saw a raw committer name in walkthrough output,
60
+ // the one boundary that gap missed. Reads the SAME env var
61
+ // `report/index.js`'s `_normalizedProvenance` and `mcp/tools.js`'s
62
+ // `providerEnrichment`-aware call read back
63
+ // (AGENTIC_SECURITY_PSEUDONYMIZE_AUTHORS=1 / --pseudonymize-authors). Keyed
64
+ // on name only, not email: `deriveComplianceProvenance`'s `earliestOrigin`
65
+ // deliberately never carries `authorEmail` (see its own comment — that
66
+ // object bypasses the `redactFindingProvenance` sweep entirely), so the
67
+ // pseudonym here is stable across repeated runs for the same author name but
68
+ // not necessarily identical to the email-keyed pseudonym shown at other
69
+ // output boundaries for the same person.
70
+ function _maybePseudonymizeName(name) {
71
+ if (!name) return name;
72
+ return process.env.AGENTIC_SECURITY_PSEUDONYMIZE_AUTHORS === '1' ? pseudonymizeAuthor(name, null) : name;
73
+ }
40
74
 
41
75
  // Re-exported so existing callers/tests keep importing these from here.
42
76
  export { COMPLIANCE_FAMILY_ALIAS, resolveFamilyKeys };
77
+
78
+ // FR-PROV-016 (M2): "earliest proven open condition" among a control's
79
+ // contributing findings. Prefers a finding whose findingProvenance resolved
80
+ // findingOrigin.status:'complete' (the OLDEST such authorDate wins); falls
81
+ // back to 'partial' entries with a resolved findingOrigin.authorDate when no
82
+ // complete one exists. Never fabricates an origin — zero usable entries is
83
+ // reported as null/'unknown', not the repo's first commit or "now".
84
+ export function deriveComplianceProvenance(findings) {
85
+ const list = Array.isArray(findings) ? findings.filter(Boolean) : [];
86
+ const withOrigin = list
87
+ .map((f) => ({ f, fp: f && f.findingProvenance }))
88
+ .filter((x) => x.fp && x.fp.findingOrigin && x.fp.findingOrigin.authorDate);
89
+ const complete = withOrigin.filter((x) => x.fp.status === 'complete');
90
+ const partial = withOrigin.filter((x) => x.fp.status === 'partial');
91
+ // authorDate is git's `%aI` (strict ISO-8601, author's LOCAL UTC offset —
92
+ // see git-evidence.js's commitMeta), never normalized to Z. Two commits
93
+ // authored in different timezones near a day boundary can lexically sort
94
+ // in the wrong chronological order, so compare actual instants via
95
+ // Date.parse, never the raw strings.
96
+ const pickEarliest = (arr) => arr.reduce(
97
+ (min, x) => (!min || Date.parse(x.fp.findingOrigin.authorDate) < Date.parse(min.fp.findingOrigin.authorDate)) ? x : min,
98
+ null,
99
+ );
100
+ const best = complete.length ? pickEarliest(complete) : (partial.length ? pickEarliest(partial) : null);
101
+ return {
102
+ derivedFrom: [...new Set(list.map((f) => f && f.id).filter(Boolean))],
103
+ // Only commit/authorDate/authorName are ever read from findingOrigin
104
+ // here — this object is a SIBLING field to findingProvenance (not
105
+ // nested inside it), so it bypasses the redactFindingProvenance sweep
106
+ // that runs at report/mcp output boundaries. authorEmail must never be
107
+ // added to this shape without first routing it through
108
+ // redactFindingProvenance.
109
+ earliestOrigin: best ? {
110
+ commit: best.fp.findingOrigin.commit || null,
111
+ authorDate: best.fp.findingOrigin.authorDate,
112
+ authorName: best.fp.findingOrigin.authorName || null,
113
+ } : null,
114
+ confidence: complete.length ? 'high' : (partial.length ? 'low' : 'unknown'),
115
+ limitations: best ? [] : ['no contributing finding resolved a verified origin'],
116
+ };
117
+ }
118
+
43
119
  const BUNDLED_DIR = path.join(path.dirname(new URL(import.meta.url).pathname), 'compliance-frameworks');
44
120
  function _readJson(fp) {
45
121
  try { return JSON.parse(fs.readFileSync(fp, 'utf8')); } catch { return null; }
@@ -187,7 +263,64 @@ export const COMPLIANCE_FAMILY_GAPS = {
187
263
  // works, which is the mirror image of the bug this prevents.
188
264
  };
189
265
 
266
+ // FR-501/FR-502 (assurance-hardening PRD, A-07): a `family:` mapping used to
267
+ // only count 'critical'/'high' findings as "open" — a control with 50 open
268
+ // MEDIUM findings on its mapped family rendered as
269
+ // "✓ no open critical/high findings", identical to a genuinely clean
270
+ // control. posture/privacy-framework.js already solved the analogous problem
271
+ // for its own four-bucket model (its header calls this out directly: a
272
+ // vacuous pass is "the same false assurance... arriving by a different
273
+ // route"); this raises the floor here to match rather than leaving two
274
+ // different standards for what counts as "open" across compliance surfaces.
275
+ // A named, ordered rank (not a hardcoded pair of string literals) so a
276
+ // future per-framework/per-policy threshold (FR-502's fuller scope) is a
277
+ // one-line change here rather than another hunt through the evaluator.
278
+ const SEVERITY_RANK = { info: 0, low: 1, medium: 2, high: 3, critical: 4 };
279
+ const OPEN_FINDING_MIN_SEVERITY = 'medium';
280
+
281
+ // FR-502's fuller scope, delivered: "policy-specific rather than globally
282
+ // high/critical." An operator can lower (or raise) the open-finding floor
283
+ // per framework via .agentic-security/compliance-severity-policy.json:
284
+ // { "default": "medium", "byFramework": { "gdpr": "low" } }
285
+ // `default` overrides OPEN_FINDING_MIN_SEVERITY for every framework that
286
+ // has no more specific `byFramework` entry; a framework entry wins over
287
+ // `default`. Never inferred — an operator decision, same as
288
+ // dataflow/privacy-taxonomy.js's (FR-402) taxonomy customization and
289
+ // egress/policy.js's (FR-602) config-file precedent. A missing file, a
290
+ // malformed one, or a value that is not one of SEVERITY_RANK's five known
291
+ // keys all degrade to the built-in 'medium' floor — falling back to a rank
292
+ // of `undefined` would make the `>=` comparison always false, silently
293
+ // treating EVERY finding as "not open" (the exact vacuous-pass bug this
294
+ // threshold exists to prevent), so an invalid override must never reach
295
+ // the comparison at all.
296
+ const SEVERITY_POLICY_FILE = 'compliance-severity-policy.json';
297
+
298
+ function _resolveOpenFindingMinSeverity(scanRoot, frameworkId) {
299
+ if (!scanRoot) return OPEN_FINDING_MIN_SEVERITY;
300
+ let raw;
301
+ try {
302
+ raw = fs.readFileSync(statePath(scanRoot, SEVERITY_POLICY_FILE), 'utf8');
303
+ } catch {
304
+ return OPEN_FINDING_MIN_SEVERITY; // ENOENT (the common case) or any other read failure
305
+ }
306
+ let doc;
307
+ try {
308
+ doc = JSON.parse(raw);
309
+ } catch {
310
+ return OPEN_FINDING_MIN_SEVERITY; // malformed config — never throws, never blocks evaluation
311
+ }
312
+ if (!doc || typeof doc !== 'object') return OPEN_FINDING_MIN_SEVERITY;
313
+ const byFramework = (doc.byFramework && typeof doc.byFramework === 'object') ? doc.byFramework : {};
314
+ const candidate = (frameworkId && typeof byFramework[frameworkId] === 'string')
315
+ ? byFramework[frameworkId]
316
+ : (typeof doc.default === 'string' ? doc.default : null);
317
+ return (candidate && Object.prototype.hasOwnProperty.call(SEVERITY_RANK, candidate))
318
+ ? candidate
319
+ : OPEN_FINDING_MIN_SEVERITY;
320
+ }
321
+
190
322
  export function evaluateFramework(scanRoot, fw, scan) {
323
+ const minSeverity = _resolveOpenFindingMinSeverity(scanRoot, fw && fw.id);
191
324
  // CMP-2: last-scan.json (what this is actually handed in production) carries
192
325
  // findings across four separate channels — SAST (`findings`), secrets,
193
326
  // business-logic, and SCA (`supplyChain`) — because report/index.js's
@@ -214,24 +347,30 @@ export function evaluateFramework(scanRoot, fw, scan) {
214
347
  const results = [];
215
348
  for (const c of fw.controls || []) {
216
349
  const obs = [];
350
+ // FR-PROV-016: findings that contributed an OPEN condition to this
351
+ // control's `family:` mapping(s) — the exact objects `open` below
352
+ // filters to, not just their ids, so deriveComplianceProvenance can read
353
+ // .findingProvenance off them. Naturally empty for a control that ends
354
+ // up 'present' (present requires zero open findings across every
355
+ // mapping) or 'manual' (no family: mapping ever populates it) — so a
356
+ // consumer can treat a non-empty controlRefs as "this control has an
357
+ // attributable gap" without re-deriving the bucket classification.
358
+ const contributingFindings = [];
217
359
  let status = 'manual';
218
360
  const maps = Array.isArray(c.mapsTo) ? c.mapsTo : [];
219
361
 
220
362
  if (maps.length === 0) {
221
363
  obs.push('No automated mapping — requires manual evidence collection.');
222
- // PRD F10.2: carry the MEASURED strength of the backing detector, so a
223
- // control mapped to a detector that finds 3 of 18 independent advisories
224
- // cannot read the same as one backed by a detector that finds nearly
225
- // everything. Import is lazy so the evaluator keeps working if the bench
226
- // artifacts are absent (they degrade to `unmeasured`, never to a default).
227
- let evidence = null;
228
- try { evidence = _strengthOfControl(c); } catch { /* strength is additive; never block evaluation */ }
229
- results.push({
230
- control: c,
231
- status,
232
- observations: obs,
233
- ...(evidence ? { evidence, partiallyEvidenced: evidence.tier === 'weak' || evidence.tier === 'unmeasured' } : {}),
234
- });
364
+ let evidence = null;
365
+ try { evidence = _strengthOfControl(c); } catch { /* strength is additive; never block evaluation */ }
366
+ results.push({
367
+ control: c,
368
+ status,
369
+ observations: obs,
370
+ controlRefs: [],
371
+ derivedProvenance: deriveComplianceProvenance([]),
372
+ ...(evidence ? { evidence, partiallyEvidenced: evidence.tier === 'weak' || evidence.tier === 'unmeasured' } : {}),
373
+ });
235
374
  continue;
236
375
  }
237
376
 
@@ -291,12 +430,14 @@ export function evaluateFramework(scanRoot, fw, scan) {
291
430
  const candidates = resolveFamilyKeys(fam, families.keys())
292
431
  .flatMap(k => families.get(k) || []);
293
432
  const scoped = subfam ? candidates.filter(f => !f.subfamily || f.subfamily === subfam) : candidates;
294
- const open = scoped.filter(f => !f.intentSuppressed && !f.pastDecision && (f.severity === 'critical' || f.severity === 'high'));
433
+ const minRank = SEVERITY_RANK[minSeverity];
434
+ const open = scoped.filter(f => !f.intentSuppressed && !f.pastDecision && (SEVERITY_RANK[f.severity] ?? 0) >= minRank);
295
435
  if (open.length) {
296
436
  allCleared = false;
297
- obs.push(`${open.length} open ${fam} finding(s) at high/critical.`);
437
+ contributingFindings.push(...open);
438
+ obs.push(`${open.length} open ${fam} finding(s) at ${minSeverity}+.`);
298
439
  } else {
299
- obs.push(`✓ ${fam}: no open critical/high findings.`);
440
+ obs.push(`✓ ${fam}: no open ${minSeverity}+ findings.`);
300
441
  anyCleared = true;
301
442
  }
302
443
  anySignal = true;
@@ -399,10 +540,13 @@ export function evaluateFramework(scanRoot, fw, scan) {
399
540
  // artifacts are absent (they degrade to `unmeasured`, never to a default).
400
541
  let evidence = null;
401
542
  try { evidence = _strengthOfControl(c); } catch { /* strength is additive; never block evaluation */ }
543
+ const dedupedRefs = [...new Set(contributingFindings.map((f) => f.id).filter(Boolean))];
402
544
  results.push({
403
545
  control: c,
404
546
  status,
405
547
  observations: obs,
548
+ controlRefs: dedupedRefs,
549
+ derivedProvenance: deriveComplianceProvenance(contributingFindings),
406
550
  ...(evidence ? { evidence, partiallyEvidenced: evidence.tier === 'weak' || evidence.tier === 'unmeasured' } : {}),
407
551
  });
408
552
  }
@@ -420,7 +564,7 @@ export function renderWalkthrough(fw, evaluation, opts = {}) {
420
564
  lines.push(`> License: ${fw.license}`);
421
565
  if (fw.url) lines.push(`> Source: ${fw.url}`);
422
566
  lines.push('');
423
- lines.push('> **This walkthrough organizes scanner evidence into a narrative for an external auditor.** It does NOT certify compliance. A licensed assessor is responsible for the final attestation.');
567
+ lines.push(`> **This walkthrough organizes scanner evidence into a narrative for an external auditor.** ${EVIDENCE_GRADE_DISCLAIMER_SHORT}`);
424
568
  lines.push('');
425
569
 
426
570
  const present = evaluation.filter(e => e.status === 'present').length;
@@ -457,6 +601,27 @@ export function renderWalkthrough(fw, evaluation, opts = {}) {
457
601
  if (ev.status === 'absent' || ev.status === 'partial') {
458
602
  lines.push(`**Remediation:** address the bullet(s) above, then re-run \`/compliance --walkthrough ${fw.id}\` to update this report.`);
459
603
  lines.push('');
604
+ if (Array.isArray(ev.controlRefs) && ev.controlRefs.length) {
605
+ lines.push(`**Contributing findings:** ${ev.controlRefs.join(', ')}`);
606
+ const dp = ev.derivedProvenance;
607
+ if (dp && dp.earliestOrigin) {
608
+ const short = String(dp.earliestOrigin.commit || '').slice(0, 7) || 'unknown';
609
+ const day = String(dp.earliestOrigin.authorDate || '').slice(0, 10);
610
+ // Only commit/authorDate/authorName are ever read here — same
611
+ // caveat as deriveComplianceProvenance's earliestOrigin: this
612
+ // object bypasses redactFindingProvenance, so authorEmail must
613
+ // never be surfaced from it without routing through that function
614
+ // first.
615
+ lines.push(`**Earliest proven origin:** ${short} — ${day} — ${sanitizeForTerminal(_maybePseudonymizeName(dp.earliestOrigin.authorName)) || 'unknown'} (confidence: ${dp.confidence})`);
616
+ // PRD Section 8 REQUIRED DISCLAIMER, alongside the claim it
617
+ // qualifies (not just once at the top of the document) — a reader
618
+ // who skips straight to a control's evidence must still see it.
619
+ lines.push(`_${PROVENANCE_COMPLIANCE_DISCLAIMER}_`);
620
+ } else if (dp) {
621
+ lines.push(`**Earliest proven origin:** unresolved (confidence: ${dp.confidence})`);
622
+ }
623
+ lines.push('');
624
+ }
460
625
  }
461
626
  }
462
627
 
@@ -475,4 +640,4 @@ export function persistWalkthrough(scanRoot, fw, body) {
475
640
  return fp;
476
641
  }
477
642
 
478
- export const _internals = { _readJson };
643
+ export const _internals = { _readJson, _resolveOpenFindingMinSeverity, SEVERITY_POLICY_FILE };