@clear-capabilities/agentic-security-scanner 0.145.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 (162) hide show
  1. package/CHANGELOG.md +139 -0
  2. package/bin/agentic-security.js +3508 -69
  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} +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 +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} +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 +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} +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/package.json +12 -5
  72. package/src/dataflow/CLAUDE.md +1 -1
  73. package/src/dataflow/catalog.js +42 -0
  74. package/src/dataflow/orm-write-catalog.js +175 -0
  75. package/src/engine.js +92 -1
  76. package/src/ir/CLAUDE.md +1 -0
  77. package/src/ir/chrome-probe.mjs +150 -0
  78. package/src/ir/parser-js.js +94 -7
  79. package/src/lineage/CLAUDE.md +1203 -0
  80. package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
  81. package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
  82. package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
  83. package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
  84. package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
  85. package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
  86. package/src/lineage/DESIGN_REGISTRIES.md +880 -0
  87. package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
  88. package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
  89. package/src/lineage/classification.js +56 -0
  90. package/src/lineage/coverage.js +658 -0
  91. package/src/lineage/cross-repo-link.js +107 -0
  92. package/src/lineage/dataflow-graph.schema.json +184 -0
  93. package/src/lineage/decision-story.js +206 -0
  94. package/src/lineage/drift-policy.js +279 -0
  95. package/src/lineage/driver.js +135 -0
  96. package/src/lineage/engine.js +992 -0
  97. package/src/lineage/export-briefing.js +628 -0
  98. package/src/lineage/export-csv.js +62 -0
  99. package/src/lineage/export-json.js +238 -0
  100. package/src/lineage/export-privacy.js +258 -0
  101. package/src/lineage/federation-loader.js +111 -0
  102. package/src/lineage/field-identity.js +78 -0
  103. package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
  104. package/src/lineage/fixtures/flagship-graph.json +1453 -0
  105. package/src/lineage/flow-grade.js +221 -0
  106. package/src/lineage/governance-edit.js +169 -0
  107. package/src/lineage/graph-builder.js +1114 -0
  108. package/src/lineage/graph-diff.js +431 -0
  109. package/src/lineage/graph-snapshot.js +180 -0
  110. package/src/lineage/handling-analyzer.js +168 -0
  111. package/src/lineage/ids.js +349 -0
  112. package/src/lineage/impact-assessment.js +76 -0
  113. package/src/lineage/impact-engine.js +268 -0
  114. package/src/lineage/index.js +281 -0
  115. package/src/lineage/language-coverage-tiers.js +58 -0
  116. package/src/lineage/obligation-mapping.js +126 -0
  117. package/src/lineage/obligation-predicates.js +235 -0
  118. package/src/lineage/observation-adapters.js +282 -0
  119. package/src/lineage/observation-correlation.js +622 -0
  120. package/src/lineage/observation-store.js +497 -0
  121. package/src/lineage/path-query.js +410 -0
  122. package/src/lineage/path-store.js +400 -0
  123. package/src/lineage/protection.js +53 -0
  124. package/src/lineage/recipient-profile.js +192 -0
  125. package/src/lineage/recipient-registry.js +394 -0
  126. package/src/lineage/redact-graph.js +224 -0
  127. package/src/lineage/remediation.js +417 -0
  128. package/src/lineage/resolve-destination.js +91 -0
  129. package/src/lineage/runtime-observation.js +464 -0
  130. package/src/lineage/scenario-diff.js +84 -0
  131. package/src/lineage/scenario-engine.js +251 -0
  132. package/src/lineage/scenario.js +101 -0
  133. package/src/lineage/schema.js +167 -0
  134. package/src/lineage/sink-registry.js +427 -0
  135. package/src/lineage/source-registry.js +357 -0
  136. package/src/lineage/source-seeding.js +212 -0
  137. package/src/lineage/summaries.js +590 -0
  138. package/src/lineage/transform-catalog.js +397 -0
  139. package/src/lineage/transit-protection.js +150 -0
  140. package/src/lineage/validate.js +285 -0
  141. package/src/mcp/CLAUDE.md +7 -1
  142. package/src/mcp/dataflow-tools.js +160 -0
  143. package/src/mcp/server.js +1 -1
  144. package/src/mcp/tools.js +2 -1
  145. package/src/pipeline/scan-health.js +19 -1
  146. package/src/posture/CLAUDE.md +19 -0
  147. package/src/posture/artifact-registry.js +52 -0
  148. package/src/posture/auditor-walkthrough.js +76 -0
  149. package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
  150. package/src/posture/obligation-evidence-pack.js +202 -0
  151. package/src/posture/remediation-ledger.js +337 -0
  152. package/src/server/CLAUDE.md +47 -0
  153. package/src/server/graph-loader.js +141 -0
  154. package/src/server/http-server.js +325 -0
  155. package/src/server/routes.js +129 -0
  156. package/src/server/security.js +111 -0
  157. package/src/server/static-assets.js +139 -0
  158. package/dist/11.index.js +0 -353
  159. package/dist/259.index.js +0 -975
  160. package/dist/317.index.js +0 -300
  161. package/dist/609.index.js +0 -741
  162. package/dist/838.index.js +0 -152
@@ -0,0 +1,279 @@
1
+ // drift-policy.js — M4 deliverable #8, sub-project 8b, Task 2: a
2
+ // before/after drift-policy DSL evaluated against a GraphDiff (Task 1's
3
+ // own output, see graph-diff.js).
4
+ //
5
+ // Mirrors dataflow/privacy-sink-policy.js's fail-closed axis-matching
6
+ // STYLE exactly — the `_matchesEnvironment`/`_matchesDestination`
7
+ // precedent (an unset rule field is unconstrained; a set field the
8
+ // current context has no comparable value for does NOT match) — never
9
+ // its literal single-graph-state shape. That module evaluates a static
10
+ // state ("is this (class, sink) pair permitted right now"); this module
11
+ // evaluates a TRANSITION ("did this (class, sink) pair newly start
12
+ // flowing, or did this flow's own verdict regress"), which is why its
13
+ // rule shape is `{trigger, ...}`-keyed rather than `{allow: [...]}`.
14
+ //
15
+ // ── Design judgment calls made in this file (disclosed, not hidden) ──
16
+ //
17
+ // 1. `loadDriftPolicies(policyFilePath)` takes a literal file path, not
18
+ // a scanRoot — a deliberate, task-brief-directed divergence from
19
+ // `loadPrivacySinkPolicy(scanRoot)`'s own scanRoot-relative
20
+ // convention (`statePath(scanRoot, 'privacy-policy.json')`). Drift
21
+ // policies are a new artifact with no established scanRoot-relative
22
+ // home yet (Task 3's CLI wiring is the only real caller, and it can
23
+ // resolve whatever path convention it wants — e.g. a `--policy`
24
+ // flag, or a default under `.agentic-security/` — without this
25
+ // module forcing that choice). Keeping the signature a plain path
26
+ // keeps this module honest about being a pure evaluator with no
27
+ // opinion on where its config file lives.
28
+ //
29
+ // 2. The rule vocabulary generalizes `fromPolicyVerdict`/
30
+ // `toPolicyVerdict` to a second, structurally identical pair,
31
+ // `fromProtectionSummary`/`toProtectionSummary`, rather than adding a
32
+ // generic `field`/`from`/`to` triple. The task brief's own worked
33
+ // examples name `protectionSummary`'s "protected -> unknown/
34
+ // unprotected" transition as "an equivalent from/to pair for
35
+ // protectionSummary, generalizing" the policyVerdict pair — read
36
+ // literally, that is an instruction to add the SAME shape for a
37
+ // second watched field, not to invent a fully generic DSL over every
38
+ // WATCHED_FLOW_FIELDS entry. A generic `field`/`from`/`to` triple was
39
+ // considered and rejected: nothing in the brief's worked examples
40
+ // needs a THIRD field's transition, and a generic form would need its
41
+ // own validation against `WATCHED_FLOW_FIELDS` this task's tests
42
+ // don't exercise. Extending to a fully generic form remains easy
43
+ // later (both matchers already share one small helper,
44
+ // `_matchesVerdictTransition`) without a rule-shape migration for
45
+ // what's shipped today.
46
+ //
47
+ // 3. `dataClass`/`sinkCategory` are read as ADDITIONAL, optional
48
+ // constraints on a `changed_flow` rule too, not just `new_flow`
49
+ // rules — the brief's own rule shape lists them as top-level fields
50
+ // (not scoped textually to `new_flow` alone), and a changed flow's
51
+ // id is guaranteed present in `graphAfter` by construction (a
52
+ // `changed.flows` entry only exists when the SAME flow id survived
53
+ // into the after-graph — see graph-diff.js's own `computeGraphDiff`
54
+ // header), so the same `_resolveFlowContext` resolution that
55
+ // `new_flow` rules use applies unchanged. This lets an operator write
56
+ // "a PHI flow that regresses from protected" as one rule instead of
57
+ // two, and costs nothing when a rule doesn't set either field (both
58
+ // stay unconstrained, per the fail-closed style above).
59
+ //
60
+ // 4. A `changed_flow` rule that sets none of
61
+ // dataClass/sinkCategory/fromPolicyVerdict/toPolicyVerdict/
62
+ // fromProtectionSummary/toProtectionSummary matches EVERY
63
+ // `changed.flows` entry — every axis is independently unconstrained,
64
+ // so a rule with no axes set is the fully unconstrained case, exactly
65
+ // as an `allow` rule with neither `class` nor `sink` would be in
66
+ // `privacy-sink-policy.js` (it doesn't have that case in practice
67
+ // because `sink` is required there; this module has no required axis
68
+ // besides `trigger`, so the maximally-open rule is reachable and
69
+ // intentional — "alert on ANY change to a flow matching these
70
+ // constraints" is a real, useful policy shape).
71
+ //
72
+ // 5. The task brief's PRD-quoted worked examples also name "PII -> AI"
73
+ // ,"PCI -> log" (both plain `new_flow` shapes, already covered by
74
+ // dataClass/sinkCategory matching — no separate mechanism needed) and
75
+ // "new unresolved recipient" (a `governanceRefs.recipient`-shaped
76
+ // transition). The brief's own "Tests to write" section does not
77
+ // require a test for the recipient example, and it does not reduce
78
+ // cleanly to either matcher this task implements (a "new unresolved
79
+ // recipient" is a value becoming a specific sentinel, not a flow
80
+ // newly existing or an already-modeled verdict field transitioning)
81
+ // — left out rather than guessed at. Judgment call #2's rejected
82
+ // generic `field`/`from`/`to` form is the natural place to add it
83
+ // later, against a real governanceRefs.recipient fixture.
84
+ //
85
+ // 6. Fix round 1, Important 1 (see graph-diff.js's own judgment call #4):
86
+ // a `new_flow` rule must skip an added flow entry whose
87
+ // `causeClassification === 'reidentified'` — that flow isn't actually
88
+ // new, it's the SAME real-world (source, sink, dataElementIds) flow as
89
+ // a flow that existed before, just minted under a new flowId because
90
+ // the engine's own confidence/shape (evidenceGrade/transformationIds)
91
+ // changed. Firing `new_flow` on it would false-fire `--fail-on-drift`
92
+ // on a non-event, exactly what this fix round exists to close.
93
+
94
+ import * as fs from 'node:fs';
95
+
96
+ const POLICY_TRIGGERS = Object.freeze(['new_flow', 'changed_flow']);
97
+
98
+ function _isValidRuleShape(p) {
99
+ return !!p && typeof p === 'object' && !Array.isArray(p) && POLICY_TRIGGERS.includes(p.trigger);
100
+ }
101
+
102
+ /**
103
+ * Load an operator's drift-policy rules from a literal JSON file path
104
+ * (see judgment call #1 above — never a scanRoot). Never throws — a
105
+ * missing file (ENOENT) is "no policies configured," matching
106
+ * `loadPrivacySinkPolicy`'s own precedent; a malformed file logs a
107
+ * warning and degrades to no policies. Each entry is validated loosely:
108
+ * an entry that isn't a plain object with a recognized `trigger` is
109
+ * skipped (with a warning naming how many were dropped), never crashing
110
+ * the whole load over one bad rule.
111
+ */
112
+ export function loadDriftPolicies(policyFilePath) {
113
+ const EMPTY = { policies: [] };
114
+ if (!policyFilePath) return EMPTY;
115
+
116
+ let raw = null;
117
+ try {
118
+ raw = JSON.parse(fs.readFileSync(policyFilePath, 'utf8'));
119
+ } catch (e) {
120
+ if (e.code !== 'ENOENT') {
121
+ console.error(`agentic-security: bad JSON in drift policy file (${policyFilePath}) — falling back to no policies (${e.message})`);
122
+ }
123
+ return EMPTY;
124
+ }
125
+
126
+ if (!Array.isArray(raw?.policies)) {
127
+ console.error(`agentic-security: drift policy file ${policyFilePath} has no "policies" array — falling back to no policies (expected {"policies":[...]})`);
128
+ }
129
+ const rawPolicies = Array.isArray(raw?.policies) ? raw.policies : [];
130
+ const policies = rawPolicies.filter(_isValidRuleShape);
131
+ const skipped = rawPolicies.length - policies.length;
132
+ if (skipped > 0) {
133
+ console.error(`agentic-security: skipped ${skipped} malformed drift-policy rule(s) in ${policyFilePath} (each must be an object with trigger: 'new_flow' | 'changed_flow')`);
134
+ }
135
+ return { policies };
136
+ }
137
+
138
+ // Fail-closed, matching privacy-sink-policy.js's own
139
+ // _matchesEnvironment/_matchesDestination precedent exactly: an unset
140
+ // rule value is unconstrained (matches anything); a set rule value with
141
+ // no comparable current value does NOT match.
142
+ function _matchesDataClass(ruleDataClass, dataClasses) {
143
+ if (ruleDataClass == null) return true;
144
+ if (!Array.isArray(dataClasses) || !dataClasses.length) return false;
145
+ return dataClasses.includes(ruleDataClass);
146
+ }
147
+
148
+ function _matchesSinkCategory(ruleSinkCategory, sinkCategory) {
149
+ if (ruleSinkCategory == null) return true;
150
+ if (!sinkCategory) return false;
151
+ return ruleSinkCategory === sinkCategory;
152
+ }
153
+
154
+ // Matches a rule's {fromX, toX} pair against the {before, after} of the
155
+ // changes[] entry for field X (or undefined, when that field didn't
156
+ // change on this flow). Unset fromX/toX is unconstrained. A set fromX/
157
+ // toX with no changes[] entry for that field at all (the flow changed,
158
+ // but not on this axis) does NOT match — fails closed, same as an
159
+ // environment-scoped rule against a caller who supplied no environment.
160
+ function _matchesVerdictTransition(ruleFrom, ruleTo, changeEntry) {
161
+ if (ruleFrom == null && ruleTo == null) return true;
162
+ if (!changeEntry) return false;
163
+ if (ruleFrom != null && changeEntry.before !== ruleFrom) return false;
164
+ if (ruleTo != null && changeEntry.after !== ruleTo) return false;
165
+ return true;
166
+ }
167
+
168
+ // Resolves the real dataClasses/sinkCategory/sinkNode/flow for a flow id
169
+ // against graphAfter — shared by both new_flow (added.flows entries) and
170
+ // changed_flow (changed.flows entries) matching, since a changed.flows
171
+ // entry's id is, by construction, present in graphAfter too (see
172
+ // graph-diff.js's own computeGraphDiff: a changed entry only exists for
173
+ // a flow id surviving into the after-graph). Returns null when the id
174
+ // can't be resolved (should not happen for a well-formed GraphDiff
175
+ // produced by computeGraphDiff against its own graphAfter, but a rule
176
+ // must never crash the whole evaluation over a caller-supplied
177
+ // diff/graph pairing that doesn't actually match).
178
+ function _resolveFlowContext(flowId, graphAfter) {
179
+ const flow = (graphAfter?.flows ?? []).find((f) => f.id === flowId);
180
+ if (!flow) return null;
181
+
182
+ const dataClasses = [];
183
+ const seen = new Set();
184
+ for (const deId of flow.dataElementIds ?? []) {
185
+ const de = (graphAfter.dataElements ?? []).find((d) => d.id === deId);
186
+ if (!de) continue;
187
+ for (const cls of de.dataClasses ?? []) {
188
+ if (!seen.has(cls)) { seen.add(cls); dataClasses.push(cls); }
189
+ }
190
+ }
191
+ const dataElementNames = (flow.dataElementIds ?? [])
192
+ .map((id) => (graphAfter.dataElements ?? []).find((d) => d.id === id)?.name)
193
+ .filter((n) => typeof n === 'string' && n.length > 0);
194
+
195
+ const sinkNode = (graphAfter.nodes ?? []).find((n) => n.id === flow.sink) ?? null;
196
+ const sinkCategory = sinkNode?.subtype ?? sinkNode?.kind ?? null;
197
+
198
+ return { flow, dataClasses, dataElementNames, sinkNode, sinkCategory };
199
+ }
200
+
201
+ function _describeTransition(changeEntry) {
202
+ if (!changeEntry) return null;
203
+ return `${changeEntry.field} ${changeEntry.before} -> ${changeEntry.after}`;
204
+ }
205
+
206
+ function _buildViolation(rule, trigger, ctx, transitionDescriptions) {
207
+ const base = trigger === 'new_flow'
208
+ ? `New flow ${ctx.flow.id} (${ctx.dataClasses.join('/') || 'unclassified data'}) newly reaches sink ${ctx.sinkCategory ?? ctx.sinkNode?.id ?? 'unknown'}`
209
+ : `Flow ${ctx.flow.id} changed: ${transitionDescriptions.length ? transitionDescriptions.join(', ') : 'matched changed_flow rule (no policyVerdict/protectionSummary transition named by this rule)'}`;
210
+ const reason = rule.reason ? `${base} (${rule.reason})` : base;
211
+
212
+ return {
213
+ flowId: ctx.flow.id,
214
+ dataElementNames: ctx.dataElementNames,
215
+ sinkCategory: ctx.sinkCategory,
216
+ sinkNodeId: ctx.sinkNode?.id ?? ctx.flow.sink,
217
+ trigger,
218
+ rule,
219
+ reason,
220
+ };
221
+ }
222
+
223
+ /**
224
+ * Evaluates operator-configured drift policies against a GraphDiff.
225
+ * `policies` is `{policies: [...]}` (loadDriftPolicies' own return
226
+ * shape). `graphAfter` is the AFTER graph the diff was computed against
227
+ * — needed because an `added.flows`/`changed.flows` entry carries only
228
+ * `{id, causeClassification, ...}` (see graph-diff.js's own header),
229
+ * never dataElement/sink info directly.
230
+ *
231
+ * Returns `{violations: [...]}` — never a bare boolean per flow; every
232
+ * violation names the real triggering flow (id, data element names,
233
+ * sink), the rule that fired, and a human-readable `reason` string.
234
+ */
235
+ export function evaluateDriftPolicies(diff, policies, graphAfter) {
236
+ const violations = [];
237
+ const rules = Array.isArray(policies?.policies) ? policies.policies : [];
238
+ if (!rules.length) return { violations };
239
+
240
+ for (const rule of rules) {
241
+ if (rule.trigger === 'new_flow') {
242
+ for (const addedEntry of diff?.added?.flows ?? []) {
243
+ // See judgment call #6 above / graph-diff.js's own judgment call
244
+ // #4: a reidentified added flow is not actually new.
245
+ if (addedEntry.causeClassification === 'reidentified') continue;
246
+ const ctx = _resolveFlowContext(addedEntry.id, graphAfter);
247
+ if (!ctx) continue;
248
+ if (!_matchesDataClass(rule.dataClass, ctx.dataClasses)) continue;
249
+ if (!_matchesSinkCategory(rule.sinkCategory, ctx.sinkCategory)) continue;
250
+ violations.push(_buildViolation(rule, 'new_flow', ctx, []));
251
+ }
252
+ } else if (rule.trigger === 'changed_flow') {
253
+ for (const changedEntry of diff?.changed?.flows ?? []) {
254
+ const ctx = _resolveFlowContext(changedEntry.id, graphAfter);
255
+ if (!ctx) continue;
256
+ if (!_matchesDataClass(rule.dataClass, ctx.dataClasses)) continue;
257
+ if (!_matchesSinkCategory(rule.sinkCategory, ctx.sinkCategory)) continue;
258
+
259
+ const changes = Array.isArray(changedEntry.changes) ? changedEntry.changes : [];
260
+ const policyChange = changes.find((c) => c.field === 'policyVerdict');
261
+ if (!_matchesVerdictTransition(rule.fromPolicyVerdict, rule.toPolicyVerdict, policyChange)) continue;
262
+
263
+ const protectionChange = changes.find((c) => c.field === 'protectionSummary');
264
+ if (!_matchesVerdictTransition(rule.fromProtectionSummary, rule.toProtectionSummary, protectionChange)) continue;
265
+
266
+ const transitionDescriptions = [policyChange, protectionChange].map(_describeTransition).filter(Boolean);
267
+ violations.push(_buildViolation(rule, 'changed_flow', ctx, transitionDescriptions));
268
+ }
269
+ }
270
+ // An unrecognized trigger was already filtered out by
271
+ // loadDriftPolicies' own _isValidRuleShape when loaded from disk; a
272
+ // directly hand-built `policies` object (as tests do) with an
273
+ // unrecognized trigger simply matches nothing, silently — the same
274
+ // "match nothing rather than throw" discipline as every other
275
+ // unresolvable axis in this file.
276
+ }
277
+
278
+ return { violations };
279
+ }
@@ -0,0 +1,135 @@
1
+ import { emptyState } from './field-identity.js';
2
+ import { analyzeFunctionFieldIdentity } from './engine.js';
3
+ import {
4
+ FieldIdentitySummaryCache,
5
+ createCallGraphLookup,
6
+ createCallSummaryResolver,
7
+ summaryFromAnalysisResult,
8
+ } from './summaries.js';
9
+
10
+ // Project-wide driver (Sub-project B, increment 4) — mirrors
11
+ // dataflow/engine.js's runTaintEngine Phase A/B structure, adapted to this
12
+ // package's own machinery: B1-B3 already built a fully lazy, on-demand
13
+ // interprocedural resolution mechanism, but nothing yet DRIVES it across
14
+ // an entire project — every prior increment's test only ever analyzed ONE
15
+ // caller function, hand-picking which callee to resolve. This is the
16
+ // "producing summaries for a whole small project's worth of functions,
17
+ // not just one call site at a time" piece the scoping doc calls out.
18
+ //
19
+ // Unlike dataflow's own 3-sub-pass Phase A (empty-entry pre-pass plus two
20
+ // SPECULATIVE precompute passes, justified by that engine's need to
21
+ // pre-seed conservative summaries so a non-computing cache LOOKUP
22
+ // mid-expression-walk doesn't have to guess), this driver needs none of
23
+ // that: this package's one call-consultation point (`resolveCallSummary`,
24
+ // built in B1/B2) already lazily `cache.compute()`s on every miss — see
25
+ // `createCallSummaryResolver`. A single pass over every function, each
26
+ // analyzed with its own empty entry state and a ctx wired for
27
+ // interprocedural resolution, is therefore sufficient: whichever order
28
+ // functions are visited in, a call to an as-yet-unvisited callee still
29
+ // resolves correctly (lazily, on demand) rather than falling back to a
30
+ // conservative default. No fixed-point loop here — recursive/cyclic
31
+ // convergence refinement is increment B5's job; this driver relies
32
+ // entirely on B1's existing `_stack`-based bottom-stub for safety on a
33
+ // recursive/cyclic call graph, exactly as B1-B3 already did.
34
+ //
35
+ // `callGraph` must be a real object from
36
+ // `scanner/src/ir/callgraph.js#buildCallGraph` (`{functions,
37
+ // resolveKnownCallee, ...}`) or an equivalent hand-built fixture exposing
38
+ // the same shape (see this file's own tests). `opts.maxContextsPerFn` is
39
+ // forwarded to a fresh `FieldIdentitySummaryCache` unless `opts.cache` is
40
+ // supplied directly, letting a caller reuse/inspect the cache afterward or
41
+ // seed it before calling — mirrors dataflow's own `opts.summaryCache`
42
+ // escape hatch in `runTaintEngine`.
43
+ //
44
+ // `opts.seedEntryState(fn) -> state | falsy` (Sub-project E, increment 1)
45
+ // is the SOURCE-SEEDING hook: it supplies the per-function entry state a
46
+ // source registry derived from real matched call sites inside `fn`. It is
47
+ // additive and opt-in — omit it and every observable output of this
48
+ // function is byte-identical to what it produced before the hook existed
49
+ // (`test/lineage/driver.test.js`'s E1 regression tests pin this in both
50
+ // directions, against a golden literal). `opts.recordHop` established
51
+ // exactly this contract for this file; see DESIGN_GRAPH_BUILDER.md §3.
52
+ //
53
+ // Returns `{results, cache}`: `results` is a `Map<qid, rawAnalysisResult>`
54
+ // — the raw `{exitState, returnFacts, mutatedParams, widenings}` shape
55
+ // `analyzeFunctionFieldIdentity` itself returns, one entry per function in
56
+ // `callGraph.functions` — and `cache` is the `FieldIdentitySummaryCache`
57
+ // instance used throughout, seeded with every function's own empty-entry
58
+ // summary (converted via `summaryFromAnalysisResult`) plus whatever
59
+ // additional real-context entries were computed lazily along the way as
60
+ // call sites were resolved.
61
+ export function runFieldIdentityAnalysis(callGraph, opts = {}) {
62
+ const cache = opts.cache instanceof FieldIdentitySummaryCache
63
+ ? opts.cache
64
+ : new FieldIdentitySummaryCache(opts.maxContextsPerFn);
65
+
66
+ const fnList = callGraph && callGraph.functions
67
+ ? [...callGraph.functions.values()].sort((a, b) => (a.qid < b.qid ? -1 : a.qid > b.qid ? 1 : 0))
68
+ : [];
69
+
70
+ const results = new Map();
71
+ for (const fn of fnList) {
72
+ const lookupCallee = createCallGraphLookup(callGraph, fn.file);
73
+ const resolveCallSummary = createCallSummaryResolver(cache, lookupCallee);
74
+ // Path provenance (Sub-project C, increment 3, §13.7 item 14): thread
75
+ // opts.recordHop into the per-function ctx CONDITIONALLY, so a caller
76
+ // that supplies none gets a byte-identical `{ resolveCallSummary }`
77
+ // object to what this function constructed before this change —
78
+ // Decision 7.2's "true by construction" property, extended to the
79
+ // driver.
80
+ const ctx = opts.recordHop ? { resolveCallSummary, recordHop: opts.recordHop } : { resolveCallSummary };
81
+ // Sub-project E, increment 1 (DESIGN_GRAPH_BUILDER.md §3): the seeding
82
+ // hook. `opts.seedEntryState(fn)` returns the entry state this function
83
+ // should be analyzed under — a `field-identity.js` state carrying the
84
+ // data-element identities a source registry matched at real call sites
85
+ // INSIDE `fn`. Additive and opt-in, exactly like `opts.recordHop`
86
+ // above: with no hook supplied, `entryState` is `emptyState()` and both
87
+ // this call and the `cache.set` below are byte-identical to what this
88
+ // loop did before the hook existed (Decision 7.2's "true by
89
+ // construction" property, extended once more). A hook returning a
90
+ // falsy value is treated as "no seed for this function", so a caller
91
+ // can seed a subset without special-casing the rest.
92
+ const entryState = opts.seedEntryState ? (opts.seedEntryState(fn) || emptyState()) : emptyState();
93
+ const result = analyzeFunctionFieldIdentity(fn, entryState, ctx);
94
+ results.set(fn.qid, result);
95
+ // Seed the cache with this function's OWN empty-entry summary directly
96
+ // (not via cache.compute, which is reserved for a CALL SITE resolving
97
+ // an as-yet-uncomputed callee) — this is what lets a LATER function in
98
+ // fnList that calls this one reuse the driver's own result instead of
99
+ // silently recomputing it a second time.
100
+ //
101
+ // This CAN overwrite an entry a callee-triggered lazy compute() already
102
+ // wrote for the SAME (qid, emptyState()) key earlier in this loop — and
103
+ // a final whole-branch review proved by direct construction that the
104
+ // two computations are NOT guaranteed to agree: createCallGraphLookup's
105
+ // `callerFile` is fixed to the CALLING function's file, so when fn was
106
+ // first analyzed lazily (as someone else's callee), any bare-identifier
107
+ // call fn itself makes was resolved preferring THAT CALLER's file, not
108
+ // fn's own. This loop's own direct call above uses fn.file, the
109
+ // correct scope, so the overwrite here always uses the more precise of
110
+ // the two answers, never a worse one — but it is a real overwrite of a
111
+ // possibly-different prior value, not merely redundant re-computation
112
+ // of an identical one. Today every entry state is emptyState() (no
113
+ // source registry yet), so no identity can actually differ between the
114
+ // two computations and this is unobservable; it becomes load-bearing
115
+ // the moment entry states carry real identities.
116
+ //
117
+ // Sub-project E, increment 1 CLOSED THE SECOND, WORSE HALF of that
118
+ // hazard, which the paragraph above did not name: the KEY. This line
119
+ // used to write the summary under `emptyState()` unconditionally, i.e.
120
+ // under a key that claims "this is what `fn` does when nothing flows
121
+ // in". Once a seed puts real identities into `entryState`, that claim
122
+ // is FALSE — a later call site resolving `fn` with clean arguments
123
+ // builds an empty callee entry state, hits this key, and is handed
124
+ // return facts that exist only because the DRIVER seeded them. That is
125
+ // a fabricated identity at a call site nothing tainted, and it is
126
+ // measurable (see DESIGN_GRAPH_BUILDER.md §3.6: a real two-function
127
+ // fixture, plus a 27% swing in accepted hops on a 33-file project).
128
+ // Keying by the state actually analyzed is both the fix and a no-op
129
+ // when no hook is supplied — `entryState` IS `emptyState()` then, so
130
+ // this line stays byte-identical for every pre-existing caller.
131
+ cache.set(fn.qid, entryState, summaryFromAnalysisResult(result));
132
+ }
133
+
134
+ return { results, cache };
135
+ }