@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,292 @@
|
|
|
1
|
+
// Inventory View: PRD §7.6's 11 required inventory categories. Split into
|
|
2
|
+
// pure computeInventoryViewModel() (each per-category compute function
|
|
3
|
+
// producing {columns, rows}) and thin renderInventoryView() (DOM-building
|
|
4
|
+
// via el()/clear()), mirroring every other view in src/views/.
|
|
5
|
+
|
|
6
|
+
import { worstVerdict } from '../lib/protection-visual.js';
|
|
7
|
+
import { AI_SUBTYPES } from '../lib/flow-path.js';
|
|
8
|
+
import { INVENTORY_TABLES } from '../lib/state.js';
|
|
9
|
+
import { el, clear } from '../lib/dom.js';
|
|
10
|
+
import { matchesFilters } from '../lib/row-filters.js';
|
|
11
|
+
|
|
12
|
+
const TABLE_LABELS = {
|
|
13
|
+
sources: 'Sources',
|
|
14
|
+
sinks: 'Sinks',
|
|
15
|
+
fields: 'Fields / data elements',
|
|
16
|
+
externalDestinations: 'External destinations',
|
|
17
|
+
stores: 'Stores',
|
|
18
|
+
aiSystems: 'AI systems & processing contexts',
|
|
19
|
+
transformations: 'Transformations',
|
|
20
|
+
unprotectedEdges: 'Unprotected or unknown edges',
|
|
21
|
+
policyPermittedFlows: 'Policy-permitted flows',
|
|
22
|
+
manualGovernanceGaps: 'Manual governance gaps',
|
|
23
|
+
unsupportedCandidates: 'Unsupported or unresolved candidates',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const UNPROTECTED_TIERS = new Set(['unprotected', 'mixed', 'unknown']);
|
|
27
|
+
|
|
28
|
+
function edgeWorstVerdict(edge) {
|
|
29
|
+
return worstVerdict([edge.protection.transit.verdict, edge.protection.atRest.verdict, edge.protection.handling.verdict]);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function nodeLabelFor(graph, nodeId) {
|
|
33
|
+
return graph.nodes.find((n) => n.id === nodeId)?.label ?? 'unknown';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The same per-flow filter-facet properties privacy-view.js's
|
|
37
|
+
// computePrivacyRow() attaches, computed the same way (this flow's own
|
|
38
|
+
// resolved edges, aggregated per-dimension via worstVerdict()), for
|
|
39
|
+
// Inventory's two flow-shaped categories (policyPermittedFlows,
|
|
40
|
+
// manualGovernanceGaps' "Flow"-subject rows) only. worstVerdict() always
|
|
41
|
+
// returns a real verdict string (never null/undefined, even for an empty
|
|
42
|
+
// edge list), so the three verdict properties are always set
|
|
43
|
+
// unconditionally; sourceCategory/sinkCategory/destinationExternality are
|
|
44
|
+
// only set when a real, non-null value exists, per the same rule
|
|
45
|
+
// computePrivacyRow() follows.
|
|
46
|
+
function computeFlowFilterProperties(graph, flow) {
|
|
47
|
+
const pathEdges = flow.edgeIds.map((edgeId) => graph.edges.find((e) => e.id === edgeId)).filter(Boolean);
|
|
48
|
+
const sourceNode = graph.nodes.find((n) => n.id === flow.source);
|
|
49
|
+
const sinkNode = graph.nodes.find((n) => n.id === flow.sink);
|
|
50
|
+
return {
|
|
51
|
+
transitVerdict: worstVerdict(pathEdges.map((e) => e.protection.transit.verdict)),
|
|
52
|
+
atRestVerdict: worstVerdict(pathEdges.map((e) => e.protection.atRest.verdict)),
|
|
53
|
+
handlingVerdict: worstVerdict(pathEdges.map((e) => e.protection.handling.verdict)),
|
|
54
|
+
policyVerdict: flow.policyVerdict,
|
|
55
|
+
...(sourceNode?.subtype ? { sourceCategory: sourceNode.subtype } : {}),
|
|
56
|
+
...(sinkNode?.subtype ? { sinkCategory: sinkNode.subtype } : {}),
|
|
57
|
+
...(sinkNode?.externality?.value ? { destinationExternality: sinkNode.externality.value } : {}),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const TABLE_COMPUTE = {
|
|
62
|
+
sources: (graph) => ({
|
|
63
|
+
columns: ['Label', 'Category', 'Coverage', 'Externality'],
|
|
64
|
+
rows: graph.nodes.filter((n) => n.kind === 'source').map((n) => ({
|
|
65
|
+
id: n.id, selectableId: n.id,
|
|
66
|
+
cells: [n.label, n.subtype ?? '—', n.coverageStatus, n.externality?.value ?? 'unknown'],
|
|
67
|
+
})),
|
|
68
|
+
}),
|
|
69
|
+
sinks: (graph) => ({
|
|
70
|
+
columns: ['Label', 'Category', 'Coverage', 'Externality'],
|
|
71
|
+
rows: graph.nodes.filter((n) => n.kind === 'sink').map((n) => ({
|
|
72
|
+
id: n.id, selectableId: n.id,
|
|
73
|
+
cells: [n.label, n.subtype ?? '—', n.coverageStatus, n.externality?.value ?? 'unknown'],
|
|
74
|
+
})),
|
|
75
|
+
}),
|
|
76
|
+
fields: (graph) => ({
|
|
77
|
+
columns: ['Name', 'Data classes', 'AI contexts'],
|
|
78
|
+
rows: graph.dataElements.map((d) => ({
|
|
79
|
+
id: d.id, selectableId: d.id,
|
|
80
|
+
cells: [d.name, (d.dataClasses ?? []).join(', ') || '—', (d.aiContexts ?? []).join(', ') || '—'],
|
|
81
|
+
dataClasses: d.dataClasses ?? [],
|
|
82
|
+
})),
|
|
83
|
+
}),
|
|
84
|
+
externalDestinations: (graph) => ({
|
|
85
|
+
columns: ['Label', 'Kind', 'Resolution status', 'Literal value'],
|
|
86
|
+
rows: graph.nodes.filter((n) => n.externality?.value === 'external').map((n) => ({
|
|
87
|
+
id: n.id, selectableId: n.id,
|
|
88
|
+
cells: [n.label, n.kind, n.destination?.resolutionStatus ?? 'unknown', n.destination?.literalValue ?? '—'],
|
|
89
|
+
})),
|
|
90
|
+
}),
|
|
91
|
+
stores: (graph) => ({
|
|
92
|
+
columns: ['Label', 'Operation', 'Columns'],
|
|
93
|
+
rows: graph.nodes.filter((n) => n.kind === 'store').map((n) => ({
|
|
94
|
+
id: n.id, selectableId: n.id,
|
|
95
|
+
cells: [n.label, n.storeDetail?.operation ?? 'unknown', (n.storeDetail?.columns ?? []).join(', ') || '—'],
|
|
96
|
+
})),
|
|
97
|
+
}),
|
|
98
|
+
aiSystems: (graph) => {
|
|
99
|
+
const aiNodes = graph.nodes.filter((n) => n.subtype && AI_SUBTYPES.has(n.subtype)).map((n) => ({
|
|
100
|
+
id: n.id, selectableId: n.id,
|
|
101
|
+
cells: ['Node', n.label, n.subtype],
|
|
102
|
+
}));
|
|
103
|
+
const aiDataElements = graph.dataElements.filter((d) => (d.aiContexts ?? []).length > 0).map((d) => ({
|
|
104
|
+
id: d.id, selectableId: d.id,
|
|
105
|
+
cells: ['Data element', d.name, (d.aiContexts ?? []).join(', ')],
|
|
106
|
+
}));
|
|
107
|
+
return { columns: ['Subject', 'Label', 'Category / context'], rows: [...aiNodes, ...aiDataElements] };
|
|
108
|
+
},
|
|
109
|
+
transformations: (graph) => ({
|
|
110
|
+
columns: ['Kind', 'Reversibility'],
|
|
111
|
+
rows: graph.transformations.map((t) => ({
|
|
112
|
+
id: t.id, selectableId: t.id,
|
|
113
|
+
cells: [t.kind, t.reversibility],
|
|
114
|
+
})),
|
|
115
|
+
}),
|
|
116
|
+
unprotectedEdges: (graph) => ({
|
|
117
|
+
columns: ['From', 'To', 'Transit', 'At rest', 'Handling', 'Worst verdict'],
|
|
118
|
+
rows: graph.edges.filter((e) => UNPROTECTED_TIERS.has(edgeWorstVerdict(e))).map((e) => ({
|
|
119
|
+
id: e.id, selectableId: e.id,
|
|
120
|
+
cells: [nodeLabelFor(graph, e.from), nodeLabelFor(graph, e.to), e.protection.transit.verdict, e.protection.atRest.verdict, e.protection.handling.verdict, edgeWorstVerdict(e)],
|
|
121
|
+
})),
|
|
122
|
+
}),
|
|
123
|
+
policyPermittedFlows: (graph) => ({
|
|
124
|
+
columns: ['Field', 'Source', 'Sink', 'Policy verdict'],
|
|
125
|
+
rows: graph.flows.filter((f) => f.policyVerdict === 'permitted').map((f) => {
|
|
126
|
+
const dataElement = graph.dataElements.find((d) => f.dataElementIds.includes(d.id));
|
|
127
|
+
return {
|
|
128
|
+
id: f.id, selectableId: f.id,
|
|
129
|
+
cells: [dataElement?.name ?? 'unknown field', nodeLabelFor(graph, f.source), nodeLabelFor(graph, f.sink), f.policyVerdict],
|
|
130
|
+
dataClasses: dataElement?.dataClasses ?? [],
|
|
131
|
+
protectionSummary: f.protectionSummary,
|
|
132
|
+
...computeFlowFilterProperties(graph, f),
|
|
133
|
+
};
|
|
134
|
+
}),
|
|
135
|
+
}),
|
|
136
|
+
manualGovernanceGaps: (graph) => {
|
|
137
|
+
const manualFlows = graph.flows.filter((f) => f.policyVerdict === 'manual_review_required').map((f) => {
|
|
138
|
+
const dataElement = graph.dataElements.find((d) => f.dataElementIds.includes(d.id));
|
|
139
|
+
return {
|
|
140
|
+
id: f.id, selectableId: f.id,
|
|
141
|
+
cells: ['Flow', dataElement?.name ?? 'unknown field', 'policyVerdict: manual_review_required'],
|
|
142
|
+
dataClasses: dataElement?.dataClasses ?? [],
|
|
143
|
+
protectionSummary: f.protectionSummary,
|
|
144
|
+
...computeFlowFilterProperties(graph, f),
|
|
145
|
+
};
|
|
146
|
+
});
|
|
147
|
+
const manualNodes = graph.nodes.filter((n) => n.coverageStatus === 'manual').map((n) => ({
|
|
148
|
+
id: n.id, selectableId: n.id,
|
|
149
|
+
cells: ['Node', n.label, 'coverageStatus: manual'],
|
|
150
|
+
}));
|
|
151
|
+
const manualEdges = graph.edges.filter((e) => e.coverageStatus === 'manual').map((e) => ({
|
|
152
|
+
id: e.id, selectableId: e.id,
|
|
153
|
+
cells: ['Edge', `${nodeLabelFor(graph, e.from)} → ${nodeLabelFor(graph, e.to)}`, 'coverageStatus: manual'],
|
|
154
|
+
}));
|
|
155
|
+
return { columns: ['Subject', 'Label', 'Reason'], rows: [...manualFlows, ...manualNodes, ...manualEdges] };
|
|
156
|
+
},
|
|
157
|
+
unsupportedCandidates: (graph) => ({
|
|
158
|
+
columns: ['Label', 'Kind', 'Coverage status', 'Reason'],
|
|
159
|
+
rows: graph.nodes.filter((n) => n.kind === 'unresolved' || n.coverageStatus === 'unsupported' || n.coverageStatus === 'candidate').map((n) => ({
|
|
160
|
+
id: n.id, selectableId: n.id,
|
|
161
|
+
cells: [n.label, n.kind, n.coverageStatus, n.coverageReason ?? '—'],
|
|
162
|
+
})),
|
|
163
|
+
}),
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
// Categories whose rows carry the properties filter-rail.js's three chip
|
|
167
|
+
// groups filter on (dataClasses / protectionSummary / AI relevance). Wired
|
|
168
|
+
// per docs/superpowers/plans/2026-09-01-data-flow-explorer-m3-inventory-scoping.md's
|
|
169
|
+
// decision 4 — deliberately not every category; see that doc before
|
|
170
|
+
// changing this set.
|
|
171
|
+
const FILTERABLE_TABLES = new Set(['fields', 'policyPermittedFlows', 'manualGovernanceGaps']);
|
|
172
|
+
|
|
173
|
+
// Milestone 3, sub-project M3-UX-Query, Task 4. The query language's
|
|
174
|
+
// compileQuery predicate is fundamentally FLOW-scoped (query-language.js's
|
|
175
|
+
// own header comment: "the predicate operates on a FLOW"), but Inventory's
|
|
176
|
+
// 11 categories are a mix of flow/node/edge/dataElement/transformation rows.
|
|
177
|
+
// Rather than special-case each category, this looks up a real flow by the
|
|
178
|
+
// row's own id — which only resolves for rows whose `id` genuinely IS a
|
|
179
|
+
// flow id (policyPermittedFlows' rows, and manualGovernanceGaps' "Flow"-
|
|
180
|
+
// subject rows, both of which use `id: f.id`). A row with no corresponding
|
|
181
|
+
// flow (a source/sink/store/dataElement/transformation/node/edge row) has
|
|
182
|
+
// nothing for the query predicate to test against, so the query has no
|
|
183
|
+
// effect there and the row's visibility is governed only by the existing
|
|
184
|
+
// dataClass/protection filters — an honest, disclosed scope limitation, not
|
|
185
|
+
// a bug: this DSL was never designed to describe a bare node or edge.
|
|
186
|
+
function rowMatchesQuery(graph, row, queryPredicate) {
|
|
187
|
+
if (!queryPredicate) return true;
|
|
188
|
+
const flow = graph.flows.find((f) => f.id === row.id);
|
|
189
|
+
if (!flow) return true;
|
|
190
|
+
return queryPredicate(flow);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* @param {object} graph
|
|
195
|
+
* @param {object} state
|
|
196
|
+
* @param {((flow: object) => boolean) | null} [queryPredicate] - see
|
|
197
|
+
* rowMatchesQuery's own comment above. Applied as an ADDITIONAL condition
|
|
198
|
+
* alongside the existing dataClass/protection filters, independent of
|
|
199
|
+
* whether the active table is in FILTERABLE_TABLES (query narrowing is
|
|
200
|
+
* orthogonal to the filter-rail's own chip-based facets). Omitted/null
|
|
201
|
+
* (every pre-existing caller/test) matches every row, so behavior is
|
|
202
|
+
* unchanged for anyone not passing it.
|
|
203
|
+
*/
|
|
204
|
+
export function computeInventoryViewModel(graph, state, queryPredicate = null) {
|
|
205
|
+
const activeTable = INVENTORY_TABLES.includes(state.table) ? state.table : INVENTORY_TABLES[0];
|
|
206
|
+
const tables = INVENTORY_TABLES.map((id) => ({
|
|
207
|
+
id, label: TABLE_LABELS[id],
|
|
208
|
+
count: TABLE_COMPUTE[id](graph).rows.length,
|
|
209
|
+
}));
|
|
210
|
+
|
|
211
|
+
const { columns, rows: rawRows } = TABLE_COMPUTE[activeTable](graph);
|
|
212
|
+
const filterable = FILTERABLE_TABLES.has(activeTable);
|
|
213
|
+
const rows = rawRows.map((row) => ({
|
|
214
|
+
...row,
|
|
215
|
+
selected: row.id === state.selectedId,
|
|
216
|
+
visible: (filterable ? matchesFilters(row, state.filters ?? {}) : true) && rowMatchesQuery(graph, row, queryPredicate),
|
|
217
|
+
}));
|
|
218
|
+
|
|
219
|
+
return { tables, activeTable, columns, rows, filterable };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function renderSubNav(viewModel, onTableChange) {
|
|
223
|
+
const buttons = viewModel.tables.map((t) =>
|
|
224
|
+
el(
|
|
225
|
+
'button',
|
|
226
|
+
{
|
|
227
|
+
class: 'inventory-subnav-button',
|
|
228
|
+
'data-table-id': t.id,
|
|
229
|
+
'data-active': String(t.id === viewModel.activeTable),
|
|
230
|
+
'aria-pressed': String(t.id === viewModel.activeTable),
|
|
231
|
+
onClick: () => onTableChange(t.id),
|
|
232
|
+
},
|
|
233
|
+
`${t.label} (${t.count})`,
|
|
234
|
+
),
|
|
235
|
+
);
|
|
236
|
+
return el('div', { class: 'inventory-subnav' }, buttons);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function renderRow(row, onSelect) {
|
|
240
|
+
return el(
|
|
241
|
+
'tr',
|
|
242
|
+
{
|
|
243
|
+
class: 'inventory-row',
|
|
244
|
+
'data-selected': String(row.selected),
|
|
245
|
+
'data-visible': String(row.visible),
|
|
246
|
+
tabindex: '0',
|
|
247
|
+
role: 'button',
|
|
248
|
+
'aria-label': `${row.cells[0]}${row.selected ? ', selected' : ''}`,
|
|
249
|
+
onClick: () => row.selectableId && onSelect(row.selectableId),
|
|
250
|
+
onKeydown: (evt) => {
|
|
251
|
+
if ((evt.key === 'Enter' || evt.key === ' ') && row.selectableId) {
|
|
252
|
+
evt.preventDefault();
|
|
253
|
+
onSelect(row.selectableId);
|
|
254
|
+
}
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
row.cells.map((cellText) => el('td', {}, cellText)),
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function sortRows(rows, columnIndex, direction) {
|
|
262
|
+
const sorted = [...rows].sort((a, b) => {
|
|
263
|
+
const cmp = String(a.cells[columnIndex]).localeCompare(String(b.cells[columnIndex]));
|
|
264
|
+
return direction === 'asc' ? cmp : -cmp;
|
|
265
|
+
});
|
|
266
|
+
return sorted;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* @param {ReturnType<typeof computeInventoryViewModel>} viewModel
|
|
271
|
+
* @param {HTMLElement} canvasEl
|
|
272
|
+
* @param {(selectableId: string) => void} onSelect
|
|
273
|
+
* @param {(tableId: string) => void} onTableChange
|
|
274
|
+
*/
|
|
275
|
+
export function renderInventoryView(viewModel, canvasEl, onSelect, onTableChange) {
|
|
276
|
+
clear(canvasEl);
|
|
277
|
+
|
|
278
|
+
const subNav = renderSubNav(viewModel, onTableChange);
|
|
279
|
+
|
|
280
|
+
let sortState = { columnIndex: null, direction: 'asc' };
|
|
281
|
+
const headerRow = el('tr', {}, viewModel.columns.map((col, i) =>
|
|
282
|
+
el('th', { onClick: () => {
|
|
283
|
+
sortState = sortState.columnIndex === i ? { columnIndex: i, direction: sortState.direction === 'asc' ? 'desc' : 'asc' } : { columnIndex: i, direction: 'asc' };
|
|
284
|
+
const sortedRows = sortRows(viewModel.rows, sortState.columnIndex, sortState.direction);
|
|
285
|
+
renderInventoryView({ ...viewModel, rows: sortedRows }, canvasEl, onSelect, onTableChange);
|
|
286
|
+
} }, col),
|
|
287
|
+
));
|
|
288
|
+
const bodyRows = viewModel.rows.map((row) => renderRow(row, onSelect));
|
|
289
|
+
const table = el('table', { class: 'inventory-table' }, [el('thead', {}, headerRow), el('tbody', {}, bodyRows)]);
|
|
290
|
+
|
|
291
|
+
canvasEl.appendChild(el('div', { class: 'inventory-view' }, [subNav, table]));
|
|
292
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { flowPathNodeIds, isAiRelevantFlow } from '../lib/flow-path.js';
|
|
2
|
+
import { el, clear } from '../lib/dom.js';
|
|
3
|
+
import { protectionVisual, worstVerdict } from '../lib/protection-visual.js';
|
|
4
|
+
import { matchesFilters } from '../lib/row-filters.js';
|
|
5
|
+
|
|
6
|
+
export const LIFECYCLE_STAGES = Object.freeze(['collection', 'processing', 'storage', 'sharing', 'retention', 'deletion']);
|
|
7
|
+
|
|
8
|
+
export function stageForNode(node) {
|
|
9
|
+
return LIFECYCLE_STAGES.includes(node.lifecycleStages?.[0]) ? node.lifecycleStages[0] : 'processing';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function computePrivacyRow(graph, flow) {
|
|
13
|
+
const dataElement = graph.dataElements.find((d) => flow.dataElementIds.includes(d.id));
|
|
14
|
+
const pathNodeIds = flowPathNodeIds(graph, flow);
|
|
15
|
+
const pathNodes = graph.nodes.filter((n) => pathNodeIds.has(n.id));
|
|
16
|
+
// This flow's own resolved edges — the same per-edgeId lookup
|
|
17
|
+
// flowPathNodeIds() already performs internally, applied here to keep the
|
|
18
|
+
// edge objects (rather than just the node ids) so the three protection
|
|
19
|
+
// dimensions below can be aggregated per-flow via worstVerdict().
|
|
20
|
+
const pathEdges = flow.edgeIds.map((edgeId) => graph.edges.find((e) => e.id === edgeId)).filter(Boolean);
|
|
21
|
+
|
|
22
|
+
const stageCells = LIFECYCLE_STAGES.map((stage) => ({
|
|
23
|
+
stage,
|
|
24
|
+
nodeLabels: pathNodes.filter((n) => stageForNode(n) === stage).map((n) => n.label),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
const sourceNode = graph.nodes.find((n) => n.id === flow.source);
|
|
28
|
+
const sinkNode = graph.nodes.find((n) => n.id === flow.sink);
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
flowId: flow.id,
|
|
32
|
+
dataElementName: dataElement?.name ?? 'unknown field',
|
|
33
|
+
dataClasses: dataElement?.dataClasses ?? [],
|
|
34
|
+
stageCells,
|
|
35
|
+
governanceRefs: flow.governanceRefs ?? {},
|
|
36
|
+
protectionSummary: flow.protectionSummary,
|
|
37
|
+
policyVerdict: flow.policyVerdict,
|
|
38
|
+
isAiRelevant: isAiRelevantFlow(graph, flow),
|
|
39
|
+
// worstVerdict() always returns a real verdict string (falling back to
|
|
40
|
+
// 'not_assessed', never null/undefined) even for an empty edge list, so
|
|
41
|
+
// these three are always set unconditionally — unlike sourceCategory/
|
|
42
|
+
// sinkCategory/destinationExternality below, there is no genuinely-absent
|
|
43
|
+
// case to guard against.
|
|
44
|
+
transitVerdict: worstVerdict(pathEdges.map((e) => e.protection.transit.verdict)),
|
|
45
|
+
atRestVerdict: worstVerdict(pathEdges.map((e) => e.protection.atRest.verdict)),
|
|
46
|
+
handlingVerdict: worstVerdict(pathEdges.map((e) => e.protection.handling.verdict)),
|
|
47
|
+
// Only set when a real, non-null value exists — keeps matchesFilters's
|
|
48
|
+
// own "property absent = unaffected, never a hide" semantics clean
|
|
49
|
+
// rather than introducing a third (present-but-null) state.
|
|
50
|
+
...(sourceNode?.subtype ? { sourceCategory: sourceNode.subtype } : {}),
|
|
51
|
+
...(sinkNode?.subtype ? { sinkCategory: sinkNode.subtype } : {}),
|
|
52
|
+
...(sinkNode?.externality?.value ? { destinationExternality: sinkNode.externality.value } : {}),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {object} graph
|
|
58
|
+
* @param {object} state
|
|
59
|
+
* @param {((flow: object) => boolean) | null} [queryPredicate] - Milestone 3,
|
|
60
|
+
* sub-project M3-UX-Query, Task 4: the query language's compiled predicate
|
|
61
|
+
* (lib/query-language.js's `compileQuery`), applied here as an ADDITIONAL
|
|
62
|
+
* condition alongside the existing dataClass/protection/ai filters — a row
|
|
63
|
+
* must pass BOTH to stay visible. Omitted/null (every pre-existing
|
|
64
|
+
* caller/test) means "no query active," matching every row, so behavior
|
|
65
|
+
* is unchanged for anyone not passing it.
|
|
66
|
+
*/
|
|
67
|
+
export function computePrivacyViewModel(graph, state, queryPredicate = null) {
|
|
68
|
+
const matchesQuery = queryPredicate ?? (() => true);
|
|
69
|
+
const rows = graph.flows.map((flow) => {
|
|
70
|
+
const row = computePrivacyRow(graph, flow);
|
|
71
|
+
return {
|
|
72
|
+
...row,
|
|
73
|
+
selected: row.flowId === state.selectedId,
|
|
74
|
+
visible: matchesFilters(row, state.filters ?? {}) && matchesQuery(flow),
|
|
75
|
+
};
|
|
76
|
+
});
|
|
77
|
+
return { stages: LIFECYCLE_STAGES, rows };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const CLASS_BADGE_COLOR_VAR = { PII: '--class-pii', PHI: '--class-phi', PCI: '--class-pci' };
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {ReturnType<typeof computePrivacyViewModel>} viewModel
|
|
84
|
+
* @param {HTMLElement} canvasEl
|
|
85
|
+
* @param {(flowId: string) => void} onSelectFlow
|
|
86
|
+
*/
|
|
87
|
+
export function renderPrivacyView(viewModel, canvasEl, onSelectFlow) {
|
|
88
|
+
clear(canvasEl);
|
|
89
|
+
|
|
90
|
+
const headerRow = el('tr', {}, [
|
|
91
|
+
el('th', {}, 'Field'),
|
|
92
|
+
el('th', {}, 'Protection'),
|
|
93
|
+
...viewModel.stages.map((stage) => el('th', {}, stage.charAt(0).toUpperCase() + stage.slice(1))),
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
const bodyRows = viewModel.rows.map((row) => renderPrivacyRow(row, onSelectFlow));
|
|
97
|
+
|
|
98
|
+
const table = el('table', { class: 'privacy-table' }, [el('thead', {}, headerRow), el('tbody', {}, bodyRows)]);
|
|
99
|
+
|
|
100
|
+
canvasEl.appendChild(el('div', { class: 'privacy-view' }, table));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function renderPrivacyRow(row, onSelectFlow) {
|
|
104
|
+
const classBadges = row.dataClasses.map((cls) =>
|
|
105
|
+
el('span', { class: 'privacy-class-badge', style: `color: var(${CLASS_BADGE_COLOR_VAR[cls] ?? '--text-secondary'}); border-color: var(${CLASS_BADGE_COLOR_VAR[cls] ?? '--border-default'})` }, cls),
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const fieldCell = el('td', { class: 'privacy-field-cell' }, [
|
|
109
|
+
el('div', {}, row.dataElementName),
|
|
110
|
+
el('div', {}, classBadges),
|
|
111
|
+
row.isAiRelevant ? el('span', { class: 'privacy-governance-badge', style: 'border-color: var(--context-ai); color: var(--context-ai)' }, 'AI processing') : null,
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
const visual = protectionVisual(row.protectionSummary);
|
|
115
|
+
const protectionCell = el(
|
|
116
|
+
'td',
|
|
117
|
+
{ class: 'privacy-protection-cell' },
|
|
118
|
+
el('span', { style: `border-color: var(${visual.colorVar}); color: var(${visual.colorVar})` }, `${visual.glyph} ${visual.label}`),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const stageCells = row.stageCells.map((cell) => renderStageCell(cell, row));
|
|
122
|
+
|
|
123
|
+
return el(
|
|
124
|
+
'tr',
|
|
125
|
+
{
|
|
126
|
+
class: 'privacy-row',
|
|
127
|
+
'data-selected': String(row.selected),
|
|
128
|
+
'data-visible': String(row.visible),
|
|
129
|
+
tabindex: '0',
|
|
130
|
+
role: 'button',
|
|
131
|
+
'aria-label': `${row.dataElementName} lifecycle, ${row.protectionSummary}${row.selected ? ', selected' : ''}`,
|
|
132
|
+
onClick: () => onSelectFlow(row.flowId),
|
|
133
|
+
onKeydown: (evt) => {
|
|
134
|
+
if (evt.key === 'Enter' || evt.key === ' ') {
|
|
135
|
+
evt.preventDefault();
|
|
136
|
+
onSelectFlow(row.flowId);
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
[fieldCell, protectionCell, ...stageCells],
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function renderStageCell(cell, row) {
|
|
145
|
+
const children = [];
|
|
146
|
+
if (cell.nodeLabels.length > 0) {
|
|
147
|
+
children.push(el('div', {}, cell.nodeLabels.join(', ')));
|
|
148
|
+
} else {
|
|
149
|
+
children.push(el('div', { class: 'privacy-stage-cell-empty-label' }, '—'));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Governance facts are shown once, on whichever stage cell is the most
|
|
153
|
+
// relevant home for them — sharing (recipient/purpose/lawfulBasis/transfer)
|
|
154
|
+
// or retention/deletion — rather than repeating them on every cell. This
|
|
155
|
+
// loop runs unconditionally (independent of whether nodeLabels is empty)
|
|
156
|
+
// so a fact like deletion:not_found is never structurally unreachable just
|
|
157
|
+
// because a flow's path happens not to touch a deletion-stage node.
|
|
158
|
+
const governanceKeysForStage = {
|
|
159
|
+
sharing: ['recipient', 'purpose', 'lawfulBasis', 'transfer'],
|
|
160
|
+
retention: ['retention'],
|
|
161
|
+
deletion: ['deletion'],
|
|
162
|
+
};
|
|
163
|
+
const relevantKeys = governanceKeysForStage[cell.stage] ?? [];
|
|
164
|
+
for (const key of relevantKeys) {
|
|
165
|
+
if (key in row.governanceRefs) {
|
|
166
|
+
const value = row.governanceRefs[key];
|
|
167
|
+
children.push(el('div', { class: 'privacy-governance-badge' }, `${key}: ${value}`));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return el('td', { class: cell.nodeLabels.length === 0 ? 'privacy-stage-cell privacy-stage-cell-empty' : 'privacy-stage-cell' }, children);
|
|
172
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { el, clear } from '../lib/dom.js';
|
|
2
|
+
import { protectionVisual } from '../lib/protection-visual.js';
|
|
3
|
+
|
|
4
|
+
export function computeTraceSteps(graph, flow) {
|
|
5
|
+
const steps = [];
|
|
6
|
+
const sourceNode = graph.nodes.find((n) => n.id === flow.source);
|
|
7
|
+
const dataElement = graph.dataElements.find((d) => flow.dataElementIds.includes(d.id));
|
|
8
|
+
|
|
9
|
+
steps.push({
|
|
10
|
+
kind: 'source',
|
|
11
|
+
fieldName: dataElement?.name ?? 'unknown field',
|
|
12
|
+
node: sourceNode?.label ?? 'unknown source',
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const edges = flow.edgeIds.map((id) => graph.edges.find((e) => e.id === id)).filter(Boolean);
|
|
16
|
+
for (const edge of edges) {
|
|
17
|
+
const toNode = graph.nodes.find((n) => n.id === edge.to);
|
|
18
|
+
const mappings = edge.fieldMappings ?? [];
|
|
19
|
+
|
|
20
|
+
if (mappings.length === 0) {
|
|
21
|
+
steps.push({
|
|
22
|
+
kind: 'hop',
|
|
23
|
+
node: toNode?.label ?? 'unknown',
|
|
24
|
+
fromNodeId: edge.from,
|
|
25
|
+
protection: edge.protection,
|
|
26
|
+
evidenceRefs: edge.evidenceRefs ?? [],
|
|
27
|
+
});
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
for (const mapping of mappings) {
|
|
32
|
+
const transformations = (mapping.transformationIds ?? [])
|
|
33
|
+
.map((tid) => graph.transformations.find((t) => t.id === tid))
|
|
34
|
+
.filter(Boolean);
|
|
35
|
+
steps.push({
|
|
36
|
+
kind: transformations.length > 0 ? 'transformation' : 'propagation',
|
|
37
|
+
fromPath: mapping.fromPath,
|
|
38
|
+
toPath: mapping.toPath,
|
|
39
|
+
mappingType: mapping.mappingType,
|
|
40
|
+
transformations,
|
|
41
|
+
node: toNode?.label ?? 'unknown',
|
|
42
|
+
fromNodeId: edge.from,
|
|
43
|
+
boundaryCrossing: (edge.boundaryCrossings ?? []).length > 0,
|
|
44
|
+
protection: edge.protection,
|
|
45
|
+
evidenceRefs: edge.evidenceRefs ?? [],
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const sinkNode = graph.nodes.find((n) => n.id === flow.sink);
|
|
51
|
+
steps.push({
|
|
52
|
+
kind: 'sink',
|
|
53
|
+
node: sinkNode?.label ?? 'unknown destination',
|
|
54
|
+
externality: sinkNode?.externality?.value ?? 'unknown',
|
|
55
|
+
protectionSummary: flow.protectionSummary,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
return steps;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function computeTraceStepGroups(steps) {
|
|
62
|
+
const middleSteps = steps.filter((s) => s.kind !== 'source' && s.kind !== 'sink');
|
|
63
|
+
const sourceStep = steps.find((s) => s.kind === 'source');
|
|
64
|
+
const sinkStep = steps.find((s) => s.kind === 'sink');
|
|
65
|
+
|
|
66
|
+
const groupsByFrom = new Map();
|
|
67
|
+
for (const step of middleSteps) {
|
|
68
|
+
if (!groupsByFrom.has(step.fromNodeId)) groupsByFrom.set(step.fromNodeId, []);
|
|
69
|
+
groupsByFrom.get(step.fromNodeId).push(step);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const groups = [];
|
|
73
|
+
if (sourceStep) groups.push({ type: 'source', step: sourceStep });
|
|
74
|
+
for (const groupSteps of groupsByFrom.values()) {
|
|
75
|
+
if (groupSteps.length > 1) {
|
|
76
|
+
groups.push({ type: 'branch', steps: groupSteps });
|
|
77
|
+
} else {
|
|
78
|
+
groups.push({ type: 'sequential', step: groupSteps[0] });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (sinkStep) groups.push({ type: 'sink', step: sinkStep });
|
|
82
|
+
return groups;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function computeAlternatePaths(graph, flow) {
|
|
86
|
+
return graph.flows
|
|
87
|
+
.filter((f) => f.id !== flow.id && f.dataElementIds.some((id) => flow.dataElementIds.includes(id)))
|
|
88
|
+
.map((f) => ({
|
|
89
|
+
flowId: f.id,
|
|
90
|
+
destinationLabel: graph.nodes.find((n) => n.id === f.sink)?.label ?? 'unknown',
|
|
91
|
+
protectionSummary: f.protectionSummary,
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function computeTraceViewModel(graph, state) {
|
|
96
|
+
if (!state.selectedId) return null;
|
|
97
|
+
const flow = graph.flows.find((f) => f.id === state.selectedId);
|
|
98
|
+
if (!flow) return null;
|
|
99
|
+
return {
|
|
100
|
+
flow,
|
|
101
|
+
steps: computeTraceSteps(graph, flow),
|
|
102
|
+
alternatePaths: computeAlternatePaths(graph, flow),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* @param {ReturnType<typeof computeTraceViewModel>} viewModel
|
|
108
|
+
* @param {HTMLElement} canvasEl
|
|
109
|
+
* @param {(flowId: string) => void} onSelectAlternate
|
|
110
|
+
*/
|
|
111
|
+
export function renderTraceView(viewModel, canvasEl, onSelectAlternate) {
|
|
112
|
+
clear(canvasEl);
|
|
113
|
+
|
|
114
|
+
if (!viewModel) {
|
|
115
|
+
canvasEl.appendChild(el('p', { class: 'trace-empty' }, 'Select a flow from Privacy View (or click a node/edge in Architecture View that resolves to a flow) to trace it.'));
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const container = el('div', { class: 'trace-view' });
|
|
120
|
+
const groups = computeTraceStepGroups(viewModel.steps);
|
|
121
|
+
let stepNumber = 0;
|
|
122
|
+
for (const group of groups) {
|
|
123
|
+
stepNumber += 1;
|
|
124
|
+
if (group.type === 'branch') {
|
|
125
|
+
container.appendChild(renderTraceBranchGroup(group.steps, stepNumber));
|
|
126
|
+
} else {
|
|
127
|
+
container.appendChild(renderTraceStep(group.step, String(stepNumber)));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (viewModel.alternatePaths.length > 0) {
|
|
132
|
+
const items = viewModel.alternatePaths.map((alt) => {
|
|
133
|
+
const visual = protectionVisual(alt.protectionSummary);
|
|
134
|
+
return el(
|
|
135
|
+
'div',
|
|
136
|
+
{
|
|
137
|
+
class: 'trace-alternate-item',
|
|
138
|
+
tabindex: '0',
|
|
139
|
+
role: 'button',
|
|
140
|
+
onClick: () => onSelectAlternate(alt.flowId),
|
|
141
|
+
onKeydown: (evt) => {
|
|
142
|
+
if (evt.key === 'Enter' || evt.key === ' ') {
|
|
143
|
+
evt.preventDefault();
|
|
144
|
+
onSelectAlternate(alt.flowId);
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
`${visual.glyph} ${alt.destinationLabel} — ${visual.label}`,
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
container.appendChild(el('div', { class: 'trace-alternates' }, [el('h4', {}, 'Alternate destinations'), ...items]));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
canvasEl.appendChild(container);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function renderTraceBranchGroup(steps, number) {
|
|
158
|
+
const letters = 'abcdefghijklmnopqrstuvwxyz';
|
|
159
|
+
const items = steps.map((step, i) => renderTraceStep(step, `${number}${letters[i]}`));
|
|
160
|
+
return el('div', { class: 'trace-branch-group' }, [
|
|
161
|
+
el('div', { class: 'trace-branch-label' }, `${steps.length} branches from here`),
|
|
162
|
+
...items,
|
|
163
|
+
]);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function renderTraceStep(step, label) {
|
|
167
|
+
const bodyChildren = [el('div', { class: 'trace-step-kind' }, step.kind)];
|
|
168
|
+
|
|
169
|
+
if (step.kind === 'source') {
|
|
170
|
+
bodyChildren.push(el('div', { class: 'trace-step-mapping' }, step.fieldName));
|
|
171
|
+
} else if (step.kind === 'transformation' || step.kind === 'propagation') {
|
|
172
|
+
bodyChildren.push(el('div', { class: 'trace-step-mapping' }, `${step.fromPath} → ${step.toPath} (${step.mappingType})`));
|
|
173
|
+
for (const t of step.transformations) {
|
|
174
|
+
bodyChildren.push(el('div', { class: 'trace-step-mapping' }, `${t.callee}() — ${t.kind}, ${t.reversibility}`));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
bodyChildren.push(el('div', { class: 'trace-step-node' }, step.node));
|
|
179
|
+
|
|
180
|
+
if (step.boundaryCrossing) {
|
|
181
|
+
bodyChildren.push(el('span', { class: 'trace-step-boundary' }, 'Trust boundary crossing'));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (step.protection) {
|
|
185
|
+
const dims = [
|
|
186
|
+
['Transit', step.protection.transit.verdict],
|
|
187
|
+
['At rest', step.protection.atRest.verdict],
|
|
188
|
+
['Handling', step.protection.handling.verdict],
|
|
189
|
+
];
|
|
190
|
+
for (const [label, verdict] of dims) {
|
|
191
|
+
const visual = protectionVisual(verdict);
|
|
192
|
+
bodyChildren.push(el('div', { class: 'trace-step-mapping' }, `${visual.glyph} ${label}: ${visual.label}`));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (step.evidenceRefs?.length > 0) {
|
|
197
|
+
bodyChildren.push(el('div', { class: 'trace-step-mapping' }, `${step.evidenceRefs.length} evidence reference(s)`));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (step.kind === 'sink') {
|
|
201
|
+
const visual = protectionVisual(step.protectionSummary);
|
|
202
|
+
bodyChildren.push(el('div', { class: 'trace-step-mapping' }, `${visual.glyph} Overall: ${visual.label} · ${step.externality} destination`));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return el('div', { class: 'trace-step' }, [el('div', { class: 'trace-step-number' }, label), el('div', { class: 'trace-step-body' }, bodyChildren)]);
|
|
206
|
+
}
|