@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.
- package/CHANGELOG.md +266 -0
- package/bin/agentic-security.js +3508 -69
- package/dist/1122.index.js +779 -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 +379 -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 +841 -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/dist/frontend/index.html +21 -0
- package/dist/frontend/src/app.js +176 -0
- package/dist/frontend/src/components/evidence-inspector.js +141 -0
- package/dist/frontend/src/components/filter-rail.js +119 -0
- package/dist/frontend/src/components/query-bar.js +126 -0
- package/dist/frontend/src/data/flagship-graph.js +1460 -0
- package/dist/frontend/src/export-entry.js +36 -0
- package/dist/frontend/src/lib/api-client.js +92 -0
- package/dist/frontend/src/lib/contrast.js +34 -0
- package/dist/frontend/src/lib/dom.js +24 -0
- package/dist/frontend/src/lib/escape-html.js +16 -0
- package/dist/frontend/src/lib/flow-path.js +40 -0
- package/dist/frontend/src/lib/focus-controls.js +149 -0
- package/dist/frontend/src/lib/protection-visual.js +46 -0
- package/dist/frontend/src/lib/query-language.js +240 -0
- package/dist/frontend/src/lib/row-filters.js +43 -0
- package/dist/frontend/src/lib/state.js +84 -0
- package/dist/frontend/src/main.js +83 -0
- package/dist/frontend/src/shell.js +184 -0
- package/dist/frontend/src/views/architecture-view.js +798 -0
- package/dist/frontend/src/views/inventory-view.js +292 -0
- package/dist/frontend/src/views/privacy-view.js +172 -0
- package/dist/frontend/src/views/trace-view.js +206 -0
- package/dist/frontend/styles/architecture-view.css +93 -0
- package/dist/frontend/styles/filter-rail.css +34 -0
- package/dist/frontend/styles/inspector.css +69 -0
- package/dist/frontend/styles/inventory-view.css +74 -0
- package/dist/frontend/styles/privacy-view.css +86 -0
- package/dist/frontend/styles/query-bar.css +107 -0
- package/dist/frontend/styles/shell.css +155 -0
- package/dist/frontend/styles/tokens.css +128 -0
- package/dist/frontend/styles/trace-view.css +95 -0
- package/package.json +13 -6
- 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 +144 -0
- package/src/shared/frontend-root.js +52 -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,268 @@
|
|
|
1
|
+
// impact-engine.js — M5 deliverable #4 (FR-507): the pure read/
|
|
2
|
+
// aggregate computation behind "assess impact" from a compromised
|
|
3
|
+
// node/edge/flow/data element. Reuses the already-shipped, already-
|
|
4
|
+
// tested BFS traversal in frontend/src/lib/focus-controls.js — the
|
|
5
|
+
// established scanner/src/ -> frontend/src/ cross-import precedent
|
|
6
|
+
// (export-privacy.js's own computePrivacyViewModel import). No
|
|
7
|
+
// mutation, no hypothesis, no re-run of the taint/path pipeline — a
|
|
8
|
+
// pure filter/aggregate over the graph's own already-computed fields.
|
|
9
|
+
|
|
10
|
+
import { showAllPaths } from '../../../frontend/src/lib/focus-controls.js';
|
|
11
|
+
import { computeGraphDigest } from './export-json.js';
|
|
12
|
+
import { impactAssessmentId } from './ids.js';
|
|
13
|
+
import { IMPACT_VERSION } from './impact-assessment.js';
|
|
14
|
+
|
|
15
|
+
// dataElement's real stable-id prefix is 'data:' (see ids.js's own
|
|
16
|
+
// dataElementId(), which returns `data:${hash}` — every real
|
|
17
|
+
// DataFlowGraph v1 document's dataElements[] carries this prefix, not
|
|
18
|
+
// 'de:'). node/edge/flow below match ids.js's nodeId/edgeId/flowId
|
|
19
|
+
// prefixes exactly for the identical reason.
|
|
20
|
+
const _KIND_PREFIXES = Object.freeze({ node: 'node:', edge: 'edge:', flow: 'flow:', dataElement: 'data:' });
|
|
21
|
+
|
|
22
|
+
function _resolveTargetKind(targetId) {
|
|
23
|
+
if (typeof targetId !== 'string') return null;
|
|
24
|
+
for (const [kind, prefix] of Object.entries(_KIND_PREFIXES)) {
|
|
25
|
+
if (targetId.startsWith(prefix)) return kind;
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The seed set for showAllPaths, node targets only — a node target
|
|
31
|
+
// seeds itself; edge/flow/dataElement targets never reach this
|
|
32
|
+
// function any more (final-review I1 fix). See
|
|
33
|
+
// _flowRestrictedAffectedSet's header comment for why: compromising a
|
|
34
|
+
// NODE genuinely puts everything reachable from it in the blast
|
|
35
|
+
// radius, but compromising one EDGE/FLOW/data-element does not — the
|
|
36
|
+
// affected record still NAMES that edge/flow's own two endpoint nodes
|
|
37
|
+
// (you need them to know what the compromised channel actually
|
|
38
|
+
// touches), it just never treats either endpoint as ITSELF fully
|
|
39
|
+
// compromised for the purpose of finding everything ELSE reachable
|
|
40
|
+
// from it via unrelated edges — that further step is exactly the
|
|
41
|
+
// over-inclusion class the original dataElement fix removed.
|
|
42
|
+
function _seedNodeIds(graph, targetId, targetKind) {
|
|
43
|
+
if (targetKind === 'node') return [targetId];
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Generalizes the direct flow-based trace to all three "named
|
|
48
|
+
// relationship" target kinds (dataElement/edge/flow) — each is
|
|
49
|
+
// restricted to the REAL flows that carry it, never the topology-wide
|
|
50
|
+
// showAllPaths BFS below (that stays node-only). A compromised
|
|
51
|
+
// flow/edge/data-element never sweeps in an unrelated sibling flow
|
|
52
|
+
// that merely shares a node — the exact carrier path is already on
|
|
53
|
+
// each flow's own record (source/sink/edgeIds/dataElementIds), so
|
|
54
|
+
// approximating with topology reachability here would discard real
|
|
55
|
+
// data, not fill a gap (reproduced live by this task's own review on
|
|
56
|
+
// a branching-topology graph: one source, three sinks, one flow each
|
|
57
|
+
// carrying a distinct data element — a `flow:`/`edge:` target for the
|
|
58
|
+
// sink-A flow incorrectly swept in sinks B and C, and their unrelated
|
|
59
|
+
// data classes/recipients with them). `matchesFlow(flow) -> boolean`
|
|
60
|
+
// selects which flows carry the target. Also computes dataClasses
|
|
61
|
+
// directly from the SAME matched-flow set (closes a related leak: the
|
|
62
|
+
// edge-membership-based _affectedDataClasses below could pull in an
|
|
63
|
+
// unrelated flow that merely shares one edge with the target flow but
|
|
64
|
+
// carries a different data element — unreachable on any real
|
|
65
|
+
// graph-builder output today since ids.edgeId's own discriminator
|
|
66
|
+
// includes the data element id, but this closes the gap for a future
|
|
67
|
+
// builder or an externally-supplied graph). Never adds a dangling
|
|
68
|
+
// edge id that doesn't resolve to a real edge in graph.edges. Node
|
|
69
|
+
// targets keep the showAllPaths BFS above — for those, "everything
|
|
70
|
+
// topologically reachable from the compromised node's own endpoints"
|
|
71
|
+
// IS the intended pessimistic scope:'possible' semantics.
|
|
72
|
+
function _flowRestrictedAffectedSet(graph, matchesFlow) {
|
|
73
|
+
const nodeIds = new Set();
|
|
74
|
+
const edgeIds = new Set();
|
|
75
|
+
const dataClasses = new Set();
|
|
76
|
+
for (const f of graph.flows ?? []) {
|
|
77
|
+
if (!matchesFlow(f)) continue;
|
|
78
|
+
nodeIds.add(f.source);
|
|
79
|
+
nodeIds.add(f.sink);
|
|
80
|
+
for (const deId of f.dataElementIds ?? []) {
|
|
81
|
+
const de = (graph.dataElements ?? []).find((d) => d.id === deId);
|
|
82
|
+
for (const c of de?.dataClasses ?? []) dataClasses.add(c);
|
|
83
|
+
}
|
|
84
|
+
for (const eId of f.edgeIds ?? []) {
|
|
85
|
+
const edge = (graph.edges ?? []).find((e) => e.id === eId);
|
|
86
|
+
if (!edge) continue; // never surface a dangling/unresolved edge id
|
|
87
|
+
edgeIds.add(eId);
|
|
88
|
+
nodeIds.add(edge.from);
|
|
89
|
+
nodeIds.add(edge.to);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return { nodeIds, edgeIds, dataClasses };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Edge-membership-based aggregation — kept ONLY as the `node` target's
|
|
96
|
+
// fallback (a topology-reachability target has no single "the matched
|
|
97
|
+
// flows" set to read dataClasses off directly, unlike the three named-
|
|
98
|
+
// relationship kinds above, which use _flowRestrictedAffectedSet's own
|
|
99
|
+
// dataClasses instead).
|
|
100
|
+
function _affectedDataClasses(graph, affectedEdgeIds) {
|
|
101
|
+
const classes = new Set();
|
|
102
|
+
for (const f of graph.flows ?? []) {
|
|
103
|
+
if (!(f.edgeIds ?? []).some((id) => affectedEdgeIds.has(id))) continue;
|
|
104
|
+
for (const deId of f.dataElementIds ?? []) {
|
|
105
|
+
const de = (graph.dataElements ?? []).find((d) => d.id === deId);
|
|
106
|
+
for (const c of de?.dataClasses ?? []) classes.add(c);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return [...classes].sort();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function _affectedRecipientProfileIds(graph, affectedNodeIds) {
|
|
113
|
+
return (graph.recipientProfiles ?? [])
|
|
114
|
+
.filter((rp) => (rp.contributingGraphIds ?? []).some((id) => affectedNodeIds.has(id)))
|
|
115
|
+
.map((rp) => rp.id)
|
|
116
|
+
.sort();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Whole-graph, not scoped to the affected subgraph — no node carries a
|
|
120
|
+
// language field to filter by, so a per-language coverage gap is
|
|
121
|
+
// reported as a real, honest limitation on any assessment computed
|
|
122
|
+
// over this graph, not narrowed to the exact affected nodes. See this
|
|
123
|
+
// sub-project's own implementation plan for the full disclosed
|
|
124
|
+
// reasoning. Complemented, not replaced, by _affectedCoverageLimitations
|
|
125
|
+
// below — that function reports the real per-entity coverageStatus gaps
|
|
126
|
+
// among the entities THIS assessment actually affects, which this
|
|
127
|
+
// whole-graph language disclosure cannot express.
|
|
128
|
+
function _coverageLimitations(graph) {
|
|
129
|
+
return (graph.coverage?.languages ?? [])
|
|
130
|
+
.filter((l) => l.tier && l.tier !== 'full')
|
|
131
|
+
.map((l) => `${l.language}: coverage tier '${l.tier}'${typeof l.irTaintRecallPct === 'number' ? ` (${l.irTaintRecallPct}% measured recall)` : ''}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Per-entity coverage gap among the entities THIS assessment actually
|
|
135
|
+
// affects, using the real, required `coverageStatus` field every
|
|
136
|
+
// node/edge/flow already carries (`modeled|partial|candidate|
|
|
137
|
+
// unsupported|manual` — populated by graph-builder.js, already
|
|
138
|
+
// consumed by export-csv.js/graph-diff.js). Complements (never
|
|
139
|
+
// replaces) _coverageLimitations' whole-graph language disclosure
|
|
140
|
+
// above — both are real, complementary limitations of the same
|
|
141
|
+
// assessment.
|
|
142
|
+
function _affectedCoverageLimitations(graph, affectedNodeIds, affectedEdgeIds) {
|
|
143
|
+
const limitations = [];
|
|
144
|
+
const nonModeledNodes = (graph.nodes ?? []).filter((n) => affectedNodeIds.has(n.id) && n.coverageStatus && n.coverageStatus !== 'modeled');
|
|
145
|
+
const nonModeledEdges = (graph.edges ?? []).filter((e) => affectedEdgeIds.has(e.id) && e.coverageStatus && e.coverageStatus !== 'modeled');
|
|
146
|
+
if (nonModeledNodes.length) {
|
|
147
|
+
limitations.push(`${nonModeledNodes.length} of ${affectedNodeIds.size} affected node(s) have less-than-modeled coverage (e.g. ${nonModeledNodes[0].id}: coverage tier '${nonModeledNodes[0].coverageStatus}')`);
|
|
148
|
+
}
|
|
149
|
+
if (nonModeledEdges.length) {
|
|
150
|
+
limitations.push(`${nonModeledEdges.length} of ${affectedEdgeIds.size} affected edge(s) have less-than-modeled coverage (e.g. ${nonModeledEdges[0].id}: coverage tier '${nonModeledEdges[0].coverageStatus}')`);
|
|
151
|
+
}
|
|
152
|
+
return limitations;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Compute an ImpactAssessment for `targetId` over `graph`. Throws when
|
|
157
|
+
* `graph` is structurally malformed (missing `nodes`/`edges` arrays —
|
|
158
|
+
* see `loadSignedGraph`'s own "signature-only, no schema validation"
|
|
159
|
+
* contract for why a signed-but-malformed graph can reach here) with a
|
|
160
|
+
* message prefixed `computeImpactAssessment: malformed graph — `, so a
|
|
161
|
+
* caller can distinguish that from the other thrown case: `targetId`
|
|
162
|
+
* has no recognized canonical-id prefix (a genuine caller error, not a
|
|
163
|
+
* missing-entity case). A well-formed targetId that does not exist in
|
|
164
|
+
* the graph degrades honestly to empty affected-* arrays, never an
|
|
165
|
+
* error — mirrors applyScenario's own skip-not-throw contract for a
|
|
166
|
+
* stale/missing target.
|
|
167
|
+
*
|
|
168
|
+
* `node` targets report a deliberately pessimistic "everything
|
|
169
|
+
* topologically reachable" blast radius (`traceKind:
|
|
170
|
+
* 'topology_reachable'`) — compromising a node genuinely puts
|
|
171
|
+
* everything it can reach in the blast radius. `edge`/`flow`/
|
|
172
|
+
* `dataElement` targets report only the flows/nodes/edges that
|
|
173
|
+
* actually carry that specific edge/flow/data element (`traceKind:
|
|
174
|
+
* 'flow_restricted'`) — the record still names that edge/flow's own
|
|
175
|
+
* two endpoint nodes (you need them to know what the channel actually
|
|
176
|
+
* touches), it just never treats either endpoint as itself fully
|
|
177
|
+
* compromised for finding everything ELSE reachable from it via
|
|
178
|
+
* unrelated edges. The two target-kind families answer genuinely
|
|
179
|
+
* different questions, both honestly disclosed via `traceKind` rather
|
|
180
|
+
* than silently conflated under one `scope` value.
|
|
181
|
+
*/
|
|
182
|
+
export function computeImpactAssessment(graph, targetId, opts = {}) {
|
|
183
|
+
if (!Array.isArray(graph?.nodes) || !Array.isArray(graph?.edges)) {
|
|
184
|
+
throw new Error('computeImpactAssessment: malformed graph — graph.nodes and graph.edges must both be arrays');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const targetKind = _resolveTargetKind(targetId);
|
|
188
|
+
if (!targetKind) {
|
|
189
|
+
throw new Error(`computeImpactAssessment: targetId "${targetId}" has no recognized prefix (expected one of node:/edge:/flow:/data:)`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// showAllPaths (frontend/src/lib/focus-controls.js) unconditionally
|
|
193
|
+
// seeds its own result with the start id itself, even when that id
|
|
194
|
+
// does not exist in the graph at all (bfsDirection's `nodeIds = new
|
|
195
|
+
// Set([startId])`) — so a seed must be filtered against the graph's
|
|
196
|
+
// real node ids first, or a well-formed-but-nonexistent targetId
|
|
197
|
+
// would surface as a phantom single-node "affected" set instead of
|
|
198
|
+
// degrading honestly to empty arrays. The same filter is applied to
|
|
199
|
+
// _flowRestrictedAffectedSet's own node ids below, for the identical
|
|
200
|
+
// reason.
|
|
201
|
+
const realNodeIds = new Set((graph.nodes ?? []).map((n) => n.id));
|
|
202
|
+
const affectedNodeIds = new Set();
|
|
203
|
+
const affectedEdgeIds = new Set();
|
|
204
|
+
let affectedDataClasses;
|
|
205
|
+
|
|
206
|
+
if (targetKind === 'node') {
|
|
207
|
+
const seedNodeIds = _seedNodeIds(graph, targetId, targetKind).filter((id) => realNodeIds.has(id));
|
|
208
|
+
for (const seedId of seedNodeIds) {
|
|
209
|
+
const { nodeIds, edgeIds } = showAllPaths(graph, seedId);
|
|
210
|
+
for (const id of nodeIds) affectedNodeIds.add(id);
|
|
211
|
+
for (const id of edgeIds) affectedEdgeIds.add(id);
|
|
212
|
+
}
|
|
213
|
+
affectedDataClasses = _affectedDataClasses(graph, affectedEdgeIds);
|
|
214
|
+
} else {
|
|
215
|
+
// dataElement/edge/flow — direct flow-based trace via
|
|
216
|
+
// _flowRestrictedAffectedSet, never showAllPaths. See that
|
|
217
|
+
// function's own header comment for why.
|
|
218
|
+
const matchesFlow = targetKind === 'dataElement'
|
|
219
|
+
? (f) => f.dataElementIds?.includes(targetId)
|
|
220
|
+
: targetKind === 'edge'
|
|
221
|
+
? (f) => f.edgeIds?.includes(targetId)
|
|
222
|
+
: (f) => f.id === targetId; // flow
|
|
223
|
+
const { nodeIds, edgeIds, dataClasses } = _flowRestrictedAffectedSet(graph, matchesFlow);
|
|
224
|
+
for (const id of nodeIds) if (realNodeIds.has(id)) affectedNodeIds.add(id);
|
|
225
|
+
for (const id of edgeIds) affectedEdgeIds.add(id);
|
|
226
|
+
// N3 (final-review re-review): an edge: target that exists in
|
|
227
|
+
// graph.edges but appears in NO flow's own edgeIds (real for the
|
|
228
|
+
// real graph — an edge with zero flows using it is a legitimate
|
|
229
|
+
// graph shape, e.g. a dead-end connection no data currently
|
|
230
|
+
// traverses) must never come back fully empty — "this edge is
|
|
231
|
+
// compromised" means, at minimum, that edge and its own two
|
|
232
|
+
// endpoints are affected, flow or no flow. A nonexistent edge id
|
|
233
|
+
// still correctly degrades to fully empty (realNodeIds/the edge
|
|
234
|
+
// lookup below both filter it out).
|
|
235
|
+
if (targetKind === 'edge') {
|
|
236
|
+
const edge = (graph.edges ?? []).find((e) => e.id === targetId);
|
|
237
|
+
if (edge) {
|
|
238
|
+
affectedEdgeIds.add(edge.id);
|
|
239
|
+
if (realNodeIds.has(edge.from)) affectedNodeIds.add(edge.from);
|
|
240
|
+
if (realNodeIds.has(edge.to)) affectedNodeIds.add(edge.to);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
affectedDataClasses = [...dataClasses].sort();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const graphDigest = computeGraphDigest(graph);
|
|
247
|
+
const generatedAt = opts.generatedAt ?? new Date().toISOString();
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
id: impactAssessmentId({ graphId: graph.graphId, graphDigest, targetId }, [generatedAt]),
|
|
251
|
+
version: IMPACT_VERSION,
|
|
252
|
+
graphId: graph.graphId,
|
|
253
|
+
graphDigest,
|
|
254
|
+
targetId,
|
|
255
|
+
targetKind,
|
|
256
|
+
scope: 'possible',
|
|
257
|
+
traceKind: targetKind === 'node' ? 'topology_reachable' : 'flow_restricted',
|
|
258
|
+
affectedNodeIds: [...affectedNodeIds].sort(),
|
|
259
|
+
affectedEdgeIds: [...affectedEdgeIds].sort(),
|
|
260
|
+
affectedDataClasses,
|
|
261
|
+
affectedRecipientProfileIds: _affectedRecipientProfileIds(graph, affectedNodeIds),
|
|
262
|
+
coverageLimitations: [
|
|
263
|
+
..._coverageLimitations(graph),
|
|
264
|
+
..._affectedCoverageLimitations(graph, affectedNodeIds, affectedEdgeIds),
|
|
265
|
+
],
|
|
266
|
+
generatedAt,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
//
|
|
2
|
+
// index.js — Sub-project E, increment 5 (E5). The scan-facing entry point
|
|
3
|
+
// for the Data Flow Explorer lineage engine. This is the ONLY file under
|
|
4
|
+
// src/lineage/ that engine.js/bin/agentic-security.js import — every other
|
|
5
|
+
// module in this package stays isolated per its own established reuse
|
|
6
|
+
// boundary (see src/lineage/CLAUDE.md's header).
|
|
7
|
+
//
|
|
8
|
+
// Mirrors runFullScan's own `_deepEnabled` block's CONTRACT (opt-in,
|
|
9
|
+
// best-effort, every outcome returned as a structured status a caller folds
|
|
10
|
+
// into scanHealth) — NOT `dataflow/index.js`'s `AGENTIC_SECURITY_PRIVACY_DEEP`
|
|
11
|
+
// block, whose bare `catch {}` silently swallows failure with no scanHealth
|
|
12
|
+
// signal at all (measured and disclosed in
|
|
13
|
+
// docs/superpowers/plans/2026-08-31-data-flow-explorer-m1-subproject-e5-scoping.md
|
|
14
|
+
// §1 — DESIGN_GRAPH_BUILDER.md §9.5 item 1's own wording describes the
|
|
15
|
+
// LATTER mechanism, not the former, despite naming the former by name).
|
|
16
|
+
//
|
|
17
|
+
// Unlike privacy-taint, lineage analysis has NO degraded/non-IR-backed mode:
|
|
18
|
+
// `buildGraphWithCoverage(callGraph, opts)` requires a real callGraph with
|
|
19
|
+
// real CFGs, and there is nothing meaningful to fall back to. A missing or
|
|
20
|
+
// malformed callGraph is reported as `not_available`, never attempted as a
|
|
21
|
+
// degraded run.
|
|
22
|
+
|
|
23
|
+
import * as fs from 'node:fs';
|
|
24
|
+
import { buildGraphWithCoverage } from './coverage.js';
|
|
25
|
+
import { scanTransitEvidence } from './transit-protection.js';
|
|
26
|
+
// Milestone 2, Sub-project G, increment 1 (FR-408/AC-09): loaded ONCE, here
|
|
27
|
+
// — mirroring `scanTransitEvidence`'s own single-computation discipline one
|
|
28
|
+
// line above — never re-loaded at a lower layer (`coverage.js`/
|
|
29
|
+
// `graph-builder.js` both only ever consume the already-loaded object).
|
|
30
|
+
import { loadPrivacySinkPolicy } from '../dataflow/privacy-sink-policy.js';
|
|
31
|
+
// Deliverable #10 (DFG-020, graph-derived DPIA/RoPA migration): loaded
|
|
32
|
+
// ONCE, here — mirroring `loadPrivacySinkPolicy`'s own single-computation
|
|
33
|
+
// discipline one block below. Unlike that policy load, no existence-gating
|
|
34
|
+
// is needed: `loadPrivacyGovernanceConfig` already has its own honest empty
|
|
35
|
+
// default ({byClass: {}, default: {}} — never throws), and
|
|
36
|
+
// `governanceRecordFor` already resolves an empty config to MANUAL_REQUIRED
|
|
37
|
+
// for every field, which is the correct, honest answer when no
|
|
38
|
+
// .agentic-security/privacy-governance.json exists on disk.
|
|
39
|
+
import { loadPrivacyGovernanceConfig } from '../dataflow/privacy-governance.js';
|
|
40
|
+
import { statePath } from '../posture/state-dir.js';
|
|
41
|
+
// Milestone 4, FR-506 (Third-Party and Cross-Border Intelligence): loaded
|
|
42
|
+
// ONCE, here — mirroring `loadPrivacySinkPolicy`'s own single-computation
|
|
43
|
+
// discipline above. `loadRecipientConfig` already has the SAME "never
|
|
44
|
+
// throws, missing file degrades to {recipients: {}}" contract
|
|
45
|
+
// `loadPrivacyGovernanceConfig` has, so — like that config, unlike
|
|
46
|
+
// `privacySinkPolicy` — no existence-gating is needed on the CALL itself;
|
|
47
|
+
// only the PATH resolution mirrors `privacySinkPolicy`'s own precedent,
|
|
48
|
+
// since `loadRecipientConfig` (unlike `loadPrivacyGovernanceConfig`) takes
|
|
49
|
+
// a literal file path, not a scanRoot.
|
|
50
|
+
import { loadRecipientConfig, RECIPIENT_CONFIG_FILENAME } from './recipient-registry.js';
|
|
51
|
+
// M5 deliverable #7 (FR-505/AC-29, Runtime-Corroborated Digital Twin, "7b"):
|
|
52
|
+
// loaded ONCE, here — mirroring `loadPrivacySinkPolicy`'s own
|
|
53
|
+
// single-computation discipline above, including the SAME explicit
|
|
54
|
+
// `fs.existsSync` gate and the SAME reason: `loadObservations` returns the
|
|
55
|
+
// identical empty array whether the store directory is missing or
|
|
56
|
+
// present-and-empty, and those are two DIFFERENT answers under AC-29
|
|
57
|
+
// clause 2 — see the function body below for the full reasoning.
|
|
58
|
+
import { loadObservations } from './observation-store.js';
|
|
59
|
+
// M5 deliverable #8 (FR-304 "declared" half): loaded ONCE, here —
|
|
60
|
+
// mirroring `privacySinkPolicy`'s own existence-gated, single-computation
|
|
61
|
+
// discipline below (never `recipientConfig`'s unconditional-call one — a
|
|
62
|
+
// missing cross-repo-links.json here means "no links declared", a real,
|
|
63
|
+
// distinguishable-from-empty state worth keeping honest the same way
|
|
64
|
+
// `privacy-policy.json`'s absence is, per this deliverable's own scoping
|
|
65
|
+
// doc). `validateCrossRepoLink` is imported directly (not a separate
|
|
66
|
+
// loader module) — see `_loadCrossRepoLinkRecords` below for why this
|
|
67
|
+
// small, local, tolerant reader lives here rather than in
|
|
68
|
+
// `cross-repo-link.js` (which must stay a PURE, zero-fs-access module,
|
|
69
|
+
// mirroring `scenario.js`'s own boundary) or `federation-loader.js`
|
|
70
|
+
// (which owns only the REMOTE side).
|
|
71
|
+
import { validateCrossRepoLink, CROSS_REPO_LINKS_FILENAME } from './cross-repo-link.js';
|
|
72
|
+
|
|
73
|
+
// A small, LOCAL, tolerant loader for the operator-declared
|
|
74
|
+
// cross-repo-links.json config file — mirrors `loadRecipientConfig`'s own
|
|
75
|
+
// fail-closed, skip-the-whole-entry-on-any-defect discipline
|
|
76
|
+
// (recipient-registry.js), but kept local to this file rather than
|
|
77
|
+
// exported from `cross-repo-link.js`/`federation-loader.js` (see the
|
|
78
|
+
// import comment above for the full reasoning). Never throws; a missing
|
|
79
|
+
// file is never reached here at all (the caller already gated on
|
|
80
|
+
// `fs.existsSync`); a malformed file or a malformed individual link
|
|
81
|
+
// degrades to an empty/partial array with a console warning naming the
|
|
82
|
+
// count skipped, mirroring `loadRecipientConfig`'s own per-entry
|
|
83
|
+
// discipline.
|
|
84
|
+
function _loadCrossRepoLinkRecords(filePath) {
|
|
85
|
+
let raw;
|
|
86
|
+
try {
|
|
87
|
+
raw = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
88
|
+
} catch (e) {
|
|
89
|
+
console.error(`agentic-security: bad JSON in cross-repo links file (${filePath}) — falling back to no declared links (${e.message})`);
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw) || !Array.isArray(raw.links)) {
|
|
93
|
+
console.error(`agentic-security: cross-repo links file ${filePath} has no "links" array — falling back to no declared links (expected {"links": [...]})`);
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
const records = [];
|
|
97
|
+
let skipped = 0;
|
|
98
|
+
for (const record of raw.links) {
|
|
99
|
+
const { valid } = validateCrossRepoLink(record);
|
|
100
|
+
if (!valid) { skipped += 1; continue; }
|
|
101
|
+
records.push(record);
|
|
102
|
+
}
|
|
103
|
+
if (skipped > 0) {
|
|
104
|
+
console.error(`agentic-security: skipped ${skipped} malformed cross-repo-link entr${skipped === 1 ? 'y' : 'ies'} in ${filePath} (each must be a valid CrossRepoLink-shaped object)`);
|
|
105
|
+
}
|
|
106
|
+
return records;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* @param {{functions: Map}} callGraph a real callGraph — the same shape
|
|
111
|
+
* `buildProjectIR`/`buildProjectIRAsync` produce (`_sharedIR.callGraph`
|
|
112
|
+
* in `runFullScan`).
|
|
113
|
+
* @param {object} [opts]
|
|
114
|
+
* @param {string} [opts.repository] threaded straight to `buildGraphWithCoverage`.
|
|
115
|
+
* @param {string} [opts.scanRoot] the real scan root path — distinct from
|
|
116
|
+
* `opts.repository`, which by the time it reaches this function is only a
|
|
117
|
+
* basename (see `engine.js`'s own call site). Used ONLY to load the
|
|
118
|
+
* operator's privacy sink policy (Milestone 2, Sub-project G, increment
|
|
119
|
+
* 1, FR-408/AC-09) — never threaded to `buildGraphWithCoverage` itself,
|
|
120
|
+
* which never reads the filesystem. See the function body for why
|
|
121
|
+
* existence is checked explicitly rather than inferred from
|
|
122
|
+
* `loadPrivacySinkPolicy`'s own return value.
|
|
123
|
+
* @param {string} [opts.environment] optional deployment-environment
|
|
124
|
+
* override for policy evaluation's environment-scoped rules; threaded to
|
|
125
|
+
* `buildGraphWithCoverage`'s `opts.environment`, which falls back to
|
|
126
|
+
* `AGENTIC_SECURITY_ENVIRONMENT` at the point the verdict is computed
|
|
127
|
+
* (`graph-builder.js`), mirroring `dataflow/privacy-taint.js`'s own
|
|
128
|
+
* precedent.
|
|
129
|
+
* @param {boolean} [opts.deterministic] when true, `generatedAt` is left
|
|
130
|
+
* `undefined` so `buildDataFlowGraph`'s own fixed-literal default applies
|
|
131
|
+
* — the literal itself lives in exactly one place, `graph-builder.js`.
|
|
132
|
+
* @param {Record<string,object>} [opts.perFile] threaded to the coverage
|
|
133
|
+
* ledger's `languages[]` computation.
|
|
134
|
+
* @param {Array<object>} [opts.parseFailures] threaded to the coverage
|
|
135
|
+
* ledger's `parseFailures`/`languages[].filesExpected` computation.
|
|
136
|
+
* @param {Record<string,string>} [opts.fileContents] `{path: rawSourceString}`
|
|
137
|
+
* — threaded to `transit-protection.js`'s `scanTransitEvidence` (Milestone 2,
|
|
138
|
+
* Sub-project B, increment 1). As of increment 2, `scanTransitEvidence` is
|
|
139
|
+
* called EXACTLY ONCE, here, per `buildLineageGraph` call — its result
|
|
140
|
+
* (a `Map<file, findings[]>`) feeds BOTH the returned `transitEvidence`
|
|
141
|
+
* field below AND `buildGraphWithCoverage`'s own `opts.transitEvidenceByFile`
|
|
142
|
+
* (the same `Map` reference, never recomputed), which is what actually
|
|
143
|
+
* drives `edge.protection.transit` verdicts inside `graph-builder.js`. See
|
|
144
|
+
* `DESIGN_TRANSIT_PROTECTION.md` §6 for why this single-computation
|
|
145
|
+
* discipline is load-bearing (a second call inside `coverage.js`'s own
|
|
146
|
+
* default hook would double-scan every file). `graph` is NOT
|
|
147
|
+
* byte-identical to omitting `opts.fileContents` anymore — a network
|
|
148
|
+
* sink's `edge.protection.transit` can now genuinely change with the
|
|
149
|
+
* evidence supplied.
|
|
150
|
+
* Milestone 4, FR-506: the operator's recipient config
|
|
151
|
+
* (`.agentic-security/recipient-profiles.json`, resolved against
|
|
152
|
+
* `opts.scanRoot`) is loaded exactly once here and threaded to
|
|
153
|
+
* `buildGraphWithCoverage`'s `opts.recipientConfig`, which drives the
|
|
154
|
+
* default `opts.buildRecipientProfile` hook — no separate `opts` field
|
|
155
|
+
* needed, unlike `privacySinkPolicy`, since `loadRecipientConfig` already
|
|
156
|
+
* degrades a missing/malformed file gracefully on its own.
|
|
157
|
+
* M5 deliverable #7 (FR-505/AC-29): the operator's runtime-observation
|
|
158
|
+
* store (`.agentic-security/runtime-observations/`, resolved against
|
|
159
|
+
* `opts.scanRoot`) is loaded exactly once here, existence-gated exactly
|
|
160
|
+
* like `privacySinkPolicy` above, and threaded to `buildGraphWithCoverage`'s
|
|
161
|
+
* `opts.runtimeObservations`.
|
|
162
|
+
* @param {string} [opts.observationWindowStart] optional ISO-8601 lower
|
|
163
|
+
* bound for runtime-observation correlation, threaded to
|
|
164
|
+
* `buildGraphWithCoverage`'s `opts.observationWindowStart`.
|
|
165
|
+
* @param {string} [opts.observationWindowEnd] optional ISO-8601 upper
|
|
166
|
+
* bound for runtime-observation correlation, threaded to
|
|
167
|
+
* `buildGraphWithCoverage`'s `opts.observationWindowEnd`.
|
|
168
|
+
* @returns {{status: 'not_available'|'complete'|'failed', graph: object|null, transitEvidence: Map<string,object[]>, failure: string|null, elapsedMs: number}}
|
|
169
|
+
* `status` is never `'not_requested'` — that decision belongs to the
|
|
170
|
+
* CALLER (whether to call this function at all), not to this function's
|
|
171
|
+
* own return value. `transitEvidence` is a `Map<file, findings[]>` — see
|
|
172
|
+
* `DESIGN_TRANSIT_PROTECTION.md` §3 for why a `Map`, not a plain object.
|
|
173
|
+
* It is a real, populated result of running `scanCryptoProtocol` over
|
|
174
|
+
* `opts.fileContents` (empty when omitted). As of increment 2, this same
|
|
175
|
+
* `Map` IS also joined to specific graph edges — via
|
|
176
|
+
* `buildGraphWithCoverage`'s `opts.transitEvidenceByFile`, consulted by
|
|
177
|
+
* `resolveTransitProtectionForSite` for `external-api` sink sites — but
|
|
178
|
+
* this returned field itself stays the raw, ungrouped `Map<file,
|
|
179
|
+
* findings[]>`, not a per-edge join result.
|
|
180
|
+
*/
|
|
181
|
+
export function buildLineageGraph(callGraph, opts = {}) {
|
|
182
|
+
const t0 = Date.now();
|
|
183
|
+
if (!callGraph || typeof callGraph.functions?.values !== 'function') {
|
|
184
|
+
return { status: 'not_available', graph: null, transitEvidence: new Map(), failure: null, elapsedMs: Date.now() - t0 };
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
// Milestone 2, Sub-project B, increment 2 (DESIGN_TRANSIT_PROTECTION.md
|
|
188
|
+
// §6, item 4): `scanTransitEvidence` runs EXACTLY ONCE per
|
|
189
|
+
// `buildLineageGraph` call, here — the same `Map` reference feeds both
|
|
190
|
+
// this function's own `transitEvidence` return field AND
|
|
191
|
+
// `buildGraphWithCoverage`'s `opts.transitEvidenceByFile`, which is what
|
|
192
|
+
// `coverage.js`'s default `resolveTransitProtection` hook actually
|
|
193
|
+
// consults. `coverage.js` never calls `scanTransitEvidence`/
|
|
194
|
+
// `scanCryptoProtocol` itself — it only reads this pre-computed Map —
|
|
195
|
+
// so no file is ever scanned twice.
|
|
196
|
+
const transitEvidence = scanTransitEvidence(opts.fileContents ?? {});
|
|
197
|
+
// Milestone 2, Sub-project G, increment 1 (FR-408/AC-09): load the
|
|
198
|
+
// operator's privacy sink policy exactly once, here. Existence is
|
|
199
|
+
// checked EXPLICITLY (never inferred from `loadPrivacySinkPolicy`'s own
|
|
200
|
+
// return value alone) because that function deliberately returns the
|
|
201
|
+
// SAME empty `{allow: []}` shape whether the policy file is missing,
|
|
202
|
+
// malformed, or genuinely present with an empty `allow` array —
|
|
203
|
+
// collapsing three states `graph-builder.js`'s own policy-verdict logic
|
|
204
|
+
// needs to keep apart. A MISSING policy must read
|
|
205
|
+
// `flow.policyVerdict: 'not_evaluated'` (nothing was actually
|
|
206
|
+
// evaluated — privacy-sink-policy.js's own header: "nothing changes for
|
|
207
|
+
// a repo with no policy file"); a PRESENT-but-empty policy (an
|
|
208
|
+
// operator's deliberate "nothing is permitted yet" `{"allow": []}`)
|
|
209
|
+
// must read `'prohibited'` — the deny-by-default stance that same
|
|
210
|
+
// header establishes once a policy is genuinely in play.
|
|
211
|
+
// `privacySinkPolicy` therefore stays `undefined` (never coerced to
|
|
212
|
+
// `{allow: []}`) unless the file genuinely exists on disk — this is
|
|
213
|
+
// what lets `graph-builder.js`'s `opts.privacySinkPolicy != null` gate
|
|
214
|
+
// make that distinction at all.
|
|
215
|
+
const _policyFile = opts.scanRoot ? statePath(opts.scanRoot, 'privacy-policy.json') : null;
|
|
216
|
+
const privacySinkPolicy = _policyFile && fs.existsSync(_policyFile)
|
|
217
|
+
? loadPrivacySinkPolicy(opts.scanRoot)
|
|
218
|
+
: undefined;
|
|
219
|
+
// Deliverable #10 (DFG-020): the operator's privacy governance config,
|
|
220
|
+
// loaded exactly once, here — mirroring privacySinkPolicy's own
|
|
221
|
+
// single-load precedent immediately above it. See this file's own
|
|
222
|
+
// import comment for why no existence-gating is needed here, unlike
|
|
223
|
+
// privacySinkPolicy.
|
|
224
|
+
const privacyGovernanceConfig = loadPrivacyGovernanceConfig(opts.scanRoot);
|
|
225
|
+
// Milestone 4, FR-506: the operator's recipient config, loaded exactly
|
|
226
|
+
// once, here — mirroring `privacySinkPolicy`'s own path-resolution step
|
|
227
|
+
// (gated on `opts.scanRoot`, since `loadRecipientConfig` takes a literal
|
|
228
|
+
// file path rather than a scanRoot) but, per this file's own import
|
|
229
|
+
// comment above, calling the loader UNCONDITIONALLY once the path is
|
|
230
|
+
// resolved — `loadRecipientConfig` already degrades a missing/malformed
|
|
231
|
+
// file to `{recipients: {}}` on its own, the same honest-empty-default
|
|
232
|
+
// contract `loadPrivacyGovernanceConfig` has.
|
|
233
|
+
const recipientConfigPath = opts.scanRoot ? statePath(opts.scanRoot, RECIPIENT_CONFIG_FILENAME) : null;
|
|
234
|
+
const recipientConfig = loadRecipientConfig(recipientConfigPath);
|
|
235
|
+
// M5 deliverable #7 (FR-505/AC-29): the operator's runtime-observation
|
|
236
|
+
// store, loaded exactly ONCE here — the same single-computation discipline
|
|
237
|
+
// scanTransitEvidence and loadPrivacySinkPolicy already follow. Existence
|
|
238
|
+
// is checked EXPLICITLY, exactly like privacySinkPolicy and for the
|
|
239
|
+
// identical reason: `loadObservations` returns the same empty array whether
|
|
240
|
+
// the store directory is missing or present-and-empty, and those are two
|
|
241
|
+
// DIFFERENT answers under AC-29 clause 2. A MISSING store must leave
|
|
242
|
+
// `graph.runtimeCorroboration` absent (`not_evaluated` — nothing was
|
|
243
|
+
// consulted); a PRESENT-but-empty store must produce a real correlation
|
|
244
|
+
// result whose every flow reads `not_observed_in_window` (a store WAS
|
|
245
|
+
// consulted and the window genuinely contained nothing). PRD line 2098:
|
|
246
|
+
// absence of observation is never non-occurrence.
|
|
247
|
+
const _observationsDir = opts.scanRoot ? statePath(opts.scanRoot, 'runtime-observations') : null;
|
|
248
|
+
const runtimeObservations = _observationsDir && fs.existsSync(_observationsDir)
|
|
249
|
+
? loadObservations(opts.scanRoot)
|
|
250
|
+
: undefined;
|
|
251
|
+
// M5 deliverable #8 (FR-304 "declared" half): the operator's declared
|
|
252
|
+
// cross-repo links, loaded exactly once here — the same
|
|
253
|
+
// single-computation discipline every other config load in this
|
|
254
|
+
// function follows. Existence is checked EXPLICITLY, exactly like
|
|
255
|
+
// `privacySinkPolicy` above.
|
|
256
|
+
const _crossRepoLinksFile = opts.scanRoot ? statePath(opts.scanRoot, CROSS_REPO_LINKS_FILENAME) : null;
|
|
257
|
+
const crossRepoLinkRecords = _crossRepoLinksFile && fs.existsSync(_crossRepoLinksFile)
|
|
258
|
+
? _loadCrossRepoLinkRecords(_crossRepoLinksFile)
|
|
259
|
+
: undefined;
|
|
260
|
+
const built = buildGraphWithCoverage(callGraph, {
|
|
261
|
+
repository: opts.repository,
|
|
262
|
+
generatedAt: opts.deterministic ? undefined : new Date().toISOString(),
|
|
263
|
+
perFile: opts.perFile,
|
|
264
|
+
parseFailures: opts.parseFailures,
|
|
265
|
+
transitEvidenceByFile: transitEvidence,
|
|
266
|
+
privacySinkPolicy,
|
|
267
|
+
privacyGovernanceConfig,
|
|
268
|
+
environment: opts.environment,
|
|
269
|
+
recipientConfig,
|
|
270
|
+
runtimeObservations,
|
|
271
|
+
crossRepoLinkRecords,
|
|
272
|
+
observationWindowStart: opts.observationWindowStart,
|
|
273
|
+
observationWindowEnd: opts.observationWindowEnd,
|
|
274
|
+
});
|
|
275
|
+
return { status: 'complete', graph: built.graph, transitEvidence, failure: null, elapsedMs: Date.now() - t0 };
|
|
276
|
+
} catch (e) {
|
|
277
|
+
// Best-effort (DESIGN_GRAPH_BUILDER.md §9.5 item 1): recorded, never
|
|
278
|
+
// swallowed. The caller (runFullScan) folds `failure` into scanHealth.
|
|
279
|
+
return { status: 'failed', graph: null, transitEvidence: new Map(), failure: String((e && e.message) || e), elapsedMs: Date.now() - t0 };
|
|
280
|
+
}
|
|
281
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// language-coverage-tiers.js — Milestone 5, language coverage-tier
|
|
2
|
+
// disclosure. A small, curated, static table answering "how much can this
|
|
3
|
+
// codebase's Data Flow Explorer actually see for language X today" —
|
|
4
|
+
// PRD §22.1's own explicitly-sanctioned alternative to claiming a language
|
|
5
|
+
// is fully "supported" before it clears §22.3's 85% field-to-sink-recall
|
|
6
|
+
// bar (as of docs/METRICS.md, measured 2026-08-19, NONE of the 9
|
|
7
|
+
// lineage-wired languages do — best is python at 66%).
|
|
8
|
+
//
|
|
9
|
+
// Every number here is copied VERBATIM from docs/METRICS.md's own
|
|
10
|
+
// currently-committed table (bench/layer-recall's IR-TAINT column — the
|
|
11
|
+
// closest existing proxy for the PRD's own field-to-sink recall definition,
|
|
12
|
+
// not a byte-identical measurement of it). This module does not re-measure
|
|
13
|
+
// anything; re-run bench/layer-recall and update BOTH docs/METRICS.md and
|
|
14
|
+
// this table together if the numbers ever change, or this file goes stale
|
|
15
|
+
// silently. Zero imports — a pure, static data module, mirroring
|
|
16
|
+
// flow-grade.js's own "zero imports" precedent for a self-contained
|
|
17
|
+
// vocabulary/data table.
|
|
18
|
+
//
|
|
19
|
+
// The 9 keys below are languageForFile's own normalized vocabulary
|
|
20
|
+
// (coverage.js) — js/python/java/csharp/kotlin/go/php/ruby/cpp — the exact
|
|
21
|
+
// languages with real IR-to-lineage wiring today. The 4 pattern-only keys
|
|
22
|
+
// (rust/solidity/swift/dart) have ZERO lineage/taint wiring: they exist
|
|
23
|
+
// only as tree-sitter grammar loads feeding sast/tree-sitter-sinks.js's
|
|
24
|
+
// pattern matching, never scanner/src/lineage/ or scanner/src/dataflow/ —
|
|
25
|
+
// confirmed by the M5 top-level scoping doc's own investigation. No
|
|
26
|
+
// lineage engine ever runs against them, so they carry no recall number at
|
|
27
|
+
// all (irTaintRecallPct: null) — never a fabricated 0%, which would read
|
|
28
|
+
// as "measured and found to be zero" rather than "never measured, because
|
|
29
|
+
// nothing here can produce a lineage finding for this language yet."
|
|
30
|
+
|
|
31
|
+
export const LANGUAGE_COVERAGE_TIERS = Object.freeze([
|
|
32
|
+
Object.freeze({ language: 'python', tier: 'partial', irTaintRecallPct: 66, measuredAt: '2026-08-19', source: 'docs/METRICS.md' }),
|
|
33
|
+
Object.freeze({ language: 'go', tier: 'partial', irTaintRecallPct: 59, measuredAt: '2026-08-19', source: 'docs/METRICS.md' }),
|
|
34
|
+
Object.freeze({ language: 'js', tier: 'partial', irTaintRecallPct: 58, measuredAt: '2026-08-19', source: 'docs/METRICS.md' }),
|
|
35
|
+
Object.freeze({ language: 'csharp', tier: 'partial', irTaintRecallPct: 57, measuredAt: '2026-08-19', source: 'docs/METRICS.md' }),
|
|
36
|
+
Object.freeze({ language: 'ruby', tier: 'partial', irTaintRecallPct: 55, measuredAt: '2026-08-19', source: 'docs/METRICS.md' }),
|
|
37
|
+
Object.freeze({ language: 'java', tier: 'partial', irTaintRecallPct: 52, measuredAt: '2026-08-19', source: 'docs/METRICS.md' }),
|
|
38
|
+
Object.freeze({ language: 'php', tier: 'partial', irTaintRecallPct: 52, measuredAt: '2026-08-19', source: 'docs/METRICS.md' }),
|
|
39
|
+
Object.freeze({ language: 'kotlin', tier: 'partial', irTaintRecallPct: 48, measuredAt: '2026-08-19', source: 'docs/METRICS.md' }),
|
|
40
|
+
Object.freeze({ language: 'cpp', tier: 'partial', irTaintRecallPct: 18, measuredAt: '2026-08-19', source: 'docs/METRICS.md' }),
|
|
41
|
+
Object.freeze({ language: 'rust', tier: 'pattern-only', irTaintRecallPct: null, measuredAt: null, source: null }),
|
|
42
|
+
Object.freeze({ language: 'solidity', tier: 'pattern-only', irTaintRecallPct: null, measuredAt: null, source: null }),
|
|
43
|
+
Object.freeze({ language: 'swift', tier: 'pattern-only', irTaintRecallPct: null, measuredAt: null, source: null }),
|
|
44
|
+
Object.freeze({ language: 'dart', tier: 'pattern-only', irTaintRecallPct: null, measuredAt: null, source: null }),
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
const _byLanguage = new Map(LANGUAGE_COVERAGE_TIERS.map((e) => [e.language, e]));
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* `coverageTierForLanguage(language) -> entry | null`. Never fabricates —
|
|
51
|
+
* returns `null` for any language string not in the curated table above
|
|
52
|
+
* (including coverage.js's own `'unknown'` fallback), so a caller must
|
|
53
|
+
* decide its own honest default (coverage.js's ledger uses `'unknown'`).
|
|
54
|
+
*/
|
|
55
|
+
export function coverageTierForLanguage(language) {
|
|
56
|
+
if (typeof language !== 'string' || language.length === 0) return null;
|
|
57
|
+
return _byLanguage.get(language) ?? null;
|
|
58
|
+
}
|