@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,168 @@
1
+ //
2
+ // handling-analyzer.js — Milestone 2, Sub-project D ("handling analyzer +
3
+ // FR-307 control-credit"), increment 1.
4
+ //
5
+ // FR-403's single-path handling TAXONOMY classifier — see
6
+ // DESIGN_HANDLING_ANALYZER.md for the full design record: the exact
7
+ // `transform-catalog.js` `kind` -> `schema.js` `HANDLING_VALUES` mapping
8
+ // table, the load-bearing naming note distinguishing `flow.handling` (this
9
+ // module's output) from `protection.js`'s own `PROTECTION_DIMENSIONS`
10
+ // `handling` dimension (a different field, a different vocabulary, a
11
+ // different question), and everything explicitly deferred (FR-307's
12
+ // multi-path control-credit rule, `aggregate`'s own `'aggregated'` verdict,
13
+ // any UI/display concern).
14
+ //
15
+ // Reuse boundary: imports ONLY `recognizeTransformation` from
16
+ // `./transform-catalog.js` and `exprRoots`/`walkExpr` from
17
+ // `./source-seeding.js` — both already-shipped, read-only consumers, per
18
+ // this package's established isolation convention. Never
19
+ // `dataflow/engine.js`, never `dataflow/summaries.js`, never
20
+ // `dataflow/catalog.js`/`dataflow/privacy-catalog.js` directly.
21
+ //
22
+ // A pure, one-path-at-a-time classifier. `classifyHandling(path, callGraph)`
23
+ // takes ONE `path-query.js#reconstructPaths` result path — the caller picks
24
+ // which; this module never iterates multiple paths to one sink, that is
25
+ // FR-307's job, explicitly deferred to D2 — and never throws on malformed
26
+ // input.
27
+
28
+ import { recognizeTransformation } from './transform-catalog.js';
29
+ import { exprRoots, walkExpr } from './source-seeding.js';
30
+
31
+ /**
32
+ * The `transform-catalog.js` `kind` -> FR-403 `HANDLING_VALUES` mapping
33
+ * (DESIGN_HANDLING_ANALYZER.md §2). Five kinds map to their own protective
34
+ * handling state (`mask`/`redact`/`hash`/`tokenize`/`encrypt`); five more
35
+ * (`decrypt`/`encode`/`decode`/`truncate`/`normalize`) map to `'unknown'`
36
+ * because none of them is itself a PROTECTIVE handling state for FR-403's
37
+ * purposes — decoding/truncating/normalizing doesn't protect a field, and
38
+ * a `decrypt` immediately before a sink is actively the OPPOSITE of
39
+ * protection. `aggregate` ALSO maps to `'unknown'` here, for a different,
40
+ * disclosed reason: `HANDLING_VALUES` carries its own `'aggregated'`
41
+ * value, but awarding it needs shape-level reasoning about a WHOLE
42
+ * collection that a single-hop, single-path classifier cannot do soundly —
43
+ * deferred to D2/later, never faked from a hop-level recognition alone.
44
+ * `custom`/`unknown` never appear as keys here because
45
+ * `recognizeTransformation` never emits them as a `kind`
46
+ * (`transform-catalog.js`'s own documented, load-time-enforced contract).
47
+ */
48
+ const KIND_TO_HANDLING = Object.freeze({
49
+ mask: 'masked',
50
+ redact: 'redacted',
51
+ hash: 'hashed',
52
+ tokenize: 'tokenized',
53
+ encrypt: 'encrypted',
54
+ decrypt: 'unknown',
55
+ encode: 'unknown',
56
+ decode: 'unknown',
57
+ truncate: 'unknown',
58
+ normalize: 'unknown',
59
+ aggregate: 'unknown',
60
+ });
61
+
62
+ /** The honest empty answer: no hop on this path yielded a recognized
63
+ * transform. Frozen and shared — this module never mutates its own
64
+ * return values. */
65
+ const RAW_RESULT = Object.freeze({ handling: 'raw', recognizedTransform: null, hopIndex: null });
66
+
67
+ /**
68
+ * Mirrors `graph-builder.js`'s own private (unexported) `calleeDescriptor`
69
+ * helper. Deliberately NOT imported from there — see
70
+ * DESIGN_HANDLING_ANALYZER.md §3 for why a third copy of this ~10-line
71
+ * shape was chosen over introducing a `graph-builder.js` <->
72
+ * `handling-analyzer.js` module cycle (this module is wired INTO
73
+ * `graph-builder.js`, the reverse direction). Never throws; returns `null`
74
+ * for anything that cannot become a `transform-catalog.js` descriptor.
75
+ */
76
+ function calleeDescriptorOf(calleeExpr) {
77
+ if (typeof calleeExpr === 'string') return { type: 'call', callee: calleeExpr };
78
+ if (!calleeExpr || typeof calleeExpr !== 'object') return null;
79
+ if (calleeExpr.kind === 'ident' && typeof calleeExpr.name === 'string') return { type: 'call', callee: calleeExpr.name };
80
+ if (calleeExpr.kind === 'member' && typeof calleeExpr.prop === 'string') {
81
+ const obj = calleeExpr.object && calleeExpr.object.kind === 'ident' ? calleeExpr.object.name : null;
82
+ return obj ? { type: 'member-call', object: obj, method: calleeExpr.prop } : { type: 'call', callee: calleeExpr.prop };
83
+ }
84
+ return null;
85
+ }
86
+
87
+ /**
88
+ * Every `{kind: 'call', ...}` expression reachable from one CFG node, in
89
+ * expression-tree order — mirrors `graph-builder.js`'s own per-hop
90
+ * transformation-extraction loop exactly (`exprRoots`/`walkExpr` over the
91
+ * node, plus the node's OWN call when the node itself is `kind: 'call'` —
92
+ * `exprRoots` only ever yields a call NODE's `callee`/`args`, never a
93
+ * `{kind:'call'}` shape for the node itself, so that case needs the same
94
+ * explicit unshift `graph-builder.js` already uses). This is what makes an
95
+ * `assign`-kind CFG node whose `.source` IS a call (`const maskedPan =
96
+ * maskCard(cardNumber);`) resolvable too, not just a bare call statement —
97
+ * see DESIGN_HANDLING_ANALYZER.md §3 item 2 for why that distinction is
98
+ * load-bearing for this increment's own required AC-02 fixture. Defensive:
99
+ * a malformed `node` yields `[]`, never throws.
100
+ */
101
+ function callsAt(node) {
102
+ if (!node || typeof node !== 'object') return [];
103
+ const calls = [];
104
+ for (const root of exprRoots(node)) walkExpr(root, (e) => { if (e && typeof e === 'object' && e.kind === 'call') calls.push(e); });
105
+ if (node.kind === 'call' && node.callee) calls.unshift({ kind: 'call', callee: node.callee, args: node.args ?? [] });
106
+ return calls;
107
+ }
108
+
109
+ /**
110
+ * FR-403's single-path handling classifier. `path` is one
111
+ * `path-query.js#reconstructPaths` result path (the caller picks which —
112
+ * this module never iterates multiple paths to one sink, per FR-307's own
113
+ * deferred multi-path control-credit rule). `callGraph` is the same
114
+ * `scanner/src/ir/callgraph.js#buildCallGraph`-shaped object (a real
115
+ * `Map` at `.functions`) `graph-builder.js` already consumes, or an
116
+ * equivalent hand-built fixture exposing the same shape.
117
+ *
118
+ * Walks `path.hops` in the SOURCE-TO-SINK order `path-query.js` already
119
+ * materializes paths in. For each hop, resolves
120
+ * `callGraph.functions.get(hop.scope)?.cfg?.nodes?.[hop.siteNodeId]`
121
+ * defensively — never throws on a missing/malformed lookup, mirroring
122
+ * every other lineage module's defensiveness — and, when that CFG node
123
+ * yields one or more call expressions (via `callsAt`, above), tries
124
+ * `recognizeTransformation` on each in expression order. Returns the FIRST
125
+ * recognized transform found walking from source to sink.
126
+ *
127
+ * @param {object} path a `reconstructPaths` result path
128
+ * @param {object} callGraph `{functions: Map<qid, {cfg: {nodes}}>, ...}`
129
+ * @returns {{handling: string, recognizedTransform: object|null, hopIndex: number|null}}
130
+ * `handling` is always a `schema.js` `HANDLING_VALUES` member.
131
+ * `recognizedTransform` is `recognizeTransformation`'s own decision
132
+ * object (kind/reversibility/algorithm/confidence/evidence), unmodified,
133
+ * when one was found — else `null`. `hopIndex` is the index into
134
+ * `path.hops` the transform was found at, else `null`. When no hop
135
+ * yields a recognized transform, returns
136
+ * `{handling: 'raw', recognizedTransform: null, hopIndex: null}` —
137
+ * never a guess.
138
+ */
139
+ export function classifyHandling(path, callGraph) {
140
+ const hops = Array.isArray(path?.hops) ? path.hops : [];
141
+ const fns = callGraph && callGraph.functions && typeof callGraph.functions.get === 'function' ? callGraph.functions : null;
142
+ if (!fns) return RAW_RESULT;
143
+
144
+ for (let i = 0; i < hops.length; i++) {
145
+ const hop = hops[i];
146
+ if (!hop || typeof hop !== 'object') continue;
147
+ const fn = fns.get(hop.scope);
148
+ const node = fn?.cfg?.nodes?.[hop.siteNodeId];
149
+ if (!node) continue;
150
+ for (const call of callsAt(node)) {
151
+ const descriptor = calleeDescriptorOf(call.callee);
152
+ if (!descriptor) continue;
153
+ const rec = recognizeTransformation(descriptor);
154
+ if (!rec) continue;
155
+ // Defensive fallback only — every non-fallback `TRANSFORM_KINDS`
156
+ // value `recognizeTransformation` can actually emit is a key in
157
+ // `KIND_TO_HANDLING` above (11 kinds, both tables measured against
158
+ // the same live `transform-catalog.js`), so `?? 'unknown'` is never
159
+ // exercised by real catalog output today. Kept rather than omitted
160
+ // so a future catalog kind this table hasn't been updated for fails
161
+ // safe (an honest `'unknown'`) instead of writing `undefined` onto
162
+ // `flow.handling`.
163
+ const handling = KIND_TO_HANDLING[rec.kind] ?? 'unknown';
164
+ return { handling, recognizedTransform: rec, hopIndex: i };
165
+ }
166
+ }
167
+ return RAW_RESULT;
168
+ }
@@ -0,0 +1,349 @@
1
+ //
2
+ // Stable-ID spec for DataFlowGraph v1 (PRD 10.1: "Stable within the
3
+ // repository/commit; independent of visual layout"). Mirrors the shape
4
+ // scanner/src/posture/stable-id.js already established for findings:
5
+ // sha256 over a canonicalized, pipe-joined material string, truncated to
6
+ // a fixed hex length, prefixed by the entity kind. Same rationale — a
7
+ // content hash survives reordering and re-emission, unlike an
8
+ // incrementing counter.
9
+
10
+ import * as crypto from 'node:crypto';
11
+
12
+ const ID_HEX_LEN = 12;
13
+
14
+ function _hash(material, len = ID_HEX_LEN) {
15
+ return crypto.createHash('sha256').update(material).digest('hex').slice(0, len);
16
+ }
17
+
18
+ function _canon(parts) {
19
+ return parts.map((p) => (p === undefined || p === null ? '' : String(p))).join('|');
20
+ }
21
+
22
+ /** PRD 10.2's literal example shape: `dfg:<repository>:<commit>:<configuration-hash>`. */
23
+ export function graphId({ repository, commit, configHash } = {}) {
24
+ const repo = repository || 'unknown-repo';
25
+ const c = commit || 'uncommitted';
26
+ const cfg = configHash || 'default';
27
+ return `dfg:${repo}:${c}:${cfg}`;
28
+ }
29
+
30
+ /** discriminatorParts should include enough of {system, location, destination} to be unique within the graph. */
31
+ export function nodeId(kind, discriminatorParts = []) {
32
+ return `node:${kind}:${_hash(_canon([kind, ...discriminatorParts]))}`;
33
+ }
34
+
35
+ /** discriminatorParts should include the owning service/schema so the same field name in two services never collides (PRD 10.4). */
36
+ export function dataElementId(canonicalName, discriminatorParts = []) {
37
+ return `data:${_hash(_canon([canonicalName, ...discriminatorParts]))}`;
38
+ }
39
+
40
+ export function edgeId(fromId, toId, relationship, discriminatorParts = []) {
41
+ return `edge:${_hash(_canon([fromId, toId, relationship, ...discriminatorParts]))}`;
42
+ }
43
+
44
+ /**
45
+ * A node in `path-store.js`'s provenance DAG (Sub-project C, increment 4;
46
+ * DESIGN_PATH_PROVENANCE.md §14.2/§14.5). `pnode:`/`pedge:` are deliberately
47
+ * distinct prefixes from `node:`/`edge:` above: a provenance node is NOT a
48
+ * `DataFlowGraph v1` node, and `validate.js`'s id-prefix regexes must never
49
+ * be able to confuse the two namespaces.
50
+ *
51
+ * Object-argument, not this file's usual positional-plus-discriminatorParts
52
+ * form (a deliberate, narrow divergence — `graphId` is the in-file
53
+ * precedent) — the discriminator is wide enough that a positional array is
54
+ * exactly the shape a future field addition would silently omit from.
55
+ */
56
+ export function provenanceNodeId(
57
+ { kind, scope, context, path, siteNodeId, dataElementId },
58
+ discriminatorParts = [],
59
+ ) {
60
+ return `pnode:${kind}:${_hash(_canon([kind, scope, context, path, siteNodeId, dataElementId, ...discriminatorParts]))}`;
61
+ }
62
+
63
+ /**
64
+ * An edge in `path-store.js`'s provenance DAG: one (in-half, out-half) pair
65
+ * at one join group (§14.5). The discriminator carries the SITE (`scope`,
66
+ * `context`, `siteNodeId`) as well as both endpoint ids — two structurally
67
+ * identical hops at two different program points are two materially
68
+ * different edges (FR-305), each needing its own `line` for display and
69
+ * §9.2's hop-ordering lever; omitting `siteNodeId` would silently collide
70
+ * them into one edge carrying one arbitrary line. It also carries both
71
+ * halves' `kind`/`subKind` and their reason strings — the
72
+ * `flagship-fixture.mjs` lesson (see this package's own CLAUDE.md row)
73
+ * applied deliberately: under-specifying a content-hash discriminator is a
74
+ * silent merge. NOT in the discriminator: `syntacticPath` and `line`
75
+ * (display material), edge `annotations[]`, and `ambiguousCorrelation`
76
+ * (both are functions of the group and the endpoints already in the id).
77
+ */
78
+ export function provenanceEdgeId(
79
+ {
80
+ fromNodeId, toNodeId, dataElementId,
81
+ scope, context, siteNodeId,
82
+ inKind, inSubKind, outKind, outSubKind,
83
+ widenReasons = [], lossReasons = [],
84
+ },
85
+ discriminatorParts = [],
86
+ ) {
87
+ return `pedge:${_hash(_canon([
88
+ fromNodeId, toNodeId, dataElementId,
89
+ scope, context, siteNodeId,
90
+ inKind, inSubKind, outKind, outSubKind,
91
+ [...widenReasons].sort().join(','), [...lossReasons].sort().join(','),
92
+ ...discriminatorParts,
93
+ ]))}`;
94
+ }
95
+
96
+ /**
97
+ * A reconstructed path (Sub-project C, increment 5;
98
+ * DESIGN_PATH_PROVENANCE.md §15.6). `pnode:`/`pedge:`'s own header left this
99
+ * name deliberately unclaimed (§14.5: "the thing C5 reconstructs *is* a
100
+ * path, and it will plausibly want that name") — increment 5 claims it,
101
+ * with a `ppath:` prefix joining the same family. A reconstructed path is
102
+ * not a `DataFlowGraph v1` entity either, so `validate.js` needs no change.
103
+ *
104
+ * The discriminator is the EDGE id SEQUENCE, never the node id sequence
105
+ * (§15.6/FR-305 — two paths can share a node sequence while differing in
106
+ * the edges that join it, e.g. two assignments at two different program
107
+ * points, and that difference must not be hidden by dedup) — order matters
108
+ * for a path, unlike a node/edge discriminator's set-like fields, so
109
+ * `edgeIds` is NOT sorted before hashing. `startNodeId` is strictly
110
+ * redundant today (a path always has at least one hop, so the last edge id
111
+ * already determines it) but is kept per §14.5's own lesson: over-specifying
112
+ * a content hash costs nothing, under-specifying one is a silent merge.
113
+ */
114
+ export function pathId({ startNodeId, edgeIds }, discriminatorParts = []) {
115
+ return `ppath:${_hash(_canon([startNodeId, ...edgeIds, ...discriminatorParts]))}`;
116
+ }
117
+
118
+ /**
119
+ * dataElementIds is treated as a SET (sorted before hashing) — a flow
120
+ * carrying {card_number, cvv} has one identity regardless of the order the
121
+ * builder discovered them in. `discriminatorParts` is the escape hatch for
122
+ * two flows sharing source/sink/fields that must still be distinct paths
123
+ * (e.g. a masked branch vs. a raw branch to the same log sink).
124
+ */
125
+ export function flowId(sourceNodeId, sinkNodeId, dataElementIds = [], discriminatorParts = []) {
126
+ const sorted = [...dataElementIds].sort();
127
+ return `flow:${_hash(_canon([sourceNodeId, sinkNodeId, ...sorted, ...discriminatorParts]))}`;
128
+ }
129
+
130
+ export function transformationId(anchorId, calleeName, discriminatorParts = []) {
131
+ return `transform:${_hash(_canon([anchorId, calleeName, ...discriminatorParts]))}`;
132
+ }
133
+
134
+ export function evidenceId(claim, location, discriminatorParts = []) {
135
+ return `evidence:${_hash(_canon([claim, location, ...discriminatorParts]))}`;
136
+ }
137
+
138
+ /**
139
+ * An ObligationMapping record's id (FR-504 §7.12, sub-project 6a) — NOT a
140
+ * DataFlowGraph v1 entity, so validate.js's id-prefix regexes and
141
+ * json-schema-parity.test.js's $defs audit need zero change, mirroring
142
+ * why provenanceNodeId/provenanceEdgeId are prefixed outside the
143
+ * node:/edge:/flow: family. Discriminated by (framework, frameworkVersion,
144
+ * requirementId, graphId, graphDigest) — graphId ALONE is not enough:
145
+ * real callers never supply graphId's own configHash component (only
146
+ * `graph-builder.js` reads it, via `opts.configHash ?? 'default'`, and
147
+ * nothing calls it with one), so every real scan at one commit produces
148
+ * the identical graphId regardless of the graph's actual CONTENT — found
149
+ * by this sub-project's own final whole-branch review, reproduced live:
150
+ * two records against genuinely different base graphs (same repo/commit,
151
+ * different analyzer output) collided onto one id without graphDigest in
152
+ * the material. graphDigest is the field §10.10 already requires every
153
+ * extension record to carry precisely so it can be distinguished from a
154
+ * same-graphId, different-content graph — this discriminator is what
155
+ * makes that requirement actually load-bearing for uniqueness, not just
156
+ * a payload field alongside an otherwise-collidable id.
157
+ */
158
+ export function obligationId(
159
+ { framework, frameworkVersion, requirementId, graphId, graphDigest },
160
+ discriminatorParts = [],
161
+ ) {
162
+ return `obligation:${_hash(_canon([framework, frameworkVersion, requirementId, graphId, graphDigest, ...discriminatorParts]))}`;
163
+ }
164
+
165
+ /**
166
+ * A RecipientProfile record's id (FR-506 §7.12, "Third-Party and
167
+ * Cross-Border Intelligence", Milestone 4) — NOT a DataFlowGraph v1
168
+ * entity, mirrors obligationId's own precedent exactly (a real,
169
+ * stable-ID'd extension record that is deliberately not a base-graph
170
+ * entity). Discriminated by (graphId, graphDigest, recipientKey) —
171
+ * graphDigest is required in the discriminator for the identical reason
172
+ * obligationId's own comment gives (no real caller supplies graphId's own
173
+ * configHash component, so graphId alone never distinguishes two
174
+ * same-commit graphs with genuinely different content); recipientKey is
175
+ * the stable string a later task keys one recipient by (e.g. a
176
+ * normalized hostname or provider id), so two profiles for the same
177
+ * recipient across two different graphs never collide, and two DIFFERENT
178
+ * recipients within the same graph never collide either.
179
+ */
180
+ export function recipientProfileId(
181
+ { graphId, graphDigest, recipientKey },
182
+ discriminatorParts = [],
183
+ ) {
184
+ return `recipient:${_hash(_canon([graphId, graphDigest, recipientKey, ...discriminatorParts]))}`;
185
+ }
186
+
187
+ /**
188
+ * A DecisionStory record's id (M4 deliverable #7, FR-501 §14, DFG-035) —
189
+ * NOT a DataFlowGraph v1 entity, mirrors obligationId's own precedent
190
+ * exactly (a real, stable-ID'd extension record that is deliberately not
191
+ * a base-graph entity). Discriminated by (graphDigest, audienceMode,
192
+ * scopeQuery) rather than graphId alone, for the identical reason
193
+ * obligationId's own comment gives: two stories over the same graphId but
194
+ * genuinely different graph CONTENT (or a different filter/audience
195
+ * scope) must not collide onto one id. `scopeQuery` is passed pre-
196
+ * serialized by the caller (a plain object is not itself hashable
197
+ * material) so this function stays a thin, generic hasher rather than
198
+ * embedding export-briefing.js's own scopeQuery shape.
199
+ */
200
+ export function storyId(
201
+ { graphDigest, audienceMode, scopeQuery },
202
+ discriminatorParts = [],
203
+ ) {
204
+ const scope = typeof scopeQuery === 'string' ? scopeQuery : JSON.stringify(scopeQuery ?? null);
205
+ return `story:${_hash(_canon([graphDigest, audienceMode, scope, ...discriminatorParts]))}`;
206
+ }
207
+
208
+ /**
209
+ * A GraphSnapshot record's id (M4 deliverable #8, FR-503 §14, DFG-022,
210
+ * sub-project 8a) — NOT a DataFlowGraph v1 entity, mirrors obligationId's/
211
+ * storyId's own precedent exactly (a real, stable-ID'd extension record
212
+ * that is deliberately not a base-graph entity). Discriminated by
213
+ * (graphId, commit, capturedAt) — `commit` is the REAL git HEAD resolved
214
+ * by graph-snapshot.js's own persistence layer, never `graphId`'s own
215
+ * embedded commit component (which is the literal string 'uncommitted' on
216
+ * every real scan today — see graph-snapshot.js's own header comment).
217
+ */
218
+ export function snapshotId(
219
+ { graphId, commit, capturedAt },
220
+ discriminatorParts = [],
221
+ ) {
222
+ return `snapshot:${_hash(_canon([graphId, commit, capturedAt, ...discriminatorParts]))}`;
223
+ }
224
+
225
+ /**
226
+ * A GraphDiff record's id (M4 deliverable #9, FR-503 §14, DFG-022,
227
+ * sub-project 8b) — NOT a DataFlowGraph v1 entity, mirrors snapshotId's
228
+ * own precedent exactly (a real, stable-ID'd extension record that is
229
+ * deliberately not a base-graph entity). Discriminated by
230
+ * (beforeSnapshotId, afterSnapshotId) — unlike snapshotId, no separate
231
+ * timestamp component is needed: each snapshot id is itself
232
+ * content-derived (its own graphId/commit/capturedAt), so the pair alone
233
+ * already makes a diff between two SPECIFIC snapshots deterministic, and
234
+ * a diff is not itself a new capture event the way a snapshot is.
235
+ */
236
+ export function diffId(
237
+ { beforeSnapshotId, afterSnapshotId },
238
+ discriminatorParts = [],
239
+ ) {
240
+ return `diff:${_hash(_canon([beforeSnapshotId, afterSnapshotId, ...discriminatorParts]))}`;
241
+ }
242
+
243
+ /**
244
+ * A Scenario record's id (M5 deliverable #3a, FR-502 §10.10) — NOT a
245
+ * DataFlowGraph v1 entity, mirrors recipientProfileId's own precedent
246
+ * exactly (a real, stable-ID'd extension record deliberately not a
247
+ * base-graph entity). Discriminated by (graphId, graphDigest) plus
248
+ * caller-supplied discriminatorParts — unlike recipientProfileId, a
249
+ * Scenario has no single natural key of its own (two saved scenarios
250
+ * over the identical base graph with identical operations are still
251
+ * two different records, since FR-502 requires author/time as real,
252
+ * always-present, non-deduplicating fields), so a caller building a
253
+ * scenario record supplies (author, createdAt) as discriminatorParts to
254
+ * make repeat calls collide only when they are genuinely the same
255
+ * scenario.
256
+ */
257
+ export function scenarioId(
258
+ { graphId, graphDigest },
259
+ discriminatorParts = [],
260
+ ) {
261
+ return `scenario:${_hash(_canon([graphId, graphDigest, ...discriminatorParts]))}`;
262
+ }
263
+
264
+ /**
265
+ * An ImpactAssessment record's id (M5 deliverable #4, FR-507 §10.10) —
266
+ * NOT a DataFlowGraph v1 entity, mirrors recipientProfileId's/
267
+ * scenarioId's own precedent exactly. Discriminated by (graphId,
268
+ * graphDigest, targetId) plus caller-supplied discriminatorParts —
269
+ * graphDigest is required for the identical reason every other
270
+ * extension-contract id in this file requires it (graphId alone never
271
+ * distinguishes two same-commit graphs with genuinely different
272
+ * content); targetId is the compromised entity the assessment was
273
+ * computed FROM, so two assessments over the same graph but different
274
+ * targets never collide. Mirrors scenarioId's own (author, createdAt)
275
+ * precedent for discriminatorParts: the only real caller
276
+ * (impact-engine.js) always supplies `[generatedAt]`, so two
277
+ * back-to-back assessments of the SAME target over an UNCHANGED graph
278
+ * get distinct ids per run (a content-addressed id that collided
279
+ * across runs would be strictly worse than a per-run one — graphDigest
280
+ * already covers "the graph changed", generatedAt is purely a per-run
281
+ * nonce, the same role snapshotId's own capturedAt plays).
282
+ */
283
+ export function impactAssessmentId(
284
+ { graphId, graphDigest, targetId },
285
+ discriminatorParts = [],
286
+ ) {
287
+ return `impact:${_hash(_canon([graphId, graphDigest, targetId, ...discriminatorParts]))}`;
288
+ }
289
+
290
+ /**
291
+ * A RuntimeObservation record's id (M5 deliverable #7, FR-505 §10.10) —
292
+ * NOT a DataFlowGraph v1 entity, mirrors impactAssessmentId's/scenarioId's
293
+ * own precedent exactly. Discriminated by (adapter, environment,
294
+ * windowStart, windowEnd) plus caller-supplied discriminatorParts, which
295
+ * the adapter fills with the observation's own attribute fingerprint —
296
+ * two observations of the SAME destination in the SAME environment and
297
+ * window are the same observation and must collapse to one id, which is
298
+ * what makes an accidental double-import idempotent at the record level.
299
+ */
300
+ export function observationId(
301
+ { adapter, environment, windowStart, windowEnd },
302
+ discriminatorParts = [],
303
+ ) {
304
+ return `observation:${_hash(_canon([adapter, environment, windowStart, windowEnd, ...discriminatorParts]))}`;
305
+ }
306
+
307
+ /**
308
+ * One adapter IMPORT's id (M5 deliverable #7, FR-505 §10.10) — the store's
309
+ * own file key, mirroring graph-snapshot.js's commit key one dimension
310
+ * over (import, not commit; see that sub-project's scoping doc §4.4 for
311
+ * why commit-keying cannot express many observations per graph entity).
312
+ * `importedAt` is a per-run nonce, exactly the role snapshotId's own
313
+ * capturedAt plays: re-importing the same file must produce a NEW import
314
+ * file, never silently overwrite the earlier one, because the store is a
315
+ * directory of IMMUTABLE whole files.
316
+ */
317
+ export function observationImportId(
318
+ { adapter, source, environment, windowStart, windowEnd, importedAt },
319
+ discriminatorParts = [],
320
+ ) {
321
+ return `obsimport:${_hash(_canon([adapter, source, environment, windowStart, windowEnd, importedAt, ...discriminatorParts]))}`;
322
+ }
323
+
324
+ /**
325
+ * A CrossRepoLink record's id (M5 deliverable #8, FR-304 "declared"
326
+ * half, §10.10) — NOT a DataFlowGraph v1 entity, mirrors
327
+ * `recipientProfileId`'s own precedent exactly: `(graphId, graphDigest,
328
+ * ...)` doubled for both endpoints. This is the concrete mechanism that
329
+ * makes "id collision is impossible by construction" real rather than
330
+ * aspirational: two links between structurally identical node shapes in
331
+ * two different repo pairs cannot collide, because both graphs' own
332
+ * `graphId`+`graphDigest` are baked into the id material on both sides —
333
+ * a bare node id string is never looked up against a merged or ambiguous
334
+ * set.
335
+ */
336
+ export function crossRepoLinkId(
337
+ {
338
+ localGraphId, localGraphDigest, localNodeId,
339
+ remoteGraphId, remoteGraphDigest, remoteNodeId,
340
+ relationship,
341
+ },
342
+ discriminatorParts = [],
343
+ ) {
344
+ return `crosslink:${_hash(_canon([
345
+ localGraphId, localGraphDigest, localNodeId,
346
+ remoteGraphId, remoteGraphDigest, remoteNodeId,
347
+ relationship, ...discriminatorParts,
348
+ ]))}`;
349
+ }
@@ -0,0 +1,76 @@
1
+ // impact-assessment.js — M5 deliverable #4 (FR-507 §10.10): the
2
+ // ImpactAssessment extension contract — the result of asking "what is
3
+ // reachable from this compromised node/edge/flow/data element, per the
4
+ // graph's own already-scanned evidence." NOT a DataFlowGraph v1 entity,
5
+ // mirrors recipient-profile.js's/scenario.js's own contract shape
6
+ // exactly (structural-only {valid, errors} validator, zero graph
7
+ // access at construction time).
8
+ //
9
+ // See docs/superpowers/plans/2026-09-02-data-flow-explorer-m5-blast-radius-impact-scoping.md
10
+ // for the full design reasoning, including why there is no
11
+ // affectedObligationIds field (ObligationMapping records are built on
12
+ // demand per compliance framework, not stored on the graph) and why
13
+ // `scope` is always 'possible' today (no runtime-corroboration layer
14
+ // exists yet).
15
+
16
+ export const IMPACT_VERSION = '1.0.0';
17
+
18
+ export const IMPACT_TARGET_KINDS = Object.freeze(['node', 'edge', 'flow', 'dataElement']);
19
+
20
+ // 'possible' is the only value any producer emits today — 'observed'
21
+ // is reserved for a future Digital Twin (M5 #7) increment with a real
22
+ // runtime-corroboration signal. Both are valid schema values now so
23
+ // that increment needs no breaking change to this contract later.
24
+ export const IMPACT_SCOPE_VALUES = Object.freeze(['possible', 'observed']);
25
+
26
+ // Discloses WHICH of the two genuinely different traversal semantics
27
+ // produced this record (final-review I2 fix) — `scope` alone cannot
28
+ // distinguish them, since both families emit `scope: 'possible'`.
29
+ // 'topology_reachable': a `node` target's topology-wide showAllPaths
30
+ // BFS — "everything this compromised node could push to." 'flow_
31
+ // restricted': an `edge`/`flow`/`dataElement` target's direct trace
32
+ // over the flows that actually carry it — never the topology-wide BFS.
33
+ // Without this field a JSON consumer cannot tell "exact carrier trace"
34
+ // from "topological presumption" apart, the exact silent-conflation
35
+ // this codebase's own disclosure discipline exists to prevent.
36
+ export const IMPACT_TRACE_KINDS = Object.freeze(['topology_reachable', 'flow_restricted']);
37
+
38
+ function _isNonEmptyString(v) { return typeof v === 'string' && v.length > 0; }
39
+ function _isStringArray(v) { return Array.isArray(v) && v.every((x) => typeof x === 'string'); }
40
+
41
+ /**
42
+ * Structural validation only — mirrors validateRecipientProfile's/
43
+ * validateScenario's own {valid, errors} shape and "never throws"
44
+ * contract.
45
+ */
46
+ export function validateImpactAssessment(record) {
47
+ const errors = [];
48
+ const err = (p, message) => errors.push({ path: p, message });
49
+ if (!record || typeof record !== 'object' || Array.isArray(record)) {
50
+ err('$', 'ImpactAssessment record must be an object');
51
+ return { valid: false, errors };
52
+ }
53
+ if (!_isNonEmptyString(record.id) || !record.id.startsWith('impact:')) {
54
+ err('$.id', 'id is required and must start with "impact:"');
55
+ }
56
+ if (!_isNonEmptyString(record.version)) err('$.version', 'version is required');
57
+ if (!_isNonEmptyString(record.graphId)) err('$.graphId', 'graphId is required');
58
+ if (!_isNonEmptyString(record.graphDigest)) err('$.graphDigest', 'graphDigest is required');
59
+ if (!_isNonEmptyString(record.targetId)) err('$.targetId', 'targetId is required');
60
+ if (!IMPACT_TARGET_KINDS.includes(record.targetKind)) {
61
+ err('$.targetKind', `targetKind must be one of ${IMPACT_TARGET_KINDS.join('|')}`);
62
+ }
63
+ if (!IMPACT_SCOPE_VALUES.includes(record.scope)) {
64
+ err('$.scope', `scope must be one of ${IMPACT_SCOPE_VALUES.join('|')}`);
65
+ }
66
+ if (!IMPACT_TRACE_KINDS.includes(record.traceKind)) {
67
+ err('$.traceKind', `traceKind must be one of ${IMPACT_TRACE_KINDS.join('|')}`);
68
+ }
69
+ if (!_isStringArray(record.affectedNodeIds ?? [])) err('$.affectedNodeIds', 'affectedNodeIds must be an array of strings');
70
+ if (!_isStringArray(record.affectedEdgeIds ?? [])) err('$.affectedEdgeIds', 'affectedEdgeIds must be an array of strings');
71
+ if (!_isStringArray(record.affectedDataClasses ?? [])) err('$.affectedDataClasses', 'affectedDataClasses must be an array of strings');
72
+ if (!_isStringArray(record.affectedRecipientProfileIds ?? [])) err('$.affectedRecipientProfileIds', 'affectedRecipientProfileIds must be an array of strings');
73
+ if (!_isStringArray(record.coverageLimitations ?? [])) err('$.coverageLimitations', 'coverageLimitations must be an array of strings');
74
+ if (!_isNonEmptyString(record.generatedAt)) err('$.generatedAt', 'generatedAt is required');
75
+ return { valid: errors.length === 0, errors };
76
+ }