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

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 (195) hide show
  1. package/CHANGELOG.md +266 -0
  2. package/bin/agentic-security.js +3508 -69
  3. package/dist/1122.index.js +779 -0
  4. package/dist/{301.index.js → 1301.index.js} +2 -2
  5. package/dist/1379.index.js +591 -0
  6. package/dist/{444.index.js → 1444.index.js} +2 -2
  7. package/dist/{660.index.js → 1660.index.js} +2 -2
  8. package/dist/{700.index.js → 1700.index.js} +2 -2
  9. package/dist/{905.index.js → 1905.index.js} +3 -3
  10. package/dist/{920.index.js → 1920.index.js} +3 -3
  11. package/dist/{238.index.js → 2238.index.js} +3 -3
  12. package/dist/{271.index.js → 2271.index.js} +2 -2
  13. package/dist/{985.index.js → 2376.index.js} +1260 -340
  14. package/dist/2432.index.js +793 -0
  15. package/dist/2659.index.js +93 -0
  16. package/dist/{826.index.js → 2826.index.js} +2 -2
  17. package/dist/{830.index.js → 2830.index.js} +2 -2
  18. package/dist/2923.index.js +298 -0
  19. package/dist/{1.index.js → 3001.index.js} +5 -5
  20. package/dist/{117.index.js → 3117.index.js} +3 -3
  21. package/dist/3180.index.js +379 -0
  22. package/dist/3276.index.js +117 -0
  23. package/dist/{415.index.js → 3415.index.js} +2 -2
  24. package/dist/{499.index.js → 3499.index.js} +2 -2
  25. package/dist/3518.index.js +450 -0
  26. package/dist/{526.index.js → 3526.index.js} +5 -5
  27. package/dist/{736.index.js → 3736.index.js} +4 -4
  28. package/dist/{839.index.js → 3839.index.js} +4 -4
  29. package/dist/{113.index.js → 4113.index.js} +5 -5
  30. package/dist/{265.index.js → 4265.index.js} +2 -2
  31. package/dist/{384.index.js → 4384.index.js} +3 -3
  32. package/dist/4547.index.js +268 -0
  33. package/dist/4863.index.js +422 -0
  34. package/dist/{970.index.js → 4970.index.js} +2 -2
  35. package/dist/5051.index.js +841 -0
  36. package/dist/{144.index.js → 5144.index.js} +5 -5
  37. package/dist/{333.index.js → 5333.index.js} +3 -3
  38. package/dist/5343.index.js +185 -0
  39. package/dist/5350.index.js +866 -0
  40. package/dist/5561.index.js +436 -0
  41. package/dist/{637.index.js → 5637.index.js} +3 -3
  42. package/dist/{449.index.js → 5830.index.js} +2 -2
  43. package/dist/6626.index.js +532 -0
  44. package/dist/6662.index.js +297 -0
  45. package/dist/{675.index.js → 6675.index.js} +5 -5
  46. package/dist/{730.index.js → 6730.index.js} +6 -6
  47. package/dist/6829.index.js +225 -0
  48. package/dist/6944.index.js +130 -0
  49. package/dist/{178.index.js → 7178.index.js} +3 -3
  50. package/dist/{227.index.js → 7227.index.js} +2 -2
  51. package/dist/7310.index.js +520 -0
  52. package/dist/{552.index.js → 7552.index.js} +4 -4
  53. package/dist/7709.index.js +78 -0
  54. package/dist/8218.index.js +160 -0
  55. package/dist/{476.index.js → 8476.index.js} +4 -4
  56. package/dist/{513.index.js → 8513.index.js} +5 -5
  57. package/dist/{520.index.js → 8520.index.js} +2 -2
  58. package/dist/{718.index.js → 8718.index.js} +2 -2
  59. package/dist/{752.index.js → 8752.index.js} +2 -2
  60. package/dist/8846.index.js +100 -0
  61. package/dist/{435.index.js → 9091.index.js} +560 -189
  62. package/dist/{207.index.js → 9207.index.js} +2 -2
  63. package/dist/{220.index.js → 9220.index.js} +2 -2
  64. package/dist/9390.index.js +163 -0
  65. package/dist/{503.index.js → 9503.index.js} +2 -2
  66. package/dist/{801.index.js → 9801.index.js} +2 -2
  67. package/dist/{824.index.js → 9824.index.js} +2 -2
  68. package/dist/agentic-security.mjs +16 -16
  69. package/dist/agentic-security.mjs.sha256 +1 -1
  70. package/dist/compliance-frameworks/hipaa-security-rule.json +3 -2
  71. package/dist/frontend/index.html +21 -0
  72. package/dist/frontend/src/app.js +176 -0
  73. package/dist/frontend/src/components/evidence-inspector.js +141 -0
  74. package/dist/frontend/src/components/filter-rail.js +119 -0
  75. package/dist/frontend/src/components/query-bar.js +126 -0
  76. package/dist/frontend/src/data/flagship-graph.js +1460 -0
  77. package/dist/frontend/src/export-entry.js +36 -0
  78. package/dist/frontend/src/lib/api-client.js +92 -0
  79. package/dist/frontend/src/lib/contrast.js +34 -0
  80. package/dist/frontend/src/lib/dom.js +24 -0
  81. package/dist/frontend/src/lib/escape-html.js +16 -0
  82. package/dist/frontend/src/lib/flow-path.js +40 -0
  83. package/dist/frontend/src/lib/focus-controls.js +149 -0
  84. package/dist/frontend/src/lib/protection-visual.js +46 -0
  85. package/dist/frontend/src/lib/query-language.js +240 -0
  86. package/dist/frontend/src/lib/row-filters.js +43 -0
  87. package/dist/frontend/src/lib/state.js +84 -0
  88. package/dist/frontend/src/main.js +83 -0
  89. package/dist/frontend/src/shell.js +184 -0
  90. package/dist/frontend/src/views/architecture-view.js +798 -0
  91. package/dist/frontend/src/views/inventory-view.js +292 -0
  92. package/dist/frontend/src/views/privacy-view.js +172 -0
  93. package/dist/frontend/src/views/trace-view.js +206 -0
  94. package/dist/frontend/styles/architecture-view.css +93 -0
  95. package/dist/frontend/styles/filter-rail.css +34 -0
  96. package/dist/frontend/styles/inspector.css +69 -0
  97. package/dist/frontend/styles/inventory-view.css +74 -0
  98. package/dist/frontend/styles/privacy-view.css +86 -0
  99. package/dist/frontend/styles/query-bar.css +107 -0
  100. package/dist/frontend/styles/shell.css +155 -0
  101. package/dist/frontend/styles/tokens.css +128 -0
  102. package/dist/frontend/styles/trace-view.css +95 -0
  103. package/package.json +13 -6
  104. package/src/dataflow/CLAUDE.md +1 -1
  105. package/src/dataflow/catalog.js +42 -0
  106. package/src/dataflow/orm-write-catalog.js +175 -0
  107. package/src/engine.js +92 -1
  108. package/src/ir/CLAUDE.md +1 -0
  109. package/src/ir/chrome-probe.mjs +150 -0
  110. package/src/ir/parser-js.js +94 -7
  111. package/src/lineage/CLAUDE.md +1203 -0
  112. package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
  113. package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
  114. package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
  115. package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
  116. package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
  117. package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
  118. package/src/lineage/DESIGN_REGISTRIES.md +880 -0
  119. package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
  120. package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
  121. package/src/lineage/classification.js +56 -0
  122. package/src/lineage/coverage.js +658 -0
  123. package/src/lineage/cross-repo-link.js +107 -0
  124. package/src/lineage/dataflow-graph.schema.json +184 -0
  125. package/src/lineage/decision-story.js +206 -0
  126. package/src/lineage/drift-policy.js +279 -0
  127. package/src/lineage/driver.js +135 -0
  128. package/src/lineage/engine.js +992 -0
  129. package/src/lineage/export-briefing.js +628 -0
  130. package/src/lineage/export-csv.js +62 -0
  131. package/src/lineage/export-json.js +238 -0
  132. package/src/lineage/export-privacy.js +258 -0
  133. package/src/lineage/federation-loader.js +111 -0
  134. package/src/lineage/field-identity.js +78 -0
  135. package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
  136. package/src/lineage/fixtures/flagship-graph.json +1453 -0
  137. package/src/lineage/flow-grade.js +221 -0
  138. package/src/lineage/governance-edit.js +169 -0
  139. package/src/lineage/graph-builder.js +1114 -0
  140. package/src/lineage/graph-diff.js +431 -0
  141. package/src/lineage/graph-snapshot.js +180 -0
  142. package/src/lineage/handling-analyzer.js +168 -0
  143. package/src/lineage/ids.js +349 -0
  144. package/src/lineage/impact-assessment.js +76 -0
  145. package/src/lineage/impact-engine.js +268 -0
  146. package/src/lineage/index.js +281 -0
  147. package/src/lineage/language-coverage-tiers.js +58 -0
  148. package/src/lineage/obligation-mapping.js +126 -0
  149. package/src/lineage/obligation-predicates.js +235 -0
  150. package/src/lineage/observation-adapters.js +282 -0
  151. package/src/lineage/observation-correlation.js +622 -0
  152. package/src/lineage/observation-store.js +497 -0
  153. package/src/lineage/path-query.js +410 -0
  154. package/src/lineage/path-store.js +400 -0
  155. package/src/lineage/protection.js +53 -0
  156. package/src/lineage/recipient-profile.js +192 -0
  157. package/src/lineage/recipient-registry.js +394 -0
  158. package/src/lineage/redact-graph.js +224 -0
  159. package/src/lineage/remediation.js +417 -0
  160. package/src/lineage/resolve-destination.js +91 -0
  161. package/src/lineage/runtime-observation.js +464 -0
  162. package/src/lineage/scenario-diff.js +84 -0
  163. package/src/lineage/scenario-engine.js +251 -0
  164. package/src/lineage/scenario.js +101 -0
  165. package/src/lineage/schema.js +167 -0
  166. package/src/lineage/sink-registry.js +427 -0
  167. package/src/lineage/source-registry.js +357 -0
  168. package/src/lineage/source-seeding.js +212 -0
  169. package/src/lineage/summaries.js +590 -0
  170. package/src/lineage/transform-catalog.js +397 -0
  171. package/src/lineage/transit-protection.js +150 -0
  172. package/src/lineage/validate.js +285 -0
  173. package/src/mcp/CLAUDE.md +7 -1
  174. package/src/mcp/dataflow-tools.js +160 -0
  175. package/src/mcp/server.js +1 -1
  176. package/src/mcp/tools.js +2 -1
  177. package/src/pipeline/scan-health.js +19 -1
  178. package/src/posture/CLAUDE.md +19 -0
  179. package/src/posture/artifact-registry.js +52 -0
  180. package/src/posture/auditor-walkthrough.js +76 -0
  181. package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
  182. package/src/posture/obligation-evidence-pack.js +202 -0
  183. package/src/posture/remediation-ledger.js +337 -0
  184. package/src/server/CLAUDE.md +47 -0
  185. package/src/server/graph-loader.js +141 -0
  186. package/src/server/http-server.js +325 -0
  187. package/src/server/routes.js +129 -0
  188. package/src/server/security.js +111 -0
  189. package/src/server/static-assets.js +144 -0
  190. package/src/shared/frontend-root.js +52 -0
  191. package/dist/11.index.js +0 -353
  192. package/dist/259.index.js +0 -975
  193. package/dist/317.index.js +0 -300
  194. package/dist/609.index.js +0 -741
  195. package/dist/838.index.js +0 -152
@@ -83,6 +83,31 @@ export const ARTIFACT_REGISTRY = [
83
83
  { name: 'scan-history.json', kind: 'file', classification: 'generated', retentionClass: 'scan' },
84
84
  { name: 'last-scan.json', kind: 'file', classification: 'generated', retentionClass: 'scan' },
85
85
  { name: 'last-scan.json.sig', kind: 'file', classification: 'generated', retentionClass: 'scan' },
86
+ // Sub-project E, increment 5: the Data Flow Explorer's DataFlowGraph v1
87
+ // document + signature, written by bin/agentic-security.js alongside
88
+ // last-scan.json whenever AGENTIC_SECURITY_LINEAGE_DEEP=1 produced a
89
+ // graph. NOT to be confused with the similarly-named, already-registered
90
+ // `repo-lineage.json` below — that one is an operator-authored
91
+ // cross-repo provenance declaration (classification 'operator-config',
92
+ // never written by the scanner); this one is scanner-generated scan
93
+ // output derived from the user's own source (dataElements[].dataClasses
94
+ // included), so it belongs in the 'generated'/'scan' bucket `reset`
95
+ // clears by default, same as last-scan.json itself.
96
+ { name: 'lineage-graph.json', kind: 'file', classification: 'generated', retentionClass: 'scan' },
97
+ { name: 'lineage-graph.json.sig', kind: 'file', classification: 'generated', retentionClass: 'scan' },
98
+ // M4 deliverable #8 (FR-503 §14, DFG-022, sub-project 8a): the "Data-Flow
99
+ // Time Machine" foundation — one commit-keyed GraphSnapshot per scan,
100
+ // written by src/lineage/graph-snapshot.js's persistGraphSnapshot(),
101
+ // additively alongside (never replacing) lineage-graph.json above. Same
102
+ // 'generated'/'scan' bucket for the same reason: fully scanner-derived,
103
+ // regenerable by re-scanning at that commit.
104
+ { name: 'lineage-snapshots', kind: 'dir', classification: 'generated', retentionClass: 'scan', source: 'src/lineage/graph-snapshot.js (persistGraphSnapshot)' },
105
+ // M5 deliverable #7 (FR-505 §7.12, AC-29): the Runtime-Corroborated
106
+ // Digital Twin's observation store — one immutable whole file per adapter
107
+ // import, written by src/lineage/observation-store.js's
108
+ // persistObservationImport(), mirroring lineage-snapshots/'s own
109
+ // directory-of-files shape re-keyed commit -> import.
110
+ { name: 'runtime-observations', kind: 'dir', classification: 'generated', retentionClass: 'evidence', confidential: true, source: 'src/lineage/observation-store.js (persistObservationImport)', note: 'FR-505 requires an observation store follow artifact encryption, RETENTION, RESET, access-control and no-egress rules. That is why this is `generated` (a plain `reset` MUST be able to delete it) rather than `operator-config`, and why it carries a real retentionClass — deliberately NOT the `remediation`/`legal-holds.json` no-retention call one section down, and deliberately NOT `provenance`\'s permanent-history call. This DOES stretch `generated`\'s usual definition — a rescan does not re-derive an import, the operator re-imports it — and that stretch is disclosed here rather than hidden: FR-505\'s explicit reset requirement breaks the tie. `confidential: true` is enforced by observation-store.js calling maybeEncryptForWrite/maybeDecryptForRead itself (posture/encryption-provider.js), the same per-writer opt-in compliance-evidence.json makes — the flag alone enforces nothing.' },
86
111
  { name: 'shadow-findings.json', kind: 'file', classification: 'generated', retentionClass: 'scan' },
87
112
  { name: 'mcp-audit.log', kind: 'file', classification: 'generated', retentionClass: 'evidence' },
88
113
  { 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" },
@@ -168,6 +193,19 @@ export const ARTIFACT_REGISTRY = [
168
193
  { name: 'deletion-report.json', kind: 'file', classification: 'generated', retentionClass: 'evidence', source: 'posture/state-lifecycle-report.js (via bin/agentic-security.js cmdReset)' },
169
194
  { name: 'export-report.json', kind: 'file', classification: 'generated', retentionClass: 'evidence', source: 'posture/state-lifecycle-report.js (via bin/agentic-security.js cmdExport)' },
170
195
 
196
+ // M5 governance editing workflow final-review fix round 1 (I5): backups
197
+ // of recipient-profiles.json written by `governance propose-edit --yes`,
198
+ // mirroring the `fix-history` directory precedent above — one directory
199
+ // entry covers every timestamped .bak file inside it, since the registry
200
+ // only supports exact-name matches, never a per-file timestamped name.
201
+ { name: 'recipient-profiles-backups', kind: 'dir', classification: 'generated', retentionClass: 'backup', note: 'per-edit backups written by `governance propose-edit --yes`, mirrors the fix-history/ precedent — one directory entry covers every timestamped .bak file inside it' },
202
+
203
+ // M5 deliverable #8 (FR-304 "declared" half): backups of
204
+ // cross-repo-links.json written by `federate declare --yes`, mirrors
205
+ // the `recipient-profiles-backups` precedent immediately above
206
+ // exactly.
207
+ { name: 'cross-repo-links-backups', kind: 'dir', classification: 'generated', retentionClass: 'backup', note: 'per-declare backups written by `federate declare --yes`, mirrors the recipient-profiles-backups/ precedent — one directory entry covers every timestamped .bak file inside it' },
208
+
171
209
  // ── Operator-config: hand-authored (or agent-authored) input, never wiped ──
172
210
  { name: 'rules.yml', kind: 'file', classification: 'operator-config' },
173
211
  { name: 'rules', kind: 'dir', classification: 'operator-config' },
@@ -191,14 +229,28 @@ export const ARTIFACT_REGISTRY = [
191
229
  { 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
230
  { 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
231
  { 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' },
232
+ { name: 'remediation', kind: 'dir', classification: 'operator-config', note: 'M5 deliverable #6 remediation work-item ledger (items.jsonl + items.lock) — an APPEND-ONLY record of human decisions: owner assignment, approvals, manual attestations, and accepted-risk exceptions with approver/reason/scope/expiration. WRITTEN by the CLI (`remediation open|update|verify|accept-risk|reopen-check --yes`) but classified operator-config, not generated, deliberately — the same call legal-holds.json makes one entry up: a plain `reset` must never be able to delete the audit trail AC-31 depends on, and nothing regenerates it from a rescan. Deliberately NOT the `provenance` entry\'s `generated` classification (see its own note above): that ledger is scan-derived history a rescan can rebuild; this one is human decisions that cannot be. No retentionClass for the same reason legal-holds.json has none — auto-expiry would silently delete an accepted-risk exception a report may already cite.' },
194
233
  { 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
234
  { 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
235
  { 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
236
  { 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' },
237
+ { name: 'recipient-profiles.json', kind: 'file', classification: 'operator-config', note: 'FR-506 recipient/subprocessor governance profiles — hand- or agent-authored via `governance propose-edit`, never scanner-regenerable, so a routine reset must never delete it' },
238
+ { name: 'cross-repo-links.json', kind: 'file', classification: 'operator-config', note: 'M5 deliverable #8 (FR-304 "declared" half) — declared local<->remote node links between two independently-scanned repos, written via `federate declare --yes`, never scanner-regenerable, so a routine reset must never delete it' },
198
239
  { 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
240
  { name: 'current-intent.md', kind: 'file', classification: 'operator-config', note: 'developer-authored; no writer exists anywhere in src/ or bin/' },
200
241
  { name: 'exploit-history.jsonl', kind: 'file', classification: 'operator-config', note: 'own header comment: "operator-curated record of past confirmed exploits"' },
201
242
  { 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' },
243
+ // Pre-existing gap, found by M5 deliverable #7's own scoping investigation
244
+ // and fixed here rather than left: posture/runtime-correlation.js reads
245
+ // these three filenames via `statePath(scanRoot, n)` with a VARIABLE, so
246
+ // test/artifact-registry-completeness.test.js's own PATTERNS regexes (which
247
+ // require a string literal) never saw them and never demanded registration.
248
+ // An unregistered state artifact means `reset` does not know about it and
249
+ // retention cannot reach it. Operator-config, not generated: these are
250
+ // hand-supplied eBPF/APM trace exports the scanner only ever reads.
251
+ { name: 'runtime-trace.jsonl', kind: 'file', classification: 'operator-config', note: 'eBPF/APM runtime trace consumed by posture/runtime-correlation.js\'s own trace loader (deliberately not named by its literal export here — no-dead-modules.test.js scans note strings too, and naming it would make it look, wrongly, like a real call site) — operator-supplied, never scanner-written; $AGENTIC_SECURITY_RUNTIME_TRACE_PATH overrides the location entirely' },
252
+ { name: 'runtime.jsonl', kind: 'file', classification: 'operator-config', note: 'alternate filename for runtime-trace.jsonl — see posture/runtime-correlation.js\'s DEFAULT_TRACE_NAMES' },
253
+ { name: 'ebpf-trace.jsonl', kind: 'file', classification: 'operator-config', note: 'alternate filename for runtime-trace.jsonl — see posture/runtime-correlation.js\'s DEFAULT_TRACE_NAMES' },
202
254
  ];
203
255
 
204
256
  export function listGeneratedArtifacts() {
@@ -53,6 +53,7 @@ import { strengthOfControl as _strengthOfControl } from './coverage-strength.js'
53
53
  // as `Jean\-Luc Picard` and `dependabot[bot]` as `dependabot\[bot\]` is a
54
54
  // visible regression on common real-world author names, not a fix.
55
55
  import { sanitizeForTerminal, pseudonymizeAuthor, PROVENANCE_COMPLIANCE_DISCLAIMER } from './provenance/schema.js';
56
+ import { evaluateGraphFlowPredicate, buildObligationMappingFromGraphPredicate } from '../lineage/obligation-predicates.js';
56
57
 
57
58
  // Fix-round item 4b: this renderer had `sanitizeForTerminal` (injection
58
59
  // safety) but never honoured `--pseudonymize-authors` at all — an operator
@@ -369,6 +370,7 @@ export function evaluateFramework(scanRoot, fw, scan) {
369
370
  observations: obs,
370
371
  controlRefs: [],
371
372
  derivedProvenance: deriveComplianceProvenance([]),
373
+ obligationMappings: [],
372
374
  ...(evidence ? { evidence, partiallyEvidenced: evidence.tier === 'weak' || evidence.tier === 'unmeasured' } : {}),
373
375
  });
374
376
  continue;
@@ -391,6 +393,15 @@ export function evaluateFramework(scanRoot, fw, scan) {
391
393
  // control at 'partial' — never 'present' — regardless of what the
392
394
  // family:/module: mappings in the same control found.
393
395
  let hasUnverifiableMapping = false;
396
+ // Sub-project 6b: real ObligationMapping records minted by graph:
397
+ // predicates on this control, collected separately from the
398
+ // present/partial/absent/manual status machinery above — a graph:
399
+ // mapping never touches anySignal/allCleared/anyCleared/
400
+ // hasUnverifiableMapping (purely additive, per the plan's own Global
401
+ // Constraints). Always an array, never undefined, so a caller can
402
+ // safely read `.length` regardless of whether this control has any
403
+ // graph: mappings at all.
404
+ const obligationMappings = [];
394
405
  for (const m of maps) {
395
406
  if (m.startsWith('family:')) {
396
407
  // `family:X` and the subfamily-qualified `family:X:Y` (used by
@@ -492,6 +503,70 @@ export function evaluateFramework(scanRoot, fw, scan) {
492
503
  obs.push(`(rule mapping) ${m} — verify manually that the bodyguard rule is enabled.`);
493
504
  anySignal = true;
494
505
  hasUnverifiableMapping = true;
506
+ } else if (m.startsWith('graph:')) {
507
+ // A graph: mapping never contributes to anySignal/allCleared/
508
+ // anyCleared/hasUnverifiableMapping — the existing
509
+ // present/partial/absent/manual status stays driven entirely by
510
+ // the family:/module:/rule: mappings a control already has (this
511
+ // task's own Global Constraint: purely additive). It instead
512
+ // mints a real ObligationMapping record, collected separately.
513
+ //
514
+ // First real predicate (scoping doc ruling 6): PHI/PII flowing to
515
+ // an external sink must cross a protected transit edge. The
516
+ // predicate STRING itself is currently a fixed label (not yet a
517
+ // parsed mini-language) — the first real graph: mapping this
518
+ // sub-project ships is HIPAA §164.312(e)'s
519
+ // "graph:transit-protection:PHI:external:transit:protected",
520
+ // hardcoded to this one spec until a second real case proves the
521
+ // parsing is worth generalizing (YAGNI — do not invent a parser
522
+ // for one caller).
523
+ const spec = { type: 'graph-flow', dataClass: 'PHI', sinkKind: 'external', dimension: 'transit', requiredVerdict: 'protected' };
524
+ const graph = scan.lineageGraph ?? null;
525
+ // Belt-and-suspenders, mirroring the sibling _strengthOfControl
526
+ // call above: obligation-predicates.js is now defensively
527
+ // hardened to never throw (see its own header), but this wrap
528
+ // keeps a future regression there from taking down the whole
529
+ // evaluateFramework call for every control in the framework
530
+ // (found by the final whole-branch review).
531
+ let mapping = null;
532
+ try {
533
+ const evaluation = graph ? evaluateGraphFlowPredicate(spec, graph) : null;
534
+ mapping = buildObligationMappingFromGraphPredicate({
535
+ framework: fw.id,
536
+ frameworkVersion: fw.controlsDigest,
537
+ requirementId: c.id,
538
+ requirementSource: fw.url ?? null,
539
+ predicateLabel: m,
540
+ graph,
541
+ evaluation,
542
+ });
543
+ } catch { /* graph obligation mapping is additive; never block evaluation */ }
544
+ if (mapping) {
545
+ obligationMappings.push(mapping);
546
+ // Found by the final whole-branch review of sub-project 6b: a
547
+ // graph: mapping's state is intentionally independent of the
548
+ // pre-existing present/partial/absent/manual status glyph
549
+ // (Global Constraint — see the block comment above), so a
550
+ // control can render a green ✅ header with a 'gap_detected'
551
+ // line buried underneath it in the observations. A ⚠️ prefix on
552
+ // a genuine gap keeps that visible to a reader who only scans
553
+ // headers, without touching the status computation itself.
554
+ // RE-CONFIRMED (sub-project 6c's own final review, F4): at the
555
+ // time this ⚠️ prefix was written, `graph:` mappings could only
556
+ // ever read 'unknown' end-to-end through the real CLI (a
557
+ // separate, then-undiscovered bug — see obligation-predicates.js
558
+ // and cmdCompliance/cmdAttest's own fix history), so this exact
559
+ // ✅-header-hiding-a-real-gap scenario was structurally
560
+ // unreachable. Sub-project 6c's CLI fixes make it genuinely
561
+ // reachable for the first time; the ruling above was written
562
+ // anticipating exactly this and needs no change — confirmed
563
+ // live via a real assessed HIPAA §164.312(e) gap
564
+ // (test/cli/attest-obligations.test.js's own gap_detected case).
565
+ const flag = mapping.state === 'gap_detected' ? '⚠️ ' : '';
566
+ obs.push(`${flag}(graph mapping) ${m} -> ${mapping.state}.`);
567
+ } else {
568
+ obs.push(`(graph mapping) ${m} -> evaluation failed, skipped.`);
569
+ }
495
570
  }
496
571
  }
497
572
 
@@ -547,6 +622,7 @@ export function evaluateFramework(scanRoot, fw, scan) {
547
622
  observations: obs,
548
623
  controlRefs: dedupedRefs,
549
624
  derivedProvenance: deriveComplianceProvenance(contributingFindings),
625
+ obligationMappings,
550
626
  ...(evidence ? { evidence, partiallyEvidenced: evidence.tier === 'weak' || evidence.tier === 'unmeasured' } : {}),
551
627
  });
552
628
  }
@@ -5,7 +5,7 @@
5
5
  "license": "US Federal regulation (public)",
6
6
  "url": "https://www.ecfr.gov/current/title-45/subtitle-A/subchapter-C/part-164",
7
7
  "scope": "SELECTIVE SUBSET. 8 of the Security Rule technical safeguards. Administrative and physical safeguards are outside what a code scanner can observe and are NOT represented here.",
8
- "controlsDigest": "a99f9c4015ffbf72",
8
+ "controlsDigest": "7e94038dd9a64d3d",
9
9
  "controlCount": 8,
10
10
  "controls": [
11
11
  {
@@ -102,7 +102,8 @@
102
102
  ],
103
103
  "mapsTo": [
104
104
  "family:crypto-tls-no-verify",
105
- "family:crypto-tls-version"
105
+ "family:crypto-tls-version",
106
+ "graph:transit-protection:PHI:external:transit:protected"
106
107
  ]
107
108
  }
108
109
  ]
@@ -0,0 +1,202 @@
1
+ // obligation-evidence-pack.js — Milestone 4 sub-project 6c: signed,
2
+ // versioned evidence packs for a Regulatory Obligation Overlay framework
3
+ // evaluation (FR-504, PRD §10.10). Fourth sibling in the
4
+ // evidence-bundle.js family — see that module's own header for the
5
+ // shared-key rationale, and provenance-evidence-bundle.js for the most
6
+ // directly mirrored precedent (same reused ensureKeyPair/keyPaths/
7
+ // canonicalJson, own schema string, own build/sign/verify trio, own
8
+ // top-level-key allowlist).
9
+ //
10
+ // WHAT THIS IS NOT
11
+ // -----------------
12
+ // Not a reuse of evidence-bundle.js's own bundle shape (that's a single
13
+ // FINDING's evidence — proofTier/taintPath/etc; this artifact has no
14
+ // finding at all). Not compliance-evidence-signing.js's ComplianceEvidence
15
+ // manifest either — that signs the pre-existing family:/module:/rule:-
16
+ // driven present/partial/absent/manual walkthrough status; this signs the
17
+ // newer, additive graph: mapping type's own real ObligationMapping
18
+ // records (evaluateGraphFlowPredicate/buildObligationMappingFromGraphPredicate,
19
+ // sub-project 6b) — a distinct FR-504 artifact with its own field list
20
+ // (scope, framework versions, facts, evidence index, unknown/manual
21
+ // items, accepted exceptions, scan health, limitations, graph digest,
22
+ // reproducibility metadata).
23
+ //
24
+ // THE EVIDENCE-INDEX DESIGN DECISION (see the scoping doc for the full
25
+ // writeup)
26
+ // -----------------------------------------------------------------
27
+ // A real ObligationMapping record's own `evidence[]` is structurally
28
+ // always empty today (graph-builder.js hardcodes `edge.evidenceRefs: []`
29
+ // on every minted edge, disclosed in obligation-predicates.js's own
30
+ // header). Rather than ship an evidence pack whose "evidence index" is
31
+ // honestly, permanently empty, this module builds a REAL evidence index
32
+ // from each fact's own `contributingGraphIds` (real flow ids
33
+ // evaluateGraphFlowPredicate already returns) — resolving each flow id
34
+ // back into a small, real summary (source/sink kind, dataElement
35
+ // dataClasses, the edge's own transit/atRest/handling verdicts) by
36
+ // joining against the graph's own entity arrays. `record.evidence` is
37
+ // still carried through verbatim on each fact (honest, even though
38
+ // empty) — the evidence index is an ADDITIONAL section built from data
39
+ // that genuinely IS populated, not a replacement that hides the gap.
40
+
41
+ import { ensureKeyPair, keyPaths, canonicalJson } from './evidence-bundle.js';
42
+ import { EVIDENCE_GRADE_DISCLAIMER } from './evidence-grade-wording.js';
43
+ import { computeGraphDigest } from '../lineage/export-json.js';
44
+ import * as crypto from 'node:crypto';
45
+
46
+ export const OBLIGATION_EVIDENCE_PACK_SCHEMA = 'agentic-security/obligation-evidence-pack@1';
47
+
48
+ function _asArray(v) {
49
+ return Array.isArray(v) ? v : [];
50
+ }
51
+
52
+ /**
53
+ * Resolve one ObligationMapping fact's contributingGraphIds (real flow
54
+ * ids) into a small, real, human-and-machine-readable summary per flow —
55
+ * the evidence index's own per-fact contribution. Never throws: a flow id
56
+ * that no longer resolves against this graph (a stale pack re-evaluated
57
+ * against a newer graph, or a malformed fact) is simply skipped, not
58
+ * fabricated.
59
+ */
60
+ function _resolveEvidenceForFact(fact, joins) {
61
+ const { flowsById, nodesById, edgesById, dataElementsById } = joins;
62
+ const flowIds = _asArray(fact?.contributingGraphIds);
63
+ const resolved = [];
64
+ for (const flowId of flowIds) {
65
+ const flow = flowsById.get(flowId);
66
+ if (!flow) continue;
67
+ const sourceNode = nodesById.get(flow.source);
68
+ const sinkNode = nodesById.get(flow.sink);
69
+ // Reads only edgeIds[0] — mirrors obligation-predicates.js's own
70
+ // disclosed, currently-unreachable limitation (graph-builder.js always
71
+ // mints single-edge flows; see that module's own header) rather than
72
+ // inventing a different convention for the same case here.
73
+ const edge = edgesById.get(_asArray(flow.edgeIds)[0]);
74
+ const dataClasses = _asArray(flow.dataElementIds)
75
+ .map((id) => dataElementsById.get(id))
76
+ .filter(Boolean)
77
+ .flatMap((d) => _asArray(d.dataClasses));
78
+ resolved.push({
79
+ flowId,
80
+ source: sourceNode ? { kind: sourceNode.kind ?? null, subtype: sourceNode.subtype ?? null } : null,
81
+ sink: sinkNode ? { kind: sinkNode.kind ?? null, subtype: sinkNode.subtype ?? null } : null,
82
+ dataClasses: [...new Set(dataClasses)],
83
+ transitVerdict: edge?.protection?.transit?.verdict ?? null,
84
+ atRestVerdict: edge?.protection?.atRest?.verdict ?? null,
85
+ handlingVerdict: edge?.protection?.handling?.verdict ?? null,
86
+ });
87
+ }
88
+ return resolved;
89
+ }
90
+
91
+ function _buildJoins(graph) {
92
+ return {
93
+ flowsById: new Map(_asArray(graph?.flows).filter(Boolean).map((f) => [f.id, f])),
94
+ nodesById: new Map(_asArray(graph?.nodes).filter(Boolean).map((n) => [n.id, n])),
95
+ edgesById: new Map(_asArray(graph?.edges).filter(Boolean).map((e) => [e.id, e])),
96
+ dataElementsById: new Map(_asArray(graph?.dataElements).filter(Boolean).map((d) => [d.id, d])),
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Build an unsigned evidence pack from a framework evaluation. Never
102
+ * throws: every field degrades honestly on missing input rather than
103
+ * fabricating a value — a null/absent graph yields empty facts/
104
+ * evidenceIndex and a null graphDigest/scope, never a guess.
105
+ *
106
+ * @param {object} args
107
+ * @param {object|null} args.graph - the scan's DataFlowGraph v1 document (scan.lineageGraph), or null
108
+ * @param {object} args.framework - the loaded framework object (auditor-walkthrough.js#loadFramework's return)
109
+ * @param {Array} args.evaluation - auditor-walkthrough.js#evaluateFramework's return (per-control entries, each carrying .obligationMappings)
110
+ * @param {object|null} [args.scanHealth] - scan.scanHealth, passed through verbatim; null if not supplied, never fabricated
111
+ * @param {string|null} [args.engineVersion]
112
+ * @param {string|null} [args.rulesetVersion]
113
+ * @param {string|null} [args.bundleSha]
114
+ * @param {string} [args.generatedAt] - defaults to new Date().toISOString()
115
+ */
116
+ export function buildObligationEvidencePack({
117
+ graph, framework, evaluation, scanHealth, engineVersion, rulesetVersion, bundleSha, generatedAt,
118
+ } = {}) {
119
+ const facts = _asArray(evaluation).flatMap((e) => _asArray(e?.obligationMappings));
120
+ const joins = _buildJoins(graph);
121
+ const evidenceIndex = facts.map((fact) => ({
122
+ obligationId: fact?.id ?? null,
123
+ requirementId: fact?.requirementId ?? null,
124
+ evidence: _resolveEvidenceForFact(fact, joins),
125
+ }));
126
+
127
+ return {
128
+ schema: OBLIGATION_EVIDENCE_PACK_SCHEMA,
129
+ framework: {
130
+ id: framework?.id ?? null,
131
+ name: framework?.name ?? null,
132
+ version: framework?.controlsDigest ?? null,
133
+ publisher: framework?.publisher ?? null,
134
+ url: framework?.url ?? null,
135
+ },
136
+ scope: graph?.scope ?? null,
137
+ facts,
138
+ evidenceIndex,
139
+ unknownItems: facts.filter((f) => f?.state === 'unknown'),
140
+ manualItems: facts.filter((f) => f?.state === 'manual_required'),
141
+ acceptedExceptions: facts.filter((f) => f?.state === 'accepted_exception'),
142
+ scanHealth: scanHealth ?? null,
143
+ limitations: _asArray(graph?.limitations),
144
+ graphDigest: graph ? computeGraphDigest(graph) : null,
145
+ reproducibility: {
146
+ graphId: graph?.graphId ?? null,
147
+ graphDigest: graph ? computeGraphDigest(graph) : null,
148
+ engineVersion: engineVersion ?? null,
149
+ rulesetVersion: rulesetVersion ?? null,
150
+ bundleSha: bundleSha ?? null,
151
+ generatedAt: generatedAt ?? new Date().toISOString(),
152
+ },
153
+ disclaimer: EVIDENCE_GRADE_DISCLAIMER,
154
+ };
155
+ }
156
+
157
+ /** Sign a pack. Returns a new object; the input is not mutated. */
158
+ export function signObligationEvidencePack(pack, privateKeyPem) {
159
+ const sig = crypto.sign(null, Buffer.from(canonicalJson(pack), 'utf8'), privateKeyPem);
160
+ return {
161
+ ...pack,
162
+ signature: { algorithm: 'ed25519', canonicalisation: OBLIGATION_EVIDENCE_PACK_SCHEMA, value: sig.toString('base64') },
163
+ };
164
+ }
165
+
166
+ const OBLIGATION_EVIDENCE_PACK_TOP_LEVEL_KEYS = new Set([
167
+ 'schema', 'framework', 'scope', 'facts', 'evidenceIndex', 'unknownItems',
168
+ 'manualItems', 'acceptedExceptions', 'scanHealth', 'limitations',
169
+ 'graphDigest', 'reproducibility', 'disclaimer', 'signature',
170
+ ]);
171
+
172
+ /**
173
+ * Verify with a PUBLIC key only. Rejects any top-level key outside the
174
+ * allowlist BEFORE checking the signature — same EA-03 discipline every
175
+ * sibling in this family carries: a signature only covers the bytes it
176
+ * was computed over, so a key stapled on after signing would otherwise
177
+ * verify as authentic.
178
+ */
179
+ export function verifyObligationEvidencePack(pack, publicKeyPem) {
180
+ if (!pack || typeof pack !== 'object') return { ok: false, reason: 'pack is not an object' };
181
+ if (pack.schema !== OBLIGATION_EVIDENCE_PACK_SCHEMA) return { ok: false, reason: `unrecognised schema: ${pack.schema}` };
182
+ const unknownKeys = Object.keys(pack).filter((k) => !OBLIGATION_EVIDENCE_PACK_TOP_LEVEL_KEYS.has(k));
183
+ if (unknownKeys.length) {
184
+ return { ok: false, reason: `unrecognised top-level key(s) not covered by the signature: ${unknownKeys.join(', ')}` };
185
+ }
186
+ const sig = pack.signature;
187
+ if (!sig?.value) return { ok: false, reason: 'pack is unsigned' };
188
+ if (sig.algorithm !== 'ed25519') return { ok: false, reason: `unsupported algorithm: ${sig.algorithm}` };
189
+ if (!publicKeyPem) return { ok: false, reason: 'no public key supplied' };
190
+ const { signature, ...unsigned } = pack;
191
+ let ok = false;
192
+ try {
193
+ ok = crypto.verify(null, Buffer.from(canonicalJson(unsigned), 'utf8'), publicKeyPem, Buffer.from(sig.value, 'base64'));
194
+ } catch (e) {
195
+ return { ok: false, reason: `verification error: ${e.message}` };
196
+ }
197
+ return ok
198
+ ? { ok: true, reason: null }
199
+ : { ok: false, reason: 'signature does not match the pack contents — it was modified after signing' };
200
+ }
201
+
202
+ export { ensureKeyPair, keyPaths };