@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
@@ -0,0 +1,251 @@
1
+ // scenario-engine.js — M5 deliverable #3a (FR-502): the clone-and-
2
+ // override engine. Deep-clones the base graph, applies each declared
3
+ // Scenario operation, then re-runs the SAME two pure aggregators
4
+ // graph-builder.js's own real pipeline uses (aggregateVerdicts,
5
+ // isSinkPermitted) over only the flows an operation actually touched —
6
+ // never re-running the taint/path pipeline, per this sub-project's own
7
+ // scoping doc.
8
+ //
9
+ // The exact recomputation this module mirrors, confirmed by direct
10
+ // read of graph-builder.js's own real edge/flow minting pass:
11
+ // flow.protectionSummary = aggregateVerdicts([
12
+ // edge.protection.transit.verdict,
13
+ // edge.protection.atRest.verdict,
14
+ // edge.protection.handling.verdict,
15
+ // ])
16
+ // flow.policyVerdict = isSinkPermitted(dataElement.dataClasses, sinkNode.subtype,
17
+ // opts.privacySinkPolicy, { environment: opts.environment, destination: sinkNode.destination?.literalValue ?? null })
18
+ // ? 'permitted' : 'prohibited' (or 'not_evaluated' if no policy/classes/sinkKind)
19
+
20
+ import { aggregateVerdicts } from './protection.js';
21
+ import { isSinkPermitted } from '../dataflow/privacy-sink-policy.js';
22
+
23
+ function _deepClone(v) { return JSON.parse(JSON.stringify(v)); }
24
+
25
+ function _byId(arr) { return new Map((arr ?? []).map((x) => [x.id, x])); }
26
+
27
+ // Aggregates protectionSummary across EVERY edge belonging to `flow`, not
28
+ // just one of them — a flow's `edgeIds` can in principle name more than
29
+ // one edge (graph-builder.js's own real flows are always single-edge
30
+ // today, but the schema and this module both accept a multi-edge array
31
+ // with no restriction), and aggregateVerdicts's own documented purpose is
32
+ // exactly this cross-branch/cross-edge reduction (PRD §8.4). Picking a
33
+ // single arbitrary edge here would silently go stale the moment a
34
+ // scenario is built against a genuinely multi-hop flow.
35
+ function _recomputeProtectionSummaryForFlow(flow, graph) {
36
+ const edges = graph.edges.filter((e) => flow.edgeIds.includes(e.id));
37
+ const verdicts = edges.flatMap((e) => [
38
+ e.protection.transit.verdict,
39
+ e.protection.atRest.verdict,
40
+ e.protection.handling.verdict,
41
+ ]);
42
+ return aggregateVerdicts(verdicts);
43
+ }
44
+
45
+ function _recomputePolicyVerdict(flow, graph, opts) {
46
+ if (opts.privacySinkPolicy == null) return null; // signal: leave flow.policyVerdict untouched
47
+ const de = graph.dataElements.find((d) => flow.dataElementIds.includes(d.id));
48
+ const sinkNode = graph.nodes.find((n) => n.id === flow.sink);
49
+ const classes = de?.dataClasses ?? [];
50
+ const sinkKind = sinkNode?.subtype ?? null;
51
+ if (!classes.length || !sinkKind) return 'not_evaluated';
52
+ const ctx = {
53
+ environment: opts.environment ?? process.env.AGENTIC_SECURITY_ENVIRONMENT ?? null,
54
+ destination: sinkNode?.destination?.literalValue ?? null,
55
+ };
56
+ return isSinkPermitted(classes, sinkKind, opts.privacySinkPolicy, ctx) ? 'permitted' : 'prohibited';
57
+ }
58
+
59
+ // Every flow using this edge (by edgeIds membership) gets protectionSummary
60
+ // recomputed; policyVerdict only for flows whose sink node's destination
61
+ // or the flow's own dataClasses could plausibly have changed — but since
62
+ // applyScenario always calls this after ANY node/edge touch to be safe
63
+ // (recomputation is cheap and idempotent), scope is simply "every flow
64
+ // touching this edge or this node".
65
+ function _touchedFlows(graph, { edgeId, nodeId }) {
66
+ return graph.flows.filter((f) =>
67
+ (edgeId && f.edgeIds.includes(edgeId)) ||
68
+ (nodeId && (f.source === nodeId || f.sink === nodeId)));
69
+ }
70
+
71
+ function _recomputeTouchedFlows(graph, touch, opts) {
72
+ for (const flow of _touchedFlows(graph, touch)) {
73
+ flow.protectionSummary = _recomputeProtectionSummaryForFlow(flow, graph);
74
+ const newPolicyVerdict = _recomputePolicyVerdict(flow, graph, opts);
75
+ if (newPolicyVerdict !== null) flow.policyVerdict = newPolicyVerdict;
76
+ }
77
+ }
78
+
79
+ function _applyRequireTransitProtection(graph, op) {
80
+ const edge = _byId(graph.edges).get(op.targetEdgeId);
81
+ if (!edge) return { ok: false, reason: `targetEdgeId "${op.targetEdgeId}" not found in graph.edges` };
82
+ edge.protection.transit = { verdict: 'protected', evidenceGrade: 'assumed' };
83
+ _recomputeTouchedFlows(graph, { edgeId: edge.id }, op._opts);
84
+ return { ok: true };
85
+ }
86
+
87
+ function _applyHandling(graph, op) {
88
+ const edge = _byId(graph.edges).get(op.targetEdgeId);
89
+ if (!edge) return { ok: false, reason: `targetEdgeId "${op.targetEdgeId}" not found in graph.edges` };
90
+ // Mirrors graph-builder.js's own gate (~line 757): 'encrypted' handling
91
+ // only earns the atRest 'protected' verdict when the edge's sink node
92
+ // is store-kind (database/file/object-storage/cache/client-storage/
93
+ // backup/export). For any other sink kind (e.g. 'external-api',
94
+ // 'queue') "at rest" isn't even a meaningful concept — falls through to
95
+ // the same 'handling' dimension the non-encrypted case already sets.
96
+ const sinkNode = graph.nodes.find((n) => n.id === edge.to);
97
+ if (op.handling === 'encrypted' && sinkNode?.kind === 'store') {
98
+ edge.protection.atRest = { verdict: 'protected', evidenceGrade: 'assumed' };
99
+ } else {
100
+ edge.protection.handling = { verdict: 'protected', evidenceGrade: 'assumed' };
101
+ }
102
+ _recomputeTouchedFlows(graph, { edgeId: edge.id }, op._opts);
103
+ return { ok: true };
104
+ }
105
+
106
+ function _applyRemoveEntity(graph, op) {
107
+ const node = _byId(graph.nodes).get(op.targetNodeId);
108
+ if (!node) return { ok: false, reason: `targetNodeId "${op.targetNodeId}" not found in graph.nodes` };
109
+ const removedEdgeIds = new Set(graph.edges.filter((e) => e.from === node.id || e.to === node.id).map((e) => e.id));
110
+ graph.edges = graph.edges.filter((e) => !removedEdgeIds.has(e.id));
111
+ graph.flows = graph.flows.filter((f) => f.source !== node.id && f.sink !== node.id
112
+ && !f.edgeIds.some((id) => removedEdgeIds.has(id)));
113
+ graph.nodes = graph.nodes.filter((n) => n.id !== node.id);
114
+ // Cascade-prune graph.recipientProfiles[] too — a RecipientProfile whose
115
+ // contributingGraphIds referenced ONLY this now-removed node must not
116
+ // survive dangling (a downstream `dataflow export --format recipients`
117
+ // consumer filters by exactly contributingGraphIds against surviving
118
+ // node ids); a profile with OTHER surviving contributing ids just loses
119
+ // this one id, never the whole record. Guarded on the array existing —
120
+ // the base graph shape used throughout this sub-project's own tests
121
+ // doesn't always include it. A profile with NO contributingGraphIds at
122
+ // all (null/undefined — real graph-builder.js output always mints a
123
+ // non-empty array, so this is defensive against a hand-built/external
124
+ // graph only) is left untouched rather than normalized to `[]` and
125
+ // dropped, since a missing array is not evidence the profile referenced
126
+ // this node.
127
+ // NOTE (final-review re-review N2, disclosed not fixed): this cascade
128
+ // is real graph mutation, but scenario-diff.js's WATCHED_SCENARIO_FIELDS
129
+ // has no `recipientProfile` kind, so diffScenarioGraph's own delta
130
+ // report stays silent about a recipient record that disappeared or lost
131
+ // a contributing id here — a future increment's fuller fix, not scope
132
+ // creep for this pass.
133
+ if (graph.recipientProfiles?.length) {
134
+ graph.recipientProfiles = graph.recipientProfiles
135
+ .map((rp) => (rp.contributingGraphIds == null ? rp
136
+ : { ...rp, contributingGraphIds: rp.contributingGraphIds.filter((id) => id !== node.id) }))
137
+ .filter((rp) => rp.contributingGraphIds == null || rp.contributingGraphIds.length > 0);
138
+ }
139
+ return { ok: true };
140
+ }
141
+
142
+ // Scoped, at the applier level, to exactly `node.destination` — this
143
+ // sub-project's own scoping doc maps "replace a provider or move region"
144
+ // onto node.destination, the graph-level equivalent of a recipient's
145
+ // technical endpoint, and nothing else. scenario.js's own
146
+ // SCENARIO_OPERATION_REQUIRED_FIELDS only requires `field` be a non-empty
147
+ // string, never restricts its value — a real applied write to any other
148
+ // top-level node field (e.g. 'subtype') would land on the node but be
149
+ // completely invisible to scenario-diff.js's WATCHED_SCENARIO_FIELDS.node
150
+ // (which only watches 'destination'/'storeDetail'), since that list can
151
+ // only stay exhaustive if this applier never writes outside it. Rejecting
152
+ // any other field here — never widening the watch list — is what keeps
153
+ // that list exhaustive by construction. Mirrors the existing "skip a
154
+ // target id it can't find" pattern: reported in skippedOperations, never
155
+ // thrown.
156
+ function _applyReplaceRecipientFact(graph, op) {
157
+ const node = _byId(graph.nodes).get(op.targetNodeId);
158
+ if (!node) return { ok: false, reason: `targetNodeId "${op.targetNodeId}" not found in graph.nodes` };
159
+ if (op.field !== 'destination') {
160
+ return { ok: false, reason: `replace_recipient_fact only supports field "destination" (got "${op.field}") — this operation is scoped to a node's recipient/destination fact only` };
161
+ }
162
+ node[op.field] = _deepClone(op.value);
163
+ // The destination just changed — any edge.protection.transit verdict INTO
164
+ // this node was derived from the OLD destination (transit-protection.js's
165
+ // resolveTransitProtectionForSite is keyed on the sink's own destination)
166
+ // and is now stale. Never let a stale verdict survive with a real (non-
167
+ // 'assumed') evidence grade — reset to the honest "we don't know anymore"
168
+ // state rather than re-derive (re-deriving needs transitEvidenceByFile,
169
+ // IR-level data unavailable post-build) or silently keep the old verdict.
170
+ // NOTE (final-review re-review N1, disclosed not fixed): this demotion is
171
+ // per-EDGE and is always honest at that level (and always visible in
172
+ // diffScenarioGraph's own delta, since edge.protection.transit is a
173
+ // WATCHED_SCENARIO_FIELDS.edge entry) — but flow.protectionSummary
174
+ // aggregates transit/atRest/handling via aggregateVerdicts, whose
175
+ // precedence ranks not_assessed LAST, so a scenario that ALSO sets this
176
+ // edge's own handling dimension to 'protected' (via apply_handling) in
177
+ // the SAME operations list can mask the demotion at the flow-summary
178
+ // level, even though the masking verdict itself is honestly graded
179
+ // 'assumed'. graph-builder.js's own cross-dimension aggregation is
180
+ // documented safe only because transit/atRest are mutually exclusive by
181
+ // construction and handling is "never written" by any real analyzer —
182
+ // this module (scenario-engine.js) is the one exception to that last
183
+ // clause, which is exactly what makes the masking reachable here.
184
+ for (const edge of graph.edges) {
185
+ if (edge.to === node.id) {
186
+ edge.protection.transit = { verdict: 'not_assessed', evidenceGrade: 'none' };
187
+ }
188
+ }
189
+ _recomputeTouchedFlows(graph, { nodeId: node.id }, op._opts);
190
+ return { ok: true };
191
+ }
192
+
193
+ function _applyChangeStorageFact(graph, op) {
194
+ const node = _byId(graph.nodes).get(op.targetNodeId);
195
+ if (!node) return { ok: false, reason: `targetNodeId "${op.targetNodeId}" not found in graph.nodes` };
196
+ node.storeDetail = { ...(node.storeDetail ?? {}), [op.field]: _deepClone(op.value) };
197
+ return { ok: true };
198
+ }
199
+
200
+ function _applyChangeGovernanceFact(graph, op) {
201
+ const flow = _byId(graph.flows).get(op.targetFlowId);
202
+ if (!flow) return { ok: false, reason: `targetFlowId "${op.targetFlowId}" not found in graph.flows` };
203
+ flow.governanceRefs = { ...(flow.governanceRefs ?? {}), [op.field]: _deepClone(op.value) };
204
+ return { ok: true };
205
+ }
206
+
207
+ const _APPLIERS = {
208
+ require_transit_protection: _applyRequireTransitProtection,
209
+ apply_handling: _applyHandling,
210
+ remove_entity: _applyRemoveEntity,
211
+ replace_recipient_fact: _applyReplaceRecipientFact,
212
+ change_storage_fact: _applyChangeStorageFact,
213
+ change_governance_fact: _applyChangeGovernanceFact,
214
+ };
215
+
216
+ /**
217
+ * Apply `scenario.operations` to a deep clone of `baseGraph`. Never
218
+ * mutates `baseGraph`. An operation whose target id does not exist in
219
+ * the graph is skipped (reported in `skippedOperations`), never thrown —
220
+ * a Scenario written against an older snapshot must degrade honestly.
221
+ * `opts.privacySinkPolicy`/`opts.environment` mirror graph-builder.js's
222
+ * own opts; omitting privacySinkPolicy means policyVerdict
223
+ * recomputation is skipped entirely (base value kept on every touched
224
+ * flow) rather than guessed.
225
+ *
226
+ * Scenario-shape validation (`validateScenario`, `scenario.js`) is the
227
+ * CALLER's responsibility, not this module's — this function does no
228
+ * upfront shape checking beyond each operation's own per-target `_byId`
229
+ * lookup. An unrecognized `op.kind` is skipped via the `_APPLIERS` map
230
+ * lookup below rather than validated against `SCENARIO_OPERATION_KINDS`
231
+ * ahead of time. This mirrors `recipient-profile.js`'s own precedent,
232
+ * where `validateRecipientProfile` is never called internally by any
233
+ * producer either — the CLI layer (Task 4) calls `validateScenario`
234
+ * before `applyScenario`, by design.
235
+ */
236
+ export function applyScenario(baseGraph, scenario, opts = {}) {
237
+ const graph = _deepClone(baseGraph);
238
+ const appliedOperations = [];
239
+ const skippedOperations = [];
240
+ for (const op of scenario.operations ?? []) {
241
+ const applier = _APPLIERS[op.kind];
242
+ if (!applier) {
243
+ skippedOperations.push({ operation: op, reason: `unrecognized operation kind "${op.kind}"` });
244
+ continue;
245
+ }
246
+ const result = applier(graph, { ...op, _opts: opts });
247
+ if (result.ok) appliedOperations.push({ operation: op });
248
+ else skippedOperations.push({ operation: op, reason: result.reason });
249
+ }
250
+ return { graph, appliedOperations, skippedOperations };
251
+ }
@@ -0,0 +1,101 @@
1
+ // scenario.js — M5 deliverable #3a (FR-502 §10.10, DFG-0xx): the
2
+ // Scenario extension contract — a hypothetical set of graph overrides,
3
+ // NOT a DataFlowGraph v1 entity, mirrors recipient-profile.js's own
4
+ // contract shape exactly (structural-only {valid, errors} validator,
5
+ // zero graph access, per-field evidence typing for the fields a
6
+ // Scenario actually overrides).
7
+ //
8
+ // See docs/superpowers/plans/2026-09-02-data-flow-explorer-m5-whatif-simulator-scoping.md
9
+ // for the full design reasoning, including why EVIDENCE_GRADES gained
10
+ // 'assumed' instead of reusing 'declared', and why this module's own
11
+ // operations catalog excludes synthetic node/edge insertion (deferred).
12
+
13
+ export const SCENARIO_VERSION = '1.0.0';
14
+
15
+ // The 6 in-scope hypothetical-change kinds (FR-502's own 7, minus the
16
+ // deferred synthetic-insertion category). Each operation names its
17
+ // target canonical id(s) plus the override value(s); scenario-engine.js
18
+ // is the only consumer that interprets `kind`.
19
+ export const SCENARIO_OPERATION_KINDS = Object.freeze([
20
+ 'require_transit_protection',
21
+ 'apply_handling',
22
+ 'remove_entity',
23
+ 'replace_recipient_fact',
24
+ 'change_storage_fact',
25
+ 'change_governance_fact',
26
+ ]);
27
+
28
+ // Per-operation-kind required fields, beyond the universal `kind`. Kept
29
+ // as data (not inline in validateScenario) for the same reason
30
+ // SCENARIO_OPERATION_KINDS above is — a single source of truth a table
31
+ // can be checked against — though scenario-engine.js does not currently
32
+ // import this table itself; it only mentions it in a prose comment on
33
+ // its own _applyReplaceRecipientFact.
34
+ export const SCENARIO_OPERATION_REQUIRED_FIELDS = Object.freeze({
35
+ require_transit_protection: ['targetEdgeId'],
36
+ apply_handling: ['targetEdgeId', 'handling'],
37
+ remove_entity: ['targetNodeId'],
38
+ replace_recipient_fact: ['targetNodeId', 'field', 'value'],
39
+ change_storage_fact: ['targetNodeId', 'field', 'value'],
40
+ change_governance_fact: ['targetFlowId', 'field', 'value'],
41
+ });
42
+
43
+ function _isNonEmptyString(v) { return typeof v === 'string' && v.length > 0; }
44
+ function _isPlainObject(v) { return v !== null && typeof v === 'object' && !Array.isArray(v); }
45
+ function _isStringArray(v) { return Array.isArray(v) && v.every((x) => typeof x === 'string'); }
46
+
47
+ /**
48
+ * Structural validation only — mirrors validateRecipientProfile's own
49
+ * {valid, errors} shape and "never throws" contract. Does not check
50
+ * that targetEdgeId/targetNodeId/targetFlowId actually exist in any
51
+ * real graph — that is scenario-engine.js's job at apply time, since
52
+ * this module has zero graph access by design.
53
+ */
54
+ export function validateScenario(record) {
55
+ const errors = [];
56
+ const err = (p, message) => errors.push({ path: p, message });
57
+ if (!record || typeof record !== 'object' || Array.isArray(record)) {
58
+ err('$', 'Scenario record must be an object');
59
+ return { valid: false, errors };
60
+ }
61
+ if (!_isNonEmptyString(record.id) || !record.id.startsWith('scenario:')) {
62
+ err('$.id', 'id is required and must start with "scenario:"');
63
+ }
64
+ if (!_isNonEmptyString(record.version)) err('$.version', 'version is required');
65
+ if (!_isNonEmptyString(record.baseGraphId)) err('$.baseGraphId', 'baseGraphId is required');
66
+ if (!_isNonEmptyString(record.baseGraphDigest)) err('$.baseGraphDigest', 'baseGraphDigest is required');
67
+ if (!_isNonEmptyString(record.author)) err('$.author', 'author is required');
68
+ if (!_isNonEmptyString(record.createdAt)) err('$.createdAt', 'createdAt is required');
69
+ if (record.expiration !== null && record.expiration !== undefined && !_isNonEmptyString(record.expiration)) {
70
+ err('$.expiration', 'expiration must be a string or null');
71
+ }
72
+ if (!_isStringArray(record.assumptions ?? [])) err('$.assumptions', 'assumptions must be an array of strings');
73
+ if (!_isStringArray(record.verificationRequirements ?? [])) {
74
+ err('$.verificationRequirements', 'verificationRequirements must be an array of strings');
75
+ }
76
+ if (!Array.isArray(record.operations) || record.operations.length === 0) {
77
+ err('$.operations', 'operations is required and must be a non-empty array');
78
+ } else {
79
+ record.operations.forEach((op, i) => {
80
+ const p = `$.operations[${i}]`;
81
+ if (!_isPlainObject(op)) { err(p, 'each operation must be an object'); return; }
82
+ if (!SCENARIO_OPERATION_KINDS.includes(op.kind)) {
83
+ err(`${p}.kind`, `unrecognized operation kind "${op.kind}" — must be one of ${SCENARIO_OPERATION_KINDS.join('|')}`);
84
+ return;
85
+ }
86
+ for (const field of SCENARIO_OPERATION_REQUIRED_FIELDS[op.kind]) {
87
+ if (op[field] === undefined || op[field] === null || op[field] === '') {
88
+ err(`${p}.${field}`, `operation of kind "${op.kind}" requires "${field}"`);
89
+ }
90
+ }
91
+ });
92
+ }
93
+ // simulatedDelta is populated by scenario-engine.js after apply, never
94
+ // by a caller constructing the pre-apply record — null is the only
95
+ // valid pre-apply value, an object (scenario-diff.js's own shape) the
96
+ // only valid post-apply value.
97
+ if (record.simulatedDelta !== null && record.simulatedDelta !== undefined && !_isPlainObject(record.simulatedDelta)) {
98
+ err('$.simulatedDelta', 'simulatedDelta must be null (before apply) or an object (after apply)');
99
+ }
100
+ return { valid: errors.length === 0, errors };
101
+ }
@@ -0,0 +1,167 @@
1
+ //
2
+ // DataFlowGraph v1 — canonical envelope and enum contract (Data Flow
3
+ // Explorer PRD section 10). This module carries only pure constants and
4
+ // the empty-envelope constructor; no analysis logic lives here. A change
5
+ // to any array below is a schema-version-bumping change.
6
+ //
7
+ // Isolated from scanner/src/dataflow/ on purpose (PRD section 18.1): this
8
+ // package may read pure, stateless exports from dataflow/privacy-taxonomy.js
9
+ // but must never import scanner/src/dataflow/engine.js or touch its taint
10
+ // state.
11
+
12
+ export const SCHEMA_VERSION = '1.0.0';
13
+
14
+ export const NODE_KINDS = Object.freeze([
15
+ 'source', 'process', 'transform', 'api', 'store', 'queue',
16
+ 'log', 'sink', 'external', 'boundary', 'unresolved',
17
+ ]);
18
+
19
+ export const MAPPING_TYPES = Object.freeze([
20
+ 'identity', 'rename', 'projection', 'serialization', 'deserialization',
21
+ 'transformation', 'aggregation', 'join', 'filter', 'sort', 'conditional', 'unknown',
22
+ ]);
23
+
24
+ export const TRANSFORM_KINDS = Object.freeze([
25
+ 'mask', 'redact', 'tokenize', 'hash', 'encrypt', 'decrypt', 'encode',
26
+ 'decode', 'aggregate', 'truncate', 'normalize', 'custom', 'unknown',
27
+ ]);
28
+
29
+ export const REVERSIBILITY_VALUES = Object.freeze(['reversible', 'irreversible', 'unknown']);
30
+
31
+ // FR-403's single-path handling TAXONOMY (Milestone 2, Sub-project D,
32
+ // increment 1 — DESIGN_HANDLING_ANALYZER.md). Lives on `flow.handling`, a
33
+ // STRING enum — deliberately NOT the same thing as `protection.js`'s
34
+ // `PROTECTION_DIMENSIONS`' own `handling` dimension (an per-EDGE
35
+ // `{verdict, evidenceGrade}` object scored from `PROTECTION_VERDICTS`).
36
+ // The two share a name because this taxonomy IS what a later Milestone 2
37
+ // analyzer will read to populate that verdict (transform-catalog.js's own
38
+ // header: "Recognizing that a `mask` happened is this module's job.
39
+ // Deciding whether that `mask` earns 'protected' is Milestone 2's FR-401-
40
+ // 405 analyzers, reading this module's output.") — but the two fields are
41
+ // never the same value, never interchangeable, and this increment sets
42
+ // only `flow.handling`, never `edge.protection.handling`.
43
+ export const HANDLING_VALUES = Object.freeze([
44
+ 'raw', 'masked', 'redacted', 'hashed', 'tokenized', 'encrypted', 'aggregated', 'unknown',
45
+ ]);
46
+
47
+ export const EXTERNALITY_VALUES = Object.freeze(['internal', 'external', 'unknown']);
48
+
49
+ export const COVERAGE_STATUS_VALUES = Object.freeze(['modeled', 'partial', 'candidate', 'unsupported', 'manual']);
50
+
51
+ // Milestone 5, language coverage-tier disclosure: a product-level tier for
52
+ // an entire LANGUAGE (per docs/METRICS.md's own curated recall measurements),
53
+ // deliberately NOT a reuse of COVERAGE_STATUS_VALUES above — that enum
54
+ // answers a different question (is this one NODE's own classification
55
+ // confident), not "does this language, as a whole, clear the PRD's own
56
+ // field-to-sink recall bar." 'full' is real and reachable (a future
57
+ // docs/METRICS.md update crossing 85% needs no code change here to report
58
+ // it) even though no language currently resolves to it.
59
+ export const LANGUAGE_COVERAGE_TIER_VALUES = Object.freeze(['full', 'partial', 'pattern-only', 'unknown']);
60
+
61
+ export const DESTINATION_RESOLUTION_VALUES = Object.freeze([
62
+ 'literal', 'resolved_from_constant', 'resolved_from_config', 'resolved_from_schema',
63
+ 'declared_service', 'runtime_corroborated', 'dynamic', 'unknown',
64
+ ]);
65
+
66
+ // Milestone 2, Sub-project F, increment 1 (FR-304): which mechanism
67
+ // discovered this edge. Deliberately value-aligned with (but a
68
+ // distinct field from) EVIDENCE_TYPES — 'code'/'schema'/'manual'/
69
+ // 'runtime' are FR-304's own four categories, reusing EVIDENCE_TYPES's
70
+ // existing spellings rather than inventing a second vocabulary for the
71
+ // same four concepts. Only 'code' has a real producer today
72
+ // (graph-builder.js sets it unconditionally); 'schema'/'manual'/
73
+ // 'runtime' are reserved for Sub-project F2/F3, not yet implemented —
74
+ // see scanner/src/lineage/CLAUDE.md.
75
+ export const EDGE_PROVENANCE_VALUES = Object.freeze(['code', 'schema', 'manual', 'runtime']);
76
+
77
+ // Milestone 2, Sub-project E, increment 2 (`node.storeDetail.operation`,
78
+ // DESIGN_STORE_DETAIL.md). `save` deliberately maps to `'upsert'`, never
79
+ // `'create'` — see graph-builder.js's own method-name mapping comment for
80
+ // the full reasoning (Mongoose's `.save()` is an INSERT on a new document
81
+ // but an UPDATE on one loaded from the database, genuinely undecidable
82
+ // statically from the call site alone; `'upsert'` is the honest umbrella,
83
+ // not a guess at which one).
84
+ export const STORE_OPERATION_VALUES = Object.freeze(['create', 'read', 'update', 'delete', 'upsert', 'unknown']);
85
+
86
+ // Milestone 2, Sub-project E, increment 3 (`node.queueDetail.operation`,
87
+ // DESIGN_QUEUE_DETAIL.md). Only two values, deliberately — both real
88
+ // PRIVACY_SINK_CATALOG queue entries (`sendMessage`, `publish`) are
89
+ // unambiguously a WRITE/publish operation, unlike the database case's real
90
+ // `create`/`update`/`upsert` spread; there is no `read`/`delete`/`create`
91
+ // ambiguity to disclose here. Every queue site this increment recognizes
92
+ // gets `operation: 'publish'` — never `'unknown'` in practice today (no
93
+ // catalog entry produces anything else), but the value exists so
94
+ // `validate.js`'s structural check has a real enum to check against, the
95
+ // same "define the vocabulary even if only one value is reachable today"
96
+ // precedent `HANDLING_VALUES`/`STORE_OPERATION_VALUES` both already
97
+ // established.
98
+ export const QUEUE_OPERATION_VALUES = Object.freeze(['publish', 'unknown']);
99
+
100
+ export const POLICY_STATES = Object.freeze([
101
+ 'prohibited', 'permitted', 'conditionally_permitted', 'manual_review_required', 'not_evaluated',
102
+ ]);
103
+
104
+ export const EVIDENCE_TYPES = Object.freeze([
105
+ 'code', 'ir', 'configuration', 'iac', 'schema', 'service_declaration', 'policy', 'manual', 'runtime',
106
+ ]);
107
+
108
+ export const FLOW_SUMMARY_VALUES = Object.freeze(['protected', 'unprotected', 'mixed', 'unknown', 'not_assessed']);
109
+
110
+ // Not named in the PRD's own envelope example (section 10.2) but required
111
+ // by Appendix D.1's "no special-casing by name" rule: the UI needs a
112
+ // GENERIC signal for the "Illustrative demo data" ribbon, not a check
113
+ // against a specific fixture filename or node id.
114
+ export const GRAPH_SCOPE_SOURCES = Object.freeze(['scan', 'fixture']);
115
+
116
+ // PRD section 11 (FR-101) source categories and section 12 (FR-201) sink
117
+ // categories — the fixed vocabulary a node's `subtype`/an inventory row's
118
+ // category field draws from. This is the TAXONOMY only; the pattern-
119
+ // matching registries that recognize one of these categories in real
120
+ // source code (source-registry.js / sink-registry.js, PRD DFG-003) are
121
+ // Milestone 1, not this module.
122
+ export const SOURCE_CATEGORIES = Object.freeze([
123
+ 'http-body', 'http-query', 'http-route', 'http-header', 'http-cookie', 'http-upload',
124
+ 'graphql-argument', 'grpc-field', 'cli-argument', 'env-value', 'queue-message',
125
+ 'database-read', 'storage-read', 'user-input', 'external-api-response',
126
+ 'webhook-payload', 'ai-model-output', 'ai-tool-result', 'ai-retrieved-document',
127
+ 'ai-memory', 'declared',
128
+ ]);
129
+
130
+ export const SINK_CATEGORIES = Object.freeze([
131
+ 'log', 'stdout', 'http-response', 'client-storage', 'database', 'file', 'object-storage',
132
+ 'cache', 'queue', 'analytics', 'monitoring', 'email', 'sms', 'push-notification', 'collaboration',
133
+ 'external-api', 'webhook', 'ai-model-provider', 'ai-local-model', 'ai-agent', 'ai-tool',
134
+ 'ai-vector-store', 'ai-memory', 'ai-training', 'ai-evaluation', 'ai-telemetry',
135
+ 'backup', 'export', 'declared',
136
+ ]);
137
+
138
+ /**
139
+ * A fresh DataFlowGraph v1 envelope with every required top-level key
140
+ * present. Callers overlay real content; every array starts empty and
141
+ * every nested object starts as `{}` except `scope.source`, which
142
+ * defaults to `'scan'` (a fixture builder must set it to `'fixture'`
143
+ * explicitly — the safe default assumes real analyzer output).
144
+ */
145
+ export function emptyGraphEnvelope(overrides = {}) {
146
+ const { graphId, generatedAt, scope, ...rest } = overrides;
147
+ return {
148
+ schemaVersion: SCHEMA_VERSION,
149
+ graphId: graphId || null,
150
+ generatedAt: generatedAt || new Date().toISOString(),
151
+ scope: { source: 'scan', ...(scope || {}) },
152
+ scanHealth: {},
153
+ taxonomy: {},
154
+ nodes: [],
155
+ edges: [],
156
+ dataElements: [],
157
+ transformations: [],
158
+ flows: [],
159
+ controls: [],
160
+ policies: [],
161
+ evidence: [],
162
+ coverage: {},
163
+ limitations: [],
164
+ extensions: {},
165
+ ...rest,
166
+ };
167
+ }