@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.
- package/CHANGELOG.md +139 -0
- package/bin/agentic-security.js +3508 -69
- package/dist/1122.index.js +702 -0
- package/dist/{301.index.js → 1301.index.js} +2 -2
- package/dist/1379.index.js +591 -0
- package/dist/{444.index.js → 1444.index.js} +2 -2
- package/dist/{660.index.js → 1660.index.js} +2 -2
- package/dist/{700.index.js → 1700.index.js} +2 -2
- package/dist/{905.index.js → 1905.index.js} +3 -3
- package/dist/{920.index.js → 1920.index.js} +3 -3
- package/dist/{238.index.js → 2238.index.js} +3 -3
- package/dist/{271.index.js → 2271.index.js} +2 -2
- package/dist/{985.index.js → 2376.index.js} +1260 -340
- package/dist/2432.index.js +793 -0
- package/dist/2659.index.js +93 -0
- package/dist/{826.index.js → 2826.index.js} +2 -2
- package/dist/{830.index.js → 2830.index.js} +2 -2
- package/dist/2923.index.js +298 -0
- package/dist/{1.index.js → 3001.index.js} +5 -5
- package/dist/{117.index.js → 3117.index.js} +3 -3
- package/dist/3180.index.js +307 -0
- package/dist/3276.index.js +117 -0
- package/dist/{415.index.js → 3415.index.js} +2 -2
- package/dist/{499.index.js → 3499.index.js} +2 -2
- package/dist/3518.index.js +450 -0
- package/dist/{526.index.js → 3526.index.js} +5 -5
- package/dist/{736.index.js → 3736.index.js} +4 -4
- package/dist/{839.index.js → 3839.index.js} +4 -4
- package/dist/{113.index.js → 4113.index.js} +5 -5
- package/dist/{265.index.js → 4265.index.js} +2 -2
- package/dist/{384.index.js → 4384.index.js} +3 -3
- package/dist/4547.index.js +268 -0
- package/dist/4863.index.js +422 -0
- package/dist/{970.index.js → 4970.index.js} +2 -2
- package/dist/5051.index.js +770 -0
- package/dist/{144.index.js → 5144.index.js} +5 -5
- package/dist/{333.index.js → 5333.index.js} +3 -3
- package/dist/5343.index.js +185 -0
- package/dist/5350.index.js +866 -0
- package/dist/5561.index.js +436 -0
- package/dist/{637.index.js → 5637.index.js} +3 -3
- package/dist/{449.index.js → 5830.index.js} +2 -2
- package/dist/6626.index.js +532 -0
- package/dist/6662.index.js +297 -0
- package/dist/{675.index.js → 6675.index.js} +5 -5
- package/dist/{730.index.js → 6730.index.js} +6 -6
- package/dist/6829.index.js +225 -0
- package/dist/6944.index.js +130 -0
- package/dist/{178.index.js → 7178.index.js} +3 -3
- package/dist/{227.index.js → 7227.index.js} +2 -2
- package/dist/7310.index.js +520 -0
- package/dist/{552.index.js → 7552.index.js} +4 -4
- package/dist/7709.index.js +78 -0
- package/dist/8218.index.js +160 -0
- package/dist/{476.index.js → 8476.index.js} +4 -4
- package/dist/{513.index.js → 8513.index.js} +5 -5
- package/dist/{520.index.js → 8520.index.js} +2 -2
- package/dist/{718.index.js → 8718.index.js} +2 -2
- package/dist/{752.index.js → 8752.index.js} +2 -2
- package/dist/8846.index.js +100 -0
- package/dist/{435.index.js → 9091.index.js} +560 -189
- package/dist/{207.index.js → 9207.index.js} +2 -2
- package/dist/{220.index.js → 9220.index.js} +2 -2
- package/dist/9390.index.js +163 -0
- package/dist/{503.index.js → 9503.index.js} +2 -2
- package/dist/{801.index.js → 9801.index.js} +2 -2
- package/dist/{824.index.js → 9824.index.js} +2 -2
- package/dist/agentic-security.mjs +16 -16
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/compliance-frameworks/hipaa-security-rule.json +3 -2
- package/package.json +12 -5
- package/src/dataflow/CLAUDE.md +1 -1
- package/src/dataflow/catalog.js +42 -0
- package/src/dataflow/orm-write-catalog.js +175 -0
- package/src/engine.js +92 -1
- package/src/ir/CLAUDE.md +1 -0
- package/src/ir/chrome-probe.mjs +150 -0
- package/src/ir/parser-js.js +94 -7
- package/src/lineage/CLAUDE.md +1203 -0
- package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
- package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
- package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
- package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
- package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
- package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
- package/src/lineage/DESIGN_REGISTRIES.md +880 -0
- package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
- package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
- package/src/lineage/classification.js +56 -0
- package/src/lineage/coverage.js +658 -0
- package/src/lineage/cross-repo-link.js +107 -0
- package/src/lineage/dataflow-graph.schema.json +184 -0
- package/src/lineage/decision-story.js +206 -0
- package/src/lineage/drift-policy.js +279 -0
- package/src/lineage/driver.js +135 -0
- package/src/lineage/engine.js +992 -0
- package/src/lineage/export-briefing.js +628 -0
- package/src/lineage/export-csv.js +62 -0
- package/src/lineage/export-json.js +238 -0
- package/src/lineage/export-privacy.js +258 -0
- package/src/lineage/federation-loader.js +111 -0
- package/src/lineage/field-identity.js +78 -0
- package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
- package/src/lineage/fixtures/flagship-graph.json +1453 -0
- package/src/lineage/flow-grade.js +221 -0
- package/src/lineage/governance-edit.js +169 -0
- package/src/lineage/graph-builder.js +1114 -0
- package/src/lineage/graph-diff.js +431 -0
- package/src/lineage/graph-snapshot.js +180 -0
- package/src/lineage/handling-analyzer.js +168 -0
- package/src/lineage/ids.js +349 -0
- package/src/lineage/impact-assessment.js +76 -0
- package/src/lineage/impact-engine.js +268 -0
- package/src/lineage/index.js +281 -0
- package/src/lineage/language-coverage-tiers.js +58 -0
- package/src/lineage/obligation-mapping.js +126 -0
- package/src/lineage/obligation-predicates.js +235 -0
- package/src/lineage/observation-adapters.js +282 -0
- package/src/lineage/observation-correlation.js +622 -0
- package/src/lineage/observation-store.js +497 -0
- package/src/lineage/path-query.js +410 -0
- package/src/lineage/path-store.js +400 -0
- package/src/lineage/protection.js +53 -0
- package/src/lineage/recipient-profile.js +192 -0
- package/src/lineage/recipient-registry.js +394 -0
- package/src/lineage/redact-graph.js +224 -0
- package/src/lineage/remediation.js +417 -0
- package/src/lineage/resolve-destination.js +91 -0
- package/src/lineage/runtime-observation.js +464 -0
- package/src/lineage/scenario-diff.js +84 -0
- package/src/lineage/scenario-engine.js +251 -0
- package/src/lineage/scenario.js +101 -0
- package/src/lineage/schema.js +167 -0
- package/src/lineage/sink-registry.js +427 -0
- package/src/lineage/source-registry.js +357 -0
- package/src/lineage/source-seeding.js +212 -0
- package/src/lineage/summaries.js +590 -0
- package/src/lineage/transform-catalog.js +397 -0
- package/src/lineage/transit-protection.js +150 -0
- package/src/lineage/validate.js +285 -0
- package/src/mcp/CLAUDE.md +7 -1
- package/src/mcp/dataflow-tools.js +160 -0
- package/src/mcp/server.js +1 -1
- package/src/mcp/tools.js +2 -1
- package/src/pipeline/scan-health.js +19 -1
- package/src/posture/CLAUDE.md +19 -0
- package/src/posture/artifact-registry.js +52 -0
- package/src/posture/auditor-walkthrough.js +76 -0
- package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
- package/src/posture/obligation-evidence-pack.js +202 -0
- package/src/posture/remediation-ledger.js +337 -0
- package/src/server/CLAUDE.md +47 -0
- package/src/server/graph-loader.js +141 -0
- package/src/server/http-server.js +325 -0
- package/src/server/routes.js +129 -0
- package/src/server/security.js +111 -0
- package/src/server/static-assets.js +139 -0
- package/dist/11.index.js +0 -353
- package/dist/259.index.js +0 -975
- package/dist/317.index.js +0 -300
- package/dist/609.index.js +0 -741
- 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
|
+
}
|