@clear-capabilities/agentic-security-scanner 0.144.0 → 0.147.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 (209) hide show
  1. package/CHANGELOG.md +390 -0
  2. package/bin/agentic-security.js +3813 -83
  3. package/dist/1122.index.js +702 -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} +13 -4
  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/2271.index.js +165 -0
  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 +307 -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} +14 -6
  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} +14 -6
  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} +67 -3
  35. package/dist/5051.index.js +770 -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} +29 -7
  42. package/dist/{449.index.js → 5830.index.js} +78 -14
  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} +26 -8
  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} +580 -187
  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/package.json +23 -10
  72. package/src/compare.js +6 -1
  73. package/src/dataflow/CLAUDE.md +2 -2
  74. package/src/dataflow/catalog.js +42 -0
  75. package/src/dataflow/orm-write-catalog.js +175 -0
  76. package/src/engine.js +580 -30
  77. package/src/fix/apply-fix-service.js +1 -0
  78. package/src/history-scan.js +22 -5
  79. package/src/ir/CLAUDE.md +2 -1
  80. package/src/ir/chrome-probe.mjs +150 -0
  81. package/src/ir/parser-js.js +94 -7
  82. package/src/lineage/CLAUDE.md +1203 -0
  83. package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
  84. package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
  85. package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
  86. package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
  87. package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
  88. package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
  89. package/src/lineage/DESIGN_REGISTRIES.md +880 -0
  90. package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
  91. package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
  92. package/src/lineage/classification.js +56 -0
  93. package/src/lineage/coverage.js +658 -0
  94. package/src/lineage/cross-repo-link.js +107 -0
  95. package/src/lineage/dataflow-graph.schema.json +184 -0
  96. package/src/lineage/decision-story.js +206 -0
  97. package/src/lineage/drift-policy.js +279 -0
  98. package/src/lineage/driver.js +135 -0
  99. package/src/lineage/engine.js +992 -0
  100. package/src/lineage/export-briefing.js +628 -0
  101. package/src/lineage/export-csv.js +62 -0
  102. package/src/lineage/export-json.js +238 -0
  103. package/src/lineage/export-privacy.js +258 -0
  104. package/src/lineage/federation-loader.js +111 -0
  105. package/src/lineage/field-identity.js +78 -0
  106. package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
  107. package/src/lineage/fixtures/flagship-graph.json +1453 -0
  108. package/src/lineage/flow-grade.js +221 -0
  109. package/src/lineage/governance-edit.js +169 -0
  110. package/src/lineage/graph-builder.js +1114 -0
  111. package/src/lineage/graph-diff.js +431 -0
  112. package/src/lineage/graph-snapshot.js +180 -0
  113. package/src/lineage/handling-analyzer.js +168 -0
  114. package/src/lineage/ids.js +349 -0
  115. package/src/lineage/impact-assessment.js +76 -0
  116. package/src/lineage/impact-engine.js +268 -0
  117. package/src/lineage/index.js +281 -0
  118. package/src/lineage/language-coverage-tiers.js +58 -0
  119. package/src/lineage/obligation-mapping.js +126 -0
  120. package/src/lineage/obligation-predicates.js +235 -0
  121. package/src/lineage/observation-adapters.js +282 -0
  122. package/src/lineage/observation-correlation.js +622 -0
  123. package/src/lineage/observation-store.js +497 -0
  124. package/src/lineage/path-query.js +410 -0
  125. package/src/lineage/path-store.js +400 -0
  126. package/src/lineage/protection.js +53 -0
  127. package/src/lineage/recipient-profile.js +192 -0
  128. package/src/lineage/recipient-registry.js +394 -0
  129. package/src/lineage/redact-graph.js +224 -0
  130. package/src/lineage/remediation.js +417 -0
  131. package/src/lineage/resolve-destination.js +91 -0
  132. package/src/lineage/runtime-observation.js +464 -0
  133. package/src/lineage/scenario-diff.js +84 -0
  134. package/src/lineage/scenario-engine.js +251 -0
  135. package/src/lineage/scenario.js +101 -0
  136. package/src/lineage/schema.js +167 -0
  137. package/src/lineage/sink-registry.js +427 -0
  138. package/src/lineage/source-registry.js +357 -0
  139. package/src/lineage/source-seeding.js +212 -0
  140. package/src/lineage/summaries.js +590 -0
  141. package/src/lineage/transform-catalog.js +397 -0
  142. package/src/lineage/transit-protection.js +150 -0
  143. package/src/lineage/validate.js +285 -0
  144. package/src/lsp/server.js +49 -2
  145. package/src/mcp/CLAUDE.md +7 -1
  146. package/src/mcp/dataflow-tools.js +160 -0
  147. package/src/mcp/server.js +1 -1
  148. package/src/mcp/tools.js +22 -1
  149. package/src/pipeline/assurance-mode.js +64 -1
  150. package/src/pipeline/finding-schema.js +8 -1
  151. package/src/pipeline/scan-health.js +19 -1
  152. package/src/posture/CLAUDE.md +140 -0
  153. package/src/posture/accuracy-scorecard.js +60 -0
  154. package/src/posture/artifact-registry.js +76 -0
  155. package/src/posture/auditor-walkthrough.js +192 -13
  156. package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
  157. package/src/posture/compliance-policy.js +12 -2
  158. package/src/posture/cross-repo-memory.js +7 -2
  159. package/src/posture/fix-history.js +25 -2
  160. package/src/posture/fix-verify.js +9 -1
  161. package/src/posture/fleet.js +0 -0
  162. package/src/posture/git-history.js +13 -5
  163. package/src/posture/material-change.js +21 -2
  164. package/src/posture/mttr.js +75 -12
  165. package/src/posture/obligation-evidence-pack.js +202 -0
  166. package/src/posture/pre-incident-archaeology.js +39 -7
  167. package/src/posture/privacy-framework.js +14 -0
  168. package/src/posture/provenance/ai-authorship.js +68 -0
  169. package/src/posture/provenance/branch-entry.js +80 -0
  170. package/src/posture/provenance/cache.js +143 -0
  171. package/src/posture/provenance/confidence.js +36 -0
  172. package/src/posture/provenance/coordinator.js +786 -0
  173. package/src/posture/provenance/dag-walk.js +249 -0
  174. package/src/posture/provenance/evidence-attribution.js +59 -0
  175. package/src/posture/provenance/git-evidence.js +310 -0
  176. package/src/posture/provenance/lifecycle.js +208 -0
  177. package/src/posture/provenance/missing-control-resolver.js +137 -0
  178. package/src/posture/provenance/origin-resolver.js +342 -0
  179. package/src/posture/provenance/predicate-replay.js +133 -0
  180. package/src/posture/provenance/providers/config.js +39 -0
  181. package/src/posture/provenance/providers/github.js +62 -0
  182. package/src/posture/provenance/providers/gitlab.js +58 -0
  183. package/src/posture/provenance/repo-lineage.js +74 -0
  184. package/src/posture/provenance/sca-origin.js +139 -0
  185. package/src/posture/provenance/schema.js +255 -0
  186. package/src/posture/provenance/transitive-sca.js +147 -0
  187. package/src/posture/provenance/validate.js +30 -0
  188. package/src/posture/provenance-evidence-bundle.js +144 -0
  189. package/src/posture/remediation-ledger.js +337 -0
  190. package/src/posture/sbom-diff.js +15 -2
  191. package/src/posture/secret-history.js +10 -2
  192. package/src/posture/state-dir.js +38 -14
  193. package/src/posture/vuln-archaeology.js +8 -2
  194. package/src/pr-delta.js +25 -4
  195. package/src/report/index.js +197 -3
  196. package/src/runScan.js +34 -5
  197. package/src/sast/rate-limit.js +33 -3
  198. package/src/server/CLAUDE.md +47 -0
  199. package/src/server/graph-loader.js +141 -0
  200. package/src/server/http-server.js +325 -0
  201. package/src/server/routes.js +129 -0
  202. package/src/server/security.js +111 -0
  203. package/src/server/static-assets.js +139 -0
  204. package/src/util/git-hardening.js +128 -0
  205. package/dist/11.index.js +0 -353
  206. package/dist/259.index.js +0 -975
  207. package/dist/317.index.js +0 -300
  208. package/dist/609.index.js +0 -741
  209. package/dist/838.index.js +0 -152
@@ -0,0 +1,400 @@
1
+ //
2
+ // path-store.js — the compact provenance DAG (Sub-project C, increment 4).
3
+ //
4
+ // Binding spec: DESIGN_PATH_PROVENANCE.md §14 (§14.1-§14.11). This module
5
+ // is a PURE CONSUMER of a hop-record stream — the 14-field shape §3/§13.0
6
+ // define, exactly as C1-C3 ship it. It does not run analysis, and it must
7
+ // NEVER import `engine.js`, `summaries.js`, or `driver.js` (§14.1): its
8
+ // only dependency is `ids.js`. This is a stronger, additional isolation
9
+ // boundary on top of the pre-existing "`src/lineage/` may import pure
10
+ // utilities from `src/dataflow/`, never that package's `engine.js`/
11
+ // `summaries.js`" rule, and it is what makes the store testable against a
12
+ // hand-built hop array with zero dependency on a real analysis run — a
13
+ // real project-wide driver run still emits ZERO hops today (no source
14
+ // registry, Sub-project D/E).
15
+ //
16
+ // Construction is cycle-safe BY CONSTRUCTION, not by discipline (§9.3,
17
+ // §14.6): one linear pass over the offered hops plus a per-group cross
18
+ // product, never a graph walk. There is no recursion anywhere in this
19
+ // file. The read API is deliberately traversal-free — every method below
20
+ // is an O(1)/O(degree) index lookup. Bounded backward reconstruction is
21
+ // C5's job and C5's alone.
22
+ //
23
+
24
+ import { provenanceNodeId, provenanceEdgeId } from './ids.js';
25
+
26
+ // §3 + §13.0's exact 14-field hop shape, in a FIXED order. Never derive a
27
+ // dedupe/discriminator key via `Object.keys(h)` — that is order-dependent,
28
+ // and would let two differently-key-ordered-but-identical hop objects hash
29
+ // to different keys. Listed explicitly so a hop with an ABSENT key (§3
30
+ // warns this is reachable for any emission path that bypasses
31
+ // `analyzeFunctionFieldIdentity`'s progressive stamping) is DETECTED here
32
+ // (`diagnostics().malformed`) instead of silently hashing to a different
33
+ // dedupe key than its fully-stamped twin — this is where §3's completeness
34
+ // guarantee becomes checkable rather than merely asserted (§14.6).
35
+ export const HOP_FIELDS = [
36
+ 'kind', 'subKind', 'scope', 'dataElementId', 'fromPath', 'toPath',
37
+ 'syntacticPath', 'nodeId', 'line', 'widenReason', 'lossReason',
38
+ 'context', 'peerScope', 'peerContext',
39
+ ];
40
+
41
+ const IN_KINDS = new Set(['production', 'selection']);
42
+
43
+ function hopDedupeKey(h) {
44
+ return HOP_FIELDS.map((f) => JSON.stringify(h[f] ?? null)).join('|');
45
+ }
46
+
47
+ function joinKeyOf(h) {
48
+ // §13.3's four-part join key, superseding §2.2's original three-part form.
49
+ return [h.scope, h.nodeId, h.dataElementId, h.context]
50
+ .map((p) => JSON.stringify(p ?? null)).join('|');
51
+ }
52
+
53
+ function scopeKeyOf(scope, context) {
54
+ return `${JSON.stringify(scope ?? null)}|${JSON.stringify(context ?? null)}`;
55
+ }
56
+
57
+ /**
58
+ * §14.3: which node does an inbound half (`production`/`selection`) start
59
+ * at?
60
+ *
61
+ * Rules, in order:
62
+ * 1. `fromPath !== null` -> sourced, at `(scope, context, path, id)`.
63
+ * 2. `fromPath === null && peerScope !== null && lossReason === null` ->
64
+ * peer-sourced, at `(peerScope, peerContext, <return>, id)`. See §14.4
65
+ * — this is the single most load-bearing rule in the whole module.
66
+ * 3. otherwise -> annotation.
67
+ */
68
+ export function classifyIn(h) {
69
+ if (h.fromPath !== null && h.fromPath !== undefined) {
70
+ return {
71
+ role: 'source',
72
+ crossScope: false,
73
+ node: {
74
+ kind: 'path', scope: h.scope, context: h.context,
75
+ path: h.fromPath, siteNodeId: null, dataElementId: h.dataElementId,
76
+ },
77
+ };
78
+ }
79
+ // §14.4, THE CORRECTION TO §2.2: a null `fromPath` with a non-null
80
+ // `peerScope` is NOT source-less. It is PEER-ADDRESSED — its source is
81
+ // the callee's own function-exit node. `lossReason === null` is the
82
+ // discriminator that keeps a §13.6 context-cap-degraded hop out of this
83
+ // branch: that hop names a callee whose body was never analyzed, so an
84
+ // edge from its (non-existent) exit node would be a fabricated origin —
85
+ // it falls through to annotation instead, exactly what §13.6 asked for.
86
+ if (h.peerScope !== null && h.peerScope !== undefined && h.lossReason == null) {
87
+ return {
88
+ role: 'source',
89
+ crossScope: true,
90
+ node: {
91
+ kind: 'return', scope: h.peerScope, context: h.peerContext,
92
+ path: null, siteNodeId: null, dataElementId: h.dataElementId,
93
+ },
94
+ };
95
+ }
96
+ return { role: 'annotation', crossScope: false, node: null };
97
+ }
98
+
99
+ /**
100
+ * §14.3: which node does an outbound half (`write-out`) land at?
101
+ *
102
+ * Rules, in order:
103
+ * 1. `toPath !== null && peerScope !== null` -> peer-targeted, at
104
+ * `(peerScope, peerContext, toPath, id)` — Q1's answer.
105
+ * 2. `toPath !== null` -> targeted, at `(scope, context, toPath, id)`.
106
+ * 3. `subKind === 'return'` -> the `return` terminal (per function-context).
107
+ * 4. `subKind === 'call-arg'` -> the `escape` terminal (per CFG node).
108
+ * 5. `lossReason !== null` -> the `loss` terminal (per CFG node).
109
+ * 6. otherwise -> unclassified, recorded and never silently dropped.
110
+ */
111
+ export function classifyOut(h) {
112
+ if (h.toPath !== null && h.toPath !== undefined) {
113
+ const cross = h.peerScope !== null && h.peerScope !== undefined;
114
+ return {
115
+ role: 'target',
116
+ crossScope: cross,
117
+ node: {
118
+ kind: 'path',
119
+ scope: cross ? h.peerScope : h.scope,
120
+ context: cross ? h.peerContext : h.context,
121
+ path: h.toPath, siteNodeId: null, dataElementId: h.dataElementId,
122
+ },
123
+ };
124
+ }
125
+ // Terminal out-halves. `return` is per-(scope, context) — it must be,
126
+ // because a `call-resolved` hop addresses it with only
127
+ // (peerScope, peerContext) and no node id, so every return site of a
128
+ // context must aggregate into one exit node. `escape`/`loss` are
129
+ // per-CFG-node, since nothing addresses them from elsewhere and the
130
+ // extra precision tells a reader WHICH call/assignment ended the path.
131
+ if (h.subKind === 'return') {
132
+ return {
133
+ role: 'target', crossScope: false,
134
+ node: { kind: 'return', scope: h.scope, context: h.context, path: null, siteNodeId: null, dataElementId: h.dataElementId },
135
+ };
136
+ }
137
+ if (h.subKind === 'call-arg') {
138
+ return {
139
+ role: 'target', crossScope: false,
140
+ node: { kind: 'escape', scope: h.scope, context: h.context, path: null, siteNodeId: h.nodeId, dataElementId: h.dataElementId },
141
+ };
142
+ }
143
+ if (h.lossReason != null) {
144
+ return {
145
+ role: 'target', crossScope: false,
146
+ node: { kind: 'loss', scope: h.scope, context: h.context, path: null, siteNodeId: h.nodeId, dataElementId: h.dataElementId },
147
+ };
148
+ }
149
+ return { role: 'unclassified', crossScope: false, node: null };
150
+ }
151
+
152
+ export class PathStore {
153
+ constructor() {
154
+ this._groups = new Map();
155
+ this._seen = new Set();
156
+ this._truncations = new Map();
157
+ this._malformed = [];
158
+ this._unclassified = [];
159
+ // Every peer-sourced in-half accepted so far (crossScope === true from
160
+ // classifyIn), tracked so diagnostics() can report §14.4's disclosed
161
+ // stream-completeness gap (`orphanedPeerSources`) at build-finalize
162
+ // time without a second pass over the raw hops.
163
+ this._peerSourced = [];
164
+ this._stats = { hopsOffered: 0, hopsAccepted: 0 };
165
+ this._built = null;
166
+ }
167
+
168
+ /**
169
+ * Accepts one hop record. Returns `true` if it was newly accepted
170
+ * (added to a group), `false` if it was an exact repeat of an
171
+ * already-accepted hop (Decision 8's worklist re-emission, collapsed
172
+ * here — §14.6 dedup boundary 1). A malformed hop (missing a required
173
+ * field) is still recorded in `diagnostics().malformed`, never thrown.
174
+ */
175
+ addHop(hop) {
176
+ this._stats.hopsOffered += 1;
177
+ const missing = HOP_FIELDS.filter((f) => !Object.prototype.hasOwnProperty.call(hop, f));
178
+ if (missing.length > 0) {
179
+ this._malformed.push({ hop, missing });
180
+ }
181
+ const k = hopDedupeKey(hop);
182
+ if (this._seen.has(k)) return false;
183
+ this._seen.add(k);
184
+ this._stats.hopsAccepted += 1;
185
+
186
+ const jk = joinKeyOf(hop);
187
+ let g = this._groups.get(jk);
188
+ if (!g) {
189
+ g = {
190
+ scope: hop.scope, nodeId: hop.nodeId, dataElementId: hop.dataElementId,
191
+ context: hop.context, line: hop.line ?? null,
192
+ in: [], out: [], annotations: [],
193
+ };
194
+ this._groups.set(jk, g);
195
+ }
196
+ if (IN_KINDS.has(hop.kind)) {
197
+ const c = classifyIn(hop);
198
+ if (c.role === 'source') {
199
+ g.in.push({ hop, ...c });
200
+ if (c.crossScope) this._peerSourced.push({ hop, node: c.node });
201
+ } else {
202
+ g.annotations.push(hop);
203
+ }
204
+ } else if (hop.kind === 'write-out') {
205
+ const c = classifyOut(hop);
206
+ if (c.role === 'target') g.out.push({ hop, ...c });
207
+ else this._unclassified.push(hop);
208
+ } else {
209
+ this._unclassified.push(hop);
210
+ }
211
+ this._built = null;
212
+ return true;
213
+ }
214
+
215
+ /** Bulk `addHop`. Returns the count newly accepted. */
216
+ addHops(hops) {
217
+ let n = 0;
218
+ for (const h of hops) if (this.addHop(h)) n += 1;
219
+ return n;
220
+ }
221
+
222
+ /**
223
+ * §9.5's reserved out-of-band channel. Analysis-level (not per-hop)
224
+ * truncation — an `ITER_BUDGET` break in `analyzeFunctionFieldIdentity`
225
+ * — has no hop representation and deliberately must not get one (§2.2's
226
+ * three-kind taxonomy is closed, per §14.8). Marks every node and edge
227
+ * in `(scope, context)` as `truncated: true`.
228
+ */
229
+ markTruncated(scope, context, reason) {
230
+ this._truncations.set(scopeKeyOf(scope, context), reason);
231
+ this._built = null;
232
+ }
233
+
234
+ _build() {
235
+ if (this._built) return this._built;
236
+ const nodes = new Map();
237
+ const edges = new Map();
238
+ const outIndex = new Map();
239
+ const inIndex = new Map();
240
+
241
+ const intern = (desc) => {
242
+ const id = provenanceNodeId(desc);
243
+ let n = nodes.get(id);
244
+ if (!n) {
245
+ n = {
246
+ id, ...desc,
247
+ truncated: this._truncations.has(scopeKeyOf(desc.scope, desc.context)),
248
+ };
249
+ nodes.set(id, n);
250
+ }
251
+ return n;
252
+ };
253
+
254
+ for (const g of this._groups.values()) {
255
+ let sources = g.in;
256
+ let originated = false;
257
+ // Populated only when `originated` below — the union of every
258
+ // annotation's widen/loss reason at this key, so an edge originating
259
+ // here never loses a reason just because it wasn't the first
260
+ // annotation delivered (final whole-branch review finding 6: picking
261
+ // bare `g.annotations[0]` made both the representative kind/subKind
262
+ // AND the edge's own widenReasons/lossReasons depend on hop delivery
263
+ // order, contradicting §14.6's own "order-insensitive" guarantee —
264
+ // unreachable via any real fixture today, since `origin` is
265
+ // hand-fixture-only per §14.2, but §14.2 also names this as the
266
+ // exact shape a Sub-project D source registry will produce first).
267
+ let originWidenReasons = [];
268
+ let originLossReasons = [];
269
+ if (sources.length === 0 && g.annotations.length > 0) {
270
+ // §2.2's annotation rule, in the ONE direction it is still right:
271
+ // with no non-null in-half anywhere at this key, the annotation is
272
+ // itself the origin of the value ("no prior aliasing source").
273
+ originated = true;
274
+ originWidenReasons = [...new Set(g.annotations.map((a) => a.widenReason).filter((r) => r != null))].sort();
275
+ originLossReasons = [...new Set(g.annotations.map((a) => a.lossReason).filter((r) => r != null))].sort();
276
+ // The representative annotation for `kind`/`subKind` display is
277
+ // chosen by a content-derived sort key, not delivery position, so
278
+ // it is stable across re-deliveries in any order.
279
+ const representative = [...g.annotations].sort((a, b) => {
280
+ const ka = `${a.kind}|${a.subKind}`;
281
+ const kb = `${b.kind}|${b.subKind}`;
282
+ return ka < kb ? -1 : ka > kb ? 1 : 0;
283
+ })[0];
284
+ sources = [{
285
+ hop: representative, role: 'source', crossScope: false,
286
+ node: { kind: 'origin', scope: g.scope, context: g.context, path: null, siteNodeId: g.nodeId, dataElementId: g.dataElementId },
287
+ }];
288
+ }
289
+ // §9.1's correlation ambiguity, extended to the call boundary by
290
+ // §13.2's own disclosure — measured PER PAIRING, not per group
291
+ // (§14.7's correction to §9.1's original group-level measure, which
292
+ // over-marks: it fires on every edge at a resolved call site,
293
+ // including the two that are exactly right, because the argument's
294
+ // in-half and the return's in-half share one join key). Counting
295
+ // only the pairings the store would ACTUALLY form (after the peer x
296
+ // peer exclusion) recovers the distinction at zero cost.
297
+ const pairable = (s, o) => !(s.crossScope && o.crossScope);
298
+ const sourcesFor = (o) => new Set(sources.filter((s) => pairable(s, o)).map((s) => provenanceNodeId(s.node)));
299
+ const targetsFor = (s) => new Set(g.out.filter((o) => pairable(s, o)).map((o) => provenanceNodeId(o.node)));
300
+
301
+ const annotations = g.annotations.map((a) => ({
302
+ kind: a.kind, subKind: a.subKind,
303
+ widenReason: a.widenReason ?? null, lossReason: a.lossReason ?? null,
304
+ peerScope: a.peerScope ?? null, peerContext: a.peerContext ?? null,
305
+ }));
306
+
307
+ for (const s of sources) {
308
+ for (const o of g.out) {
309
+ // §14.3: an edge with BOTH endpoints in the peer's namespace is
310
+ // never a caller-side fact — it is a transition entirely inside
311
+ // the callee, which the callee's own hops already record, and
312
+ // materializing it manufactures a callee-return -> callee-param
313
+ // cycle that no program ever executed.
314
+ if (!pairable(s, o)) continue;
315
+ const from = intern(s.node);
316
+ const to = intern(o.node);
317
+ const widenReasons = originated
318
+ ? [...new Set([...originWidenReasons, o.hop.widenReason].filter((r) => r != null))].sort()
319
+ : [...new Set([s.hop.widenReason, o.hop.widenReason].filter((r) => r != null))].sort();
320
+ const lossReasons = originated
321
+ ? [...new Set([...originLossReasons, o.hop.lossReason].filter((r) => r != null))].sort()
322
+ : [...new Set([s.hop.lossReason, o.hop.lossReason].filter((r) => r != null))].sort();
323
+ const desc = {
324
+ fromNodeId: from.id, toNodeId: to.id, dataElementId: g.dataElementId,
325
+ scope: g.scope, context: g.context, siteNodeId: g.nodeId,
326
+ inKind: s.hop.kind, inSubKind: s.hop.subKind,
327
+ outKind: o.hop.kind, outSubKind: o.hop.subKind,
328
+ widenReasons, lossReasons,
329
+ };
330
+ const id = provenanceEdgeId(desc);
331
+ if (!edges.has(id)) {
332
+ edges.set(id, {
333
+ id, ...desc, line: g.line,
334
+ crossScope: s.crossScope || o.crossScope,
335
+ originated,
336
+ ambiguousCorrelation: sourcesFor(o).size >= 2 && targetsFor(s).size >= 2,
337
+ annotations,
338
+ truncated: this._truncations.has(scopeKeyOf(g.scope, g.context)),
339
+ });
340
+ if (!outIndex.has(from.id)) outIndex.set(from.id, new Set());
341
+ if (!inIndex.has(to.id)) inIndex.set(to.id, new Set());
342
+ outIndex.get(from.id).add(id);
343
+ inIndex.get(to.id).add(id);
344
+ }
345
+ }
346
+ }
347
+ }
348
+
349
+ // §14.4's disclosed stream-completeness gap / §14.10 item 10: a
350
+ // peer-sourced hop (lossReason: null, non-null peerScope) whose named
351
+ // (peerScope, peerContext, <return>, dataElementId) node has ZERO real
352
+ // in-edges once the whole stream has been ingested — reachable via a
353
+ // cache warmed by a no-recorder run and reused by a later
354
+ // recorder-attached run (driver.js's own returned cache). Detected here
355
+ // via the store's own inIndex at build-finalize time. Recorded, never
356
+ // fabricated into an origin, never dropped.
357
+ const orphanedPeerSources = [];
358
+ for (const { hop, node } of this._peerSourced) {
359
+ const id = provenanceNodeId(node);
360
+ const inEdges = inIndex.get(id);
361
+ if (!inEdges || inEdges.size === 0) {
362
+ orphanedPeerSources.push({ hop, nodeId: id });
363
+ }
364
+ }
365
+
366
+ this._built = { nodes, edges, outIndex, inIndex, orphanedPeerSources };
367
+ return this._built;
368
+ }
369
+
370
+ // ---- minimal read API (§14.6/§14.9). C4 ships NO traversal: every
371
+ // method below is an O(1)/O(degree) index lookup, so no code in this
372
+ // increment can recurse into a cycle. Bounded backward reconstruction is
373
+ // C5's job and C5's alone.
374
+ nodes() { return [...this._build().nodes.values()]; }
375
+ edges() { return [...this._build().edges.values()]; }
376
+ getNode(id) { return this._build().nodes.get(id) ?? null; }
377
+ getEdge(id) { return this._build().edges.get(id) ?? null; }
378
+ nodeIdFor(desc) { return provenanceNodeId(desc); }
379
+ edgesFrom(nodeId) { return [...(this._build().outIndex.get(nodeId) ?? [])].map((e) => this._build().edges.get(e)); }
380
+ edgesTo(nodeId) { return [...(this._build().inIndex.get(nodeId) ?? [])].map((e) => this._build().edges.get(e)); }
381
+ hasEdge(fromId, toId) { return this.edgesFrom(fromId).some((e) => e.toNodeId === toId); }
382
+ stats() { return { ...this._stats, groups: this._groups.size, nodes: this._build().nodes.size, edges: this._build().edges.size }; }
383
+
384
+ /**
385
+ * Four buckets, all "recorded, never thrown, never silently dropped"
386
+ * (§14.9/§14.10 item 10):
387
+ * - `malformed`: hops missing a required field.
388
+ * - `unclassified`: out-halves matching none of §14.3's rules.
389
+ * - `truncations`: `(scope, context)` pairs marked via `markTruncated`.
390
+ * - `orphanedPeerSources`: §14.4's disclosed stream-completeness gap.
391
+ */
392
+ diagnostics() {
393
+ return {
394
+ malformed: this._malformed,
395
+ unclassified: this._unclassified,
396
+ truncations: [...this._truncations.entries()],
397
+ orphanedPeerSources: this._build().orphanedPeerSources,
398
+ };
399
+ }
400
+ }
@@ -0,0 +1,53 @@
1
+ //
2
+ // Protection verdict model (Data Flow Explorer PRD section 14.1 + 8.4).
3
+ // Every protection dimension carries two INDEPENDENT fields: a verdict
4
+ // and an evidence grade — "declared protected" must never render
5
+ // identically to code-and-configuration-proven protection (PRD 14.1).
6
+ //
7
+ // This module defines the model only (enums, the empty/default shape,
8
+ // and the pure aggregation function). The analyzers that actually DECIDE
9
+ // a verdict per edge (transit/at-rest/handling — PRD FR-401 through
10
+ // FR-403) are Milestone 2 (DFG-010), not this module.
11
+
12
+ export const PROTECTION_VERDICTS = Object.freeze(['protected', 'unprotected', 'unknown', 'not_applicable', 'not_assessed']);
13
+
14
+ export const EVIDENCE_GRADES = Object.freeze(['runtime', 'code_and_config', 'code', 'config', 'declared', 'assumed', 'manual', 'none']);
15
+
16
+ export const PROTECTION_DIMENSIONS = Object.freeze(['transit', 'atRest', 'handling']);
17
+
18
+ export function emptyProtection() {
19
+ const dim = () => ({ verdict: 'not_assessed', evidenceGrade: 'none' });
20
+ return { transit: dim(), atRest: dim(), handling: dim() };
21
+ }
22
+
23
+ export function isValidProtectionDimension(d) {
24
+ if (!d || typeof d !== 'object') return false;
25
+ return PROTECTION_VERDICTS.includes(d.verdict) && EVIDENCE_GRADES.includes(d.evidenceGrade);
26
+ }
27
+
28
+ // PRD section 8.4: "For an aggregated path, visible risk precedence is
29
+ // unprotected/prohibited -> mixed -> unknown/manual_required ->
30
+ // protected/permitted -> not_assessed." Lower index = higher precedence
31
+ // (wins the aggregation). 'mixed' is not itself in PROTECTION_VERDICTS —
32
+ // it is a caller-supplied aggregate state from an upstream step (e.g. "one
33
+ // branch protected, one branch unprotected") that this function's own
34
+ // ranking table must still place correctly among the five base verdicts.
35
+ const _PRECEDENCE = ['unprotected', 'mixed', 'unknown', 'protected', 'not_applicable', 'not_assessed'];
36
+
37
+ /**
38
+ * Reduce a set of verdicts (protection verdicts, or 'mixed') to the single
39
+ * highest-precedence one. Never guesses: an empty array is 'not_assessed',
40
+ * and an unrecognized verdict throws rather than silently sorting last —
41
+ * a typo here must not quietly rank as "safest".
42
+ */
43
+ export function aggregateVerdicts(verdicts) {
44
+ if (!Array.isArray(verdicts) || verdicts.length === 0) return 'not_assessed';
45
+ let best = null;
46
+ let bestRank = Infinity;
47
+ for (const v of verdicts) {
48
+ const rank = _PRECEDENCE.indexOf(v);
49
+ if (rank === -1) throw new Error(`aggregateVerdicts: unrecognized verdict "${v}"`);
50
+ if (rank < bestRank) { bestRank = rank; best = v; }
51
+ }
52
+ return best;
53
+ }
@@ -0,0 +1,192 @@
1
+ // recipient-profile.js — Milestone 4 sub-project: the RecipientProfile
2
+ // extension contract (FR-506 §7.12, "Third-Party and Cross-Border
3
+ // Intelligence", PRD §10.10's own field list, PRD lines 541-548).
4
+ //
5
+ // A PURE schema/validation module for RecipientProfile records — mirrors
6
+ // obligation-mapping.js's own STRUCTURE closely: a §10.10 extension
7
+ // record ("associated with, but not required inside" the immutable base
8
+ // graph, per PRD §10.10) — never added to dataflow-graph.schema.json,
9
+ // never routed through validate.js's validateGraph(), never given a
10
+ // node:/edge:/flow:/data: canonical ID. See ids.js's recipientProfileId()
11
+ // for the id scheme, mirroring obligationId()'s own precedent exactly (a
12
+ // real, stable-ID'd extension record deliberately outside the
13
+ // node:/edge:/flow: family).
14
+ //
15
+ // This task does NOT populate real records from real data — that is two
16
+ // later, separate tasks (a small curated technical-provider catalog,
17
+ // code-derived; and an operator-declared config file, for facts code can
18
+ // never determine on its own, like legal entity/jurisdiction/DPA status).
19
+ // This file only defines what a valid record looks like.
20
+ //
21
+ // Two deliberate, disclosed departures from obligation-mapping.js's own
22
+ // precedent, both real architectural decisions, not oversights:
23
+ //
24
+ // 1. PER-FIELD evidence typing, not ObligationMapping's single
25
+ // record-level `factType`. ObligationMapping is one predicate
26
+ // evaluated once, so one factType describing how that predicate's
27
+ // truth was established is enough. A RecipientProfile is a bag of
28
+ // independent facts about one external recipient, populated from TWO
29
+ // genuinely different sources on the SAME record: a curated technical
30
+ // catalog can code-derive `provider`/`serviceType`/`technicalEndpoint`
31
+ // from what the code actually calls, but `legalEntity`/`jurisdiction`-
32
+ // adjacent facts like `dpaStatus`/`transferMechanism`/etc. can only
33
+ // ever be operator-declared — no code path can ever discover a
34
+ // vendor's DPA status by reading source. Collapsing that onto one
35
+ // record-level factType would force every profile to claim either
36
+ // "everything here is code-derived" (false for the declared fields) or
37
+ // "everything here is declared" (throws away the real code-derived
38
+ // provenance the catalog module DOES have) — so `fieldEvidence` gives
39
+ // each populated field its own `{factType, source}` pair instead. This
40
+ // is what makes "never fabricate a fact with no disclosed source" a
41
+ // STRUCTURALLY enforced property of this record shape, not merely a
42
+ // convention repeated from ObligationMapping without adaptation.
43
+ //
44
+ // 2. This file imports `OBLIGATION_FACT_TYPES` from `./obligation-mapping.js`
45
+ // — the first time one §10.10 extension-contract module imports from
46
+ // another. (obligation-mapping.js itself has zero imports, by design,
47
+ // per its own header comment — that is a property of THAT module, not
48
+ // a rule this file is bound by.) The fact-type vocabulary a graph fact
49
+ // claim can carry (code_inferred/config_correlated/runtime_observed/
50
+ // declared/manual/hypothetical) is one general concept with nothing
51
+ // obligation-specific about it — it is already this codebase's real,
52
+ // shipped vocabulary for "how was this fact established." Reusing it
53
+ // here is exactly what this package's own CLAUDE.md "Conventions"
54
+ // section already requires ("every enum here is a single source of
55
+ // truth for its concept") — minting a second, equivalent enum under a
56
+ // new name would violate that rule, not respect it.
57
+
58
+ import { OBLIGATION_FACT_TYPES } from './obligation-mapping.js';
59
+
60
+ export const RECIPIENT_PROCESSOR_ROLES = Object.freeze(['processor', 'controller', 'joint_controller', 'unknown']);
61
+ export const RECIPIENT_DPA_STATUSES = Object.freeze(['in_place', 'not_in_place', 'unknown']);
62
+ export const RECIPIENT_CONFIDENCE_LEVELS = Object.freeze(['high', 'medium', 'low']);
63
+
64
+ // The complete set of "real recipient fact" fields, per the task brief's
65
+ // own field list (PRD lines 541-548 — no fewer). Every field in this list
66
+ // whose value is non-null (scalar) or non-empty (array) MUST have a
67
+ // matching `fieldEvidence` key naming a real factType. Record-level
68
+ // metadata — `contributingGraphIds`/`confidence`/`owner`/`reviewDate`/
69
+ // `conflicts`/`expiration` — is deliberately NOT in this list: it
70
+ // describes the RECORD, not a recipient fact, and needs no per-field
71
+ // provenance tag of its own (mirrors ObligationMapping's own
72
+ // `contributingGraphIds` field, which carries no factType either).
73
+ export const RECIPIENT_FACT_FIELDS = Object.freeze([
74
+ 'technicalEndpoint', 'provider', 'serviceType', 'legalEntity',
75
+ 'processorRole', 'servicePurpose', 'subprocessorChain',
76
+ 'processingCountries', 'dataResidencyCommitment', 'observedRegion',
77
+ 'dpaStatus', 'transferMechanism', 'transferImpactReviewStatus',
78
+ 'retentionCommitment',
79
+ ]);
80
+
81
+ const _ARRAY_FACT_FIELDS = new Set(['subprocessorChain', 'processingCountries']);
82
+
83
+ function _isNonEmptyString(v) {
84
+ return typeof v === 'string' && v.length > 0;
85
+ }
86
+
87
+ function _isStringOrNull(v) {
88
+ return v === null || v === undefined || typeof v === 'string';
89
+ }
90
+
91
+ function _isStringArray(v) {
92
+ return Array.isArray(v) && v.every((x) => typeof x === 'string');
93
+ }
94
+
95
+ function _isPopulated(field, value) {
96
+ if (_ARRAY_FACT_FIELDS.has(field)) return Array.isArray(value) && value.length > 0;
97
+ return value !== null && value !== undefined;
98
+ }
99
+
100
+ /**
101
+ * Structural validation only — no cross-reference into any real graph
102
+ * (this module reuses only a shared enum from obligation-mapping.js; it
103
+ * has zero graph access by design). Returns {valid, errors} — errors is
104
+ * an array of {path, message}, mirroring validateObligationMapping's own
105
+ * shape exactly. Never throws.
106
+ */
107
+ export function validateRecipientProfile(record) {
108
+ const errors = [];
109
+ const err = (path, message) => errors.push({ path, message });
110
+
111
+ if (!record || typeof record !== 'object' || Array.isArray(record)) {
112
+ err('$', 'recipient profile record must be an object');
113
+ return { valid: false, errors };
114
+ }
115
+
116
+ if (!_isNonEmptyString(record.id) || !record.id.startsWith('recipient:')) {
117
+ err('$.id', 'id is required and must start with "recipient:"');
118
+ }
119
+ if (!_isNonEmptyString(record.graphId)) err('$.graphId', 'graphId is required');
120
+ if (!_isNonEmptyString(record.graphDigest)) err('$.graphDigest', 'graphDigest is required');
121
+ if (!_isNonEmptyString(record.recipientKey)) err('$.recipientKey', 'recipientKey is required');
122
+
123
+ if (!_isStringOrNull(record.technicalEndpoint)) err('$.technicalEndpoint', 'technicalEndpoint must be a string or null');
124
+ if (!_isStringOrNull(record.provider)) err('$.provider', 'provider must be a string or null');
125
+ if (!_isStringOrNull(record.serviceType)) err('$.serviceType', 'serviceType must be a string or null');
126
+ if (!_isStringOrNull(record.legalEntity)) err('$.legalEntity', 'legalEntity must be a string or null');
127
+ if (!_isStringOrNull(record.servicePurpose)) err('$.servicePurpose', 'servicePurpose must be a string or null');
128
+ if (!_isStringOrNull(record.dataResidencyCommitment)) err('$.dataResidencyCommitment', 'dataResidencyCommitment must be a string or null');
129
+ if (!_isStringOrNull(record.observedRegion)) err('$.observedRegion', 'observedRegion must be a string or null');
130
+ if (!_isStringOrNull(record.transferMechanism)) err('$.transferMechanism', 'transferMechanism must be a string or null');
131
+ if (!_isStringOrNull(record.transferImpactReviewStatus)) err('$.transferImpactReviewStatus', 'transferImpactReviewStatus must be a string or null');
132
+ if (!_isStringOrNull(record.retentionCommitment)) err('$.retentionCommitment', 'retentionCommitment must be a string or null');
133
+ if (!_isStringOrNull(record.owner)) err('$.owner', 'owner must be a string or null');
134
+ if (!_isStringOrNull(record.reviewDate)) err('$.reviewDate', 'reviewDate must be a string or null');
135
+ if (!_isStringOrNull(record.expiration)) err('$.expiration', 'expiration must be a string or null');
136
+
137
+ if (record.processorRole !== null && record.processorRole !== undefined && !RECIPIENT_PROCESSOR_ROLES.includes(record.processorRole)) {
138
+ err('$.processorRole', `unrecognized processorRole "${record.processorRole}" — must be null or one of ${RECIPIENT_PROCESSOR_ROLES.join('|')}`);
139
+ }
140
+ if (record.dpaStatus !== null && record.dpaStatus !== undefined && !RECIPIENT_DPA_STATUSES.includes(record.dpaStatus)) {
141
+ err('$.dpaStatus', `unrecognized dpaStatus "${record.dpaStatus}" — must be null or one of ${RECIPIENT_DPA_STATUSES.join('|')}`);
142
+ }
143
+ if (record.confidence !== null && record.confidence !== undefined && !RECIPIENT_CONFIDENCE_LEVELS.includes(record.confidence)) {
144
+ err('$.confidence', `unrecognized confidence "${record.confidence}" — must be null or one of ${RECIPIENT_CONFIDENCE_LEVELS.join('|')}`);
145
+ }
146
+
147
+ if (!_isStringArray(record.subprocessorChain ?? [])) err('$.subprocessorChain', 'subprocessorChain must be an array of strings');
148
+ if (!_isStringArray(record.contributingGraphIds ?? [])) err('$.contributingGraphIds', 'contributingGraphIds must be an array of strings');
149
+ if (!_isStringArray(record.conflicts ?? [])) err('$.conflicts', 'conflicts must be an array of strings');
150
+
151
+ const countries = record.processingCountries ?? [];
152
+ if (!_isStringArray(countries)) {
153
+ err('$.processingCountries', 'processingCountries must be an array of strings');
154
+ } else {
155
+ countries.forEach((c, i) => {
156
+ if (!/^[A-Z]{2}$/.test(c)) {
157
+ err(`$.processingCountries[${i}]`, `processingCountries entry "${c}" must be a 2-uppercase-letter ISO-3166-alpha-2-shaped string`);
158
+ }
159
+ });
160
+ }
161
+
162
+ // The load-bearing structural rule: every populated fact field must
163
+ // have a matching fieldEvidence entry naming a real factType from
164
+ // OBLIGATION_FACT_TYPES, and every fieldEvidence key must correspond to
165
+ // a real fact field — a typo'd key would otherwise silently produce an
166
+ // unenforced, orphaned evidence entry.
167
+ if (!record.fieldEvidence || typeof record.fieldEvidence !== 'object' || Array.isArray(record.fieldEvidence)) {
168
+ err('$.fieldEvidence', 'fieldEvidence is required and must be an object');
169
+ } else {
170
+ for (const field of RECIPIENT_FACT_FIELDS) {
171
+ if (!_isPopulated(field, record[field])) continue; // null/empty needs no evidence — nothing to attribute
172
+ const ev = record.fieldEvidence[field];
173
+ if (!ev || typeof ev !== 'object' || Array.isArray(ev)) {
174
+ err(`$.fieldEvidence.${field}`, `fieldEvidence.${field} is required because "${field}" is populated`);
175
+ continue;
176
+ }
177
+ if (!OBLIGATION_FACT_TYPES.includes(ev.factType)) {
178
+ err(`$.fieldEvidence.${field}.factType`, `unrecognized factType "${ev.factType}" — must be one of ${OBLIGATION_FACT_TYPES.join('|')}`);
179
+ }
180
+ if (!_isStringOrNull(ev.source)) {
181
+ err(`$.fieldEvidence.${field}.source`, `fieldEvidence.${field}.source must be a string or null`);
182
+ }
183
+ }
184
+ for (const key of Object.keys(record.fieldEvidence)) {
185
+ if (!RECIPIENT_FACT_FIELDS.includes(key)) {
186
+ err(`$.fieldEvidence.${key}`, `fieldEvidence has an orphaned key "${key}" — not a real recipient-profile fact field`);
187
+ }
188
+ }
189
+ }
190
+
191
+ return { valid: errors.length === 0, errors };
192
+ }