@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,62 @@
|
|
|
1
|
+
// export-csv.js — Milestone 4, sub-project JSON/CSV export.
|
|
2
|
+
//
|
|
3
|
+
// One row per FLOW (not node, not edge — the closest analogue in this
|
|
4
|
+
// domain to report/index.js's own toCSV's "one row per finding", per this
|
|
5
|
+
// sub-project's own scoping doc decision 3). Node/edge CSV exports are
|
|
6
|
+
// deferred, named explicitly in that doc, not attempted here.
|
|
7
|
+
|
|
8
|
+
import { aggregateVerdicts } from './protection.js';
|
|
9
|
+
|
|
10
|
+
// Same escaping convention as report/index.js's own toCSV — quote only
|
|
11
|
+
// when a comma/quote/newline is present, double embedded quotes.
|
|
12
|
+
function esc(v) {
|
|
13
|
+
if (v == null) return '';
|
|
14
|
+
const s = String(v);
|
|
15
|
+
return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Join character for a flow's multiple `dataClasses` values in one CSV
|
|
19
|
+
// cell: `;`, not `,`. Chosen (this module's own decision, not prescribed
|
|
20
|
+
// by the plan) because `,` is CSV's own delimiter — joining multiple
|
|
21
|
+
// classes with it would put a raw delimiter inside a single logical cell
|
|
22
|
+
// value, forcing outer quoting for the ordinary multi-class case (e.g. a
|
|
23
|
+
// field classified both PCI and PII). `;` never collides with the CSV
|
|
24
|
+
// grammar, so the common case ("PCI;PII") never needs quoting at all.
|
|
25
|
+
// `esc()` still quotes correctly if a comma ends up in cell content for
|
|
26
|
+
// any other reason (see export-csv.test.js's own escaping test) — this
|
|
27
|
+
// choice only avoids manufacturing that case in the common path.
|
|
28
|
+
const DATA_CLASSES_JOIN = ';';
|
|
29
|
+
|
|
30
|
+
function _dataClassesForFlow(flow, dataElementsById) {
|
|
31
|
+
const classes = new Set();
|
|
32
|
+
for (const deId of flow.dataElementIds ?? []) {
|
|
33
|
+
const de = dataElementsById.get(deId);
|
|
34
|
+
for (const c of de?.dataClasses ?? []) classes.add(c);
|
|
35
|
+
}
|
|
36
|
+
return [...classes].sort();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function _dimensionVerdict(flow, edgesById, dimension) {
|
|
40
|
+
const verdicts = (flow.edgeIds ?? [])
|
|
41
|
+
.map((id) => edgesById.get(id)?.protection?.[dimension]?.verdict)
|
|
42
|
+
.filter(Boolean);
|
|
43
|
+
return aggregateVerdicts(verdicts);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function exportFlowsCSV(graph) {
|
|
47
|
+
const dataElementsById = new Map((graph.dataElements ?? []).map((d) => [d.id, d]));
|
|
48
|
+
const edgesById = new Map((graph.edges ?? []).map((e) => [e.id, e]));
|
|
49
|
+
const header = ['id', 'source', 'sink', 'dataClasses', 'transitVerdict', 'atRestVerdict', 'handlingVerdict', 'policyVerdict', 'coverageStatus'];
|
|
50
|
+
const rows = [header.join(',')];
|
|
51
|
+
for (const flow of graph.flows ?? []) {
|
|
52
|
+
rows.push([
|
|
53
|
+
esc(flow.id), esc(flow.source), esc(flow.sink),
|
|
54
|
+
esc(_dataClassesForFlow(flow, dataElementsById).join(DATA_CLASSES_JOIN)),
|
|
55
|
+
esc(_dimensionVerdict(flow, edgesById, 'transit')),
|
|
56
|
+
esc(_dimensionVerdict(flow, edgesById, 'atRest')),
|
|
57
|
+
esc(_dimensionVerdict(flow, edgesById, 'handling')),
|
|
58
|
+
esc(flow.policyVerdict), esc(flow.coverageStatus),
|
|
59
|
+
].join(','));
|
|
60
|
+
}
|
|
61
|
+
return rows.join('\n');
|
|
62
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// export-json.js — Milestone 4, sub-project JSON/CSV export.
|
|
2
|
+
//
|
|
3
|
+
// Deterministic JSON export of a DataFlowGraph v1 document, satisfying
|
|
4
|
+
// PRD §17.5 (embed filtered-or-full graph; default-redacted; scan health/
|
|
5
|
+
// scope/versions/limitations/generated timestamp; tamper-evident digest;
|
|
6
|
+
// confidential-content disclosure) and AC-14 (export reproducibility).
|
|
7
|
+
//
|
|
8
|
+
// graph.graphId is scan-metadata-derived (dfg:<repo>:<commit>:<config>,
|
|
9
|
+
// see ids.js's own `graphId()` — `dfg:${repo}:${commit}:${cfg}`), NOT a
|
|
10
|
+
// content digest — confirmed by direct read before this module was
|
|
11
|
+
// written. computeRunAttestation (posture/attestation.js) runs over
|
|
12
|
+
// `findings: normalizeFindings(scan)` only, never `scan.lineageGraph` —
|
|
13
|
+
// confirmed by direct read of both call sites in bin/agentic-security.js
|
|
14
|
+
// (the `agentic-security scan` and `agentic-security ci` commands). This
|
|
15
|
+
// module computes its own content digest instead of reusing either.
|
|
16
|
+
|
|
17
|
+
import * as crypto from 'node:crypto';
|
|
18
|
+
import { _redactGraph } from './redact-graph.js';
|
|
19
|
+
|
|
20
|
+
// Canonicalization for the content digest.
|
|
21
|
+
//
|
|
22
|
+
// REVISED after the final whole-branch review of this sub-project found
|
|
23
|
+
// the original hand-enumerated per-field allowlist (id/kind/subtype/
|
|
24
|
+
// coverageStatus on a node, etc.) silently EXCLUDED most risk-bearing
|
|
25
|
+
// content: mutating node.destination, node.externality, node.label,
|
|
26
|
+
// flow.handling, edge.provenance, dataElement.aiContexts/.name, and
|
|
27
|
+
// wiping graph.evidence/graph.transformations ENTIRELY all left the
|
|
28
|
+
// digest unchanged — the worse of the two possible failure modes for a
|
|
29
|
+
// "tamper-evident" claim (under-inclusion silently hides real tampering;
|
|
30
|
+
// AC-14's own concern, over-inclusion breaking reproducibility on a
|
|
31
|
+
// genuinely volatile field, is far easier to notice and fix, since it
|
|
32
|
+
// fails the reproducibility test immediately rather than failing
|
|
33
|
+
// silently in production).
|
|
34
|
+
//
|
|
35
|
+
// Rather than hand-list every field of every entity kind (exactly the
|
|
36
|
+
// approach that produced the gap above — dataflow-graph.schema.json
|
|
37
|
+
// alone declares 60+ distinct fields across 6 entity kinds, and a manual
|
|
38
|
+
// list drifts the moment the schema gains a field), this canonicalizes
|
|
39
|
+
// EVERYTHING in the graph via a small, explicit EXCLUDE list of the only
|
|
40
|
+
// genuinely volatile/non-content fields in the schema (confirmed against
|
|
41
|
+
// dataflow-graph.schema.json directly): `generatedAt` (graph-level,
|
|
42
|
+
// explicitly excluded per AC-14's own wording), `scanHealth` (describes
|
|
43
|
+
// the scan PROCESS — timing/duration-shaped, not the data content), and
|
|
44
|
+
// `timestamp` (evidence-level, scan-time-of-observation, not content).
|
|
45
|
+
// Everything else — every node/edge/flow/dataElement/evidence/
|
|
46
|
+
// transformation field, including ones added to the schema after this
|
|
47
|
+
// comment was written — is included by default, which is the safer
|
|
48
|
+
// default for a tamper-evidence digest: a new field must be deliberately
|
|
49
|
+
// ADDED to EXCLUDE_KEYS to be left out, rather than deliberately added to
|
|
50
|
+
// an allowlist to be included.
|
|
51
|
+
const EXCLUDE_KEYS = new Set(['generatedAt', 'scanHealth', 'timestamp']);
|
|
52
|
+
const ENTITY_ARRAY_KEYS = new Set(['nodes', 'edges', 'flows', 'dataElements', 'evidence', 'transformations']);
|
|
53
|
+
|
|
54
|
+
function _canon(value, keyHint) {
|
|
55
|
+
if (Array.isArray(value)) {
|
|
56
|
+
const mapped = value.map((v) => _canon(v));
|
|
57
|
+
// Only the six top-level entity arrays get sorted by id — they are
|
|
58
|
+
// graph-builder.js's own emission-sorted arrays (defensively re-sorted
|
|
59
|
+
// here rather than trusted), and sorting them makes the digest
|
|
60
|
+
// independent of array order. Nested arrays (edgeIds on a flow,
|
|
61
|
+
// fieldMappings on an edge, etc.) are NOT re-sorted: several are
|
|
62
|
+
// semantically ordered (a flow's edgeIds is a real path sequence,
|
|
63
|
+
// per ids.js's own pathId discriminator precedent) and re-sorting
|
|
64
|
+
// them would hide a genuine reordering of graph content.
|
|
65
|
+
if (keyHint && ENTITY_ARRAY_KEYS.has(keyHint)) {
|
|
66
|
+
mapped.sort((a, b) => {
|
|
67
|
+
const ai = a && typeof a === 'object' ? String(a.id ?? '') : String(a);
|
|
68
|
+
const bi = b && typeof b === 'object' ? String(b.id ?? '') : String(b);
|
|
69
|
+
// Plain codepoint comparison, not localeCompare — ICU-dependent
|
|
70
|
+
// collation can return 0 for genuinely distinct strings, which a
|
|
71
|
+
// digest sort must never do.
|
|
72
|
+
return ai < bi ? -1 : ai > bi ? 1 : 0;
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return mapped;
|
|
76
|
+
}
|
|
77
|
+
if (value && typeof value === 'object') {
|
|
78
|
+
const out = {};
|
|
79
|
+
for (const k of Object.keys(value).sort()) {
|
|
80
|
+
if (EXCLUDE_KEYS.has(k)) continue;
|
|
81
|
+
out[k] = _canon(value[k], k);
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
return value;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function computeGraphDigest(graph) {
|
|
89
|
+
const canon = _canon(graph ?? {});
|
|
90
|
+
return crypto.createHash('sha256').update(JSON.stringify(canon)).digest('hex');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// --- Filter narrowing rule (the real design decision this module makes) ---
|
|
94
|
+
//
|
|
95
|
+
// `opts.filter` is `{nodeIds, edgeIds}` — the exact shape
|
|
96
|
+
// `frontend/src/lib/focus-controls.js`'s nine graph-traversal functions
|
|
97
|
+
// (showUpstream/showDownstream/showAllPaths/showShortestPath/
|
|
98
|
+
// showExternalPathsOnly/showUnprotectedPathsOnly/showAliases/
|
|
99
|
+
// showDisconnected) and `views/architecture-view.js`'s own
|
|
100
|
+
// `resolveSelection` already produce and consume, confirmed by direct
|
|
101
|
+
// read of `focus-controls.js` — reused verbatim rather than inventing a
|
|
102
|
+
// third selection shape for export.
|
|
103
|
+
//
|
|
104
|
+
// nodes/edges narrow by direct id membership (the brief's own starter
|
|
105
|
+
// code). flows/dataElements need their own rule, decided and grounded
|
|
106
|
+
// here against the real flagship fixture (see export-json.test.js's
|
|
107
|
+
// `filter narrows` tests):
|
|
108
|
+
//
|
|
109
|
+
// FLOW survives iff BOTH (a) its `source` and `sink` node ids are
|
|
110
|
+
// still present in the filtered node set, AND (b) every id in its own
|
|
111
|
+
// `edgeIds[]` is still present in the filtered edge set (a full
|
|
112
|
+
// subset — not "at least one edge survives", not "some overlap").
|
|
113
|
+
//
|
|
114
|
+
// Why not node-membership alone: the real fixture has two flows
|
|
115
|
+
// (flow:f7273b6e7b61, flow:154396169be8) sharing the identical
|
|
116
|
+
// source/sink pair (Web App -> Application Logs, the masked- and
|
|
117
|
+
// raw-log branches) but diverging on their SECOND edge. A filter
|
|
118
|
+
// admitting both endpoint nodes plus only ONE of the two flows' full
|
|
119
|
+
// edge sets must keep the flow whose edges are fully present and drop
|
|
120
|
+
// the other — node-membership alone cannot tell them apart, and would
|
|
121
|
+
// leave the dropped flow's own `edgeIds[]` naming an edge no longer in
|
|
122
|
+
// `graph.edges`, a dangling reference in the exported document.
|
|
123
|
+
// Why not edgeIds-subset alone: a flow's `source`/`sink` fields are
|
|
124
|
+
// themselves references into `graph.nodes`, independent of its edges
|
|
125
|
+
// (an edgeIds-only rule could keep a flow whose own declared source or
|
|
126
|
+
// sink node was filtered out). Requiring both is the only rule that
|
|
127
|
+
// guarantees every remaining flow is fully, referentially resolvable
|
|
128
|
+
// using only what's left in the filtered graph.
|
|
129
|
+
//
|
|
130
|
+
// DATAELEMENTS narrow to the UNION of every id referenced by a
|
|
131
|
+
// surviving node's `dataElementIds[]` and every id referenced by a
|
|
132
|
+
// surviving flow's `dataElementIds[]` — not either alone. Node-only
|
|
133
|
+
// would leave a surviving flow's own `dataElementIds[]` dangling
|
|
134
|
+
// whenever a flow carries a data element none of its endpoint nodes'
|
|
135
|
+
// own list happens to name; flow-only would leave a surviving node's
|
|
136
|
+
// `dataElementIds[]` dangling whenever a filter keeps a node but drops
|
|
137
|
+
// every flow touching it (exactly the single-node filter test below,
|
|
138
|
+
// where zero flows survive but the one kept node still legitimately
|
|
139
|
+
// references three data elements). The union is the only rule that
|
|
140
|
+
// keeps every remaining reference resolvable.
|
|
141
|
+
//
|
|
142
|
+
// `graph.transformations` is deliberately left UNFILTERED — out of
|
|
143
|
+
// scope for this decision (the brief names only flows/dataElements,
|
|
144
|
+
// and `frontend/src/lib/focus-controls.js` has no transformation-aware
|
|
145
|
+
// selection to mirror). An unfiltered transformation can end up
|
|
146
|
+
// referenced by nothing remaining (orphaned, not dangling — the
|
|
147
|
+
// opposite direction of the two problems narrowed above), a disclosed,
|
|
148
|
+
// deliberate non-goal of this filter, not an oversight.
|
|
149
|
+
/**
|
|
150
|
+
* `validateFilterShape(filter) -> {valid, error}` — shape-validates an
|
|
151
|
+
* `opts.filter` value BEFORE it ever reaches `_filterGraph`. `_filterGraph`
|
|
152
|
+
* does `new Set(filter.nodeIds ?? [])`, and `new Set("not-an-array")`
|
|
153
|
+
* iterates a string as characters instead of throwing (a real JS
|
|
154
|
+
* foot-gun) — a malformed-but-truthy filter would otherwise silently
|
|
155
|
+
* produce an empty/wrong graph instead of a clear error. `undefined`
|
|
156
|
+
* (no filter at all) and `{}` (an empty, well-formed filter object) BOTH
|
|
157
|
+
* PASS VALIDATION — but they do NOT behave the same downstream. Final
|
|
158
|
+
* whole-branch review finding: `_filterGraph(graph, undefined)` returns
|
|
159
|
+
* the graph unchanged (`if (!filter) return graph;`), while
|
|
160
|
+
* `_filterGraph(graph, {})` narrows `nodeIds`/`edgeIds` to empty Sets and
|
|
161
|
+
* returns an EMPTY node/edge/flow/dataElement result — "valid shape" is
|
|
162
|
+
* not "equivalent meaning." Every caller of `validateFilterShape` must
|
|
163
|
+
* treat a validation pass as "safe to hand to `_filterGraph`," never as
|
|
164
|
+
* "produces the same result as omitting the filter."
|
|
165
|
+
* Extracted from `bin/agentic-security.js`'s own original inline
|
|
166
|
+
* `--filter` validation (verbatim logic, not rewritten) so the CLI,
|
|
167
|
+
* the `explore` server's new `POST /api/v1/query` endpoint, and the
|
|
168
|
+
* `dataflow_get_graph` MCP tool all share the identical protection
|
|
169
|
+
* rather than three independent, potentially-drifting copies.
|
|
170
|
+
*/
|
|
171
|
+
export function validateFilterShape(filter) {
|
|
172
|
+
if (filter === undefined) return { valid: true, error: null };
|
|
173
|
+
if (typeof filter !== 'object' || filter === null || Array.isArray(filter)
|
|
174
|
+
|| (filter.nodeIds !== undefined && !Array.isArray(filter.nodeIds))
|
|
175
|
+
|| (filter.edgeIds !== undefined && !Array.isArray(filter.edgeIds))) {
|
|
176
|
+
// Deliberately starts with "must be", not "filter must be" — the CLI
|
|
177
|
+
// wraps this with its own "--filter file \"X\"" prefix (see
|
|
178
|
+
// bin/agentic-security.js's own call site); the server/MCP call sites
|
|
179
|
+
// use it standalone, where "must be a JSON object..." already reads
|
|
180
|
+
// correctly with no file-path context needed.
|
|
181
|
+
return { valid: false, error: 'must be a JSON object of the form {"nodeIds":[...],"edgeIds":[...]} (both optional, but if present must be arrays)' };
|
|
182
|
+
}
|
|
183
|
+
return { valid: true, error: null };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function _filterGraph(graph, filter) {
|
|
187
|
+
if (!filter) return graph;
|
|
188
|
+
const nodeIds = new Set(filter.nodeIds ?? []);
|
|
189
|
+
const edgeIds = new Set(filter.edgeIds ?? []);
|
|
190
|
+
const nodes = (graph.nodes ?? []).filter((n) => nodeIds.has(n.id));
|
|
191
|
+
const edges = (graph.edges ?? []).filter((e) => edgeIds.has(e.id));
|
|
192
|
+
|
|
193
|
+
const flows = (graph.flows ?? []).filter((f) => {
|
|
194
|
+
if (!nodeIds.has(f.source) || !nodeIds.has(f.sink)) return false;
|
|
195
|
+
return (f.edgeIds ?? []).every((id) => edgeIds.has(id));
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const keptDataElementIds = new Set();
|
|
199
|
+
for (const n of nodes) for (const id of n.dataElementIds ?? []) keptDataElementIds.add(id);
|
|
200
|
+
for (const f of flows) for (const id of f.dataElementIds ?? []) keptDataElementIds.add(id);
|
|
201
|
+
const dataElements = (graph.dataElements ?? []).filter((d) => keptDataElementIds.has(d.id));
|
|
202
|
+
|
|
203
|
+
return { ...graph, nodes, edges, flows, dataElements };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function exportGraphJSON(graph, opts = {}) {
|
|
207
|
+
const redact = opts.redact !== false;
|
|
208
|
+
const filtered = _filterGraph(graph, opts.filter);
|
|
209
|
+
const body = redact ? _redactGraph(filtered) : filtered;
|
|
210
|
+
return {
|
|
211
|
+
exportedAt: new Date().toISOString(),
|
|
212
|
+
schemaVersion: graph?.schemaVersion ?? null,
|
|
213
|
+
// Deliberate: the digest always identifies the SOURCE graph this
|
|
214
|
+
// export was taken from, never the filtered/redacted `graph:` body
|
|
215
|
+
// below — two different filters (or redact:true vs redact:false) of
|
|
216
|
+
// the same scan must report the same digest, since both are views
|
|
217
|
+
// of one underlying scan result, not independently-verifiable
|
|
218
|
+
// artifacts of their own. A caller wanting to verify `graph:`'s own
|
|
219
|
+
// content should re-filter/re-redact the source and compare, not
|
|
220
|
+
// treat this digest as a hash of the returned body.
|
|
221
|
+
digest: computeGraphDigest(graph),
|
|
222
|
+
// A SEPARATE digest of the emitted `graph:` body itself (final
|
|
223
|
+
// whole-branch review, M5 deliverable #8, B1) — this is the field a
|
|
224
|
+
// consumer that received ONLY this exported file (no access to the
|
|
225
|
+
// source graph) must compare against to detect tampering in transit.
|
|
226
|
+
// `digest` above cannot serve that purpose whenever redact/filter
|
|
227
|
+
// changed anything, which is the DEFAULT case (redact is on by
|
|
228
|
+
// default) — comparing `digest` against a recomputation over `body`
|
|
229
|
+
// is exactly the bug this field exists to prevent a future consumer
|
|
230
|
+
// from re-introducing.
|
|
231
|
+
bodyDigest: computeGraphDigest(body),
|
|
232
|
+
scope: graph?.scope ?? null,
|
|
233
|
+
coverage: graph?.coverage ?? null,
|
|
234
|
+
limitations: graph?.limitations ?? [],
|
|
235
|
+
confidential: true,
|
|
236
|
+
graph: body,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// export-privacy.js — Milestone 4 deliverable #10 (DFG-020): graph-derived
|
|
2
|
+
// DPIA/RoPA export, migrating off the Layer-2 taint engine's
|
|
3
|
+
// dataflow/privacy-taint.js#emitDpiaArtifact / privacy-governance.js#emitRopaArtifact
|
|
4
|
+
// (which derive from piiFields, name-in-argument classification with no
|
|
5
|
+
// path/alias/field-mapping precision) onto the real DataFlowGraph v1's
|
|
6
|
+
// field-identity-tracked flows.
|
|
7
|
+
//
|
|
8
|
+
// Reuses frontend/src/views/privacy-view.js#computePrivacyViewModel
|
|
9
|
+
// DIRECTLY (the first live scanner/ -> frontend/ module import in this
|
|
10
|
+
// codebase, confirmed safe: that module only touches document/window
|
|
11
|
+
// inside renderPrivacyView's own function body, never at module top
|
|
12
|
+
// level, so importing it from plain Node never executes any DOM code)
|
|
13
|
+
// rather than re-deriving lifecycle-stage/dataClass/protection-verdict
|
|
14
|
+
// row computation a second time. This mirrors this package's own
|
|
15
|
+
// established discipline of reusing an already-correct, already-tested
|
|
16
|
+
// computation rather than a parallel copy that can drift.
|
|
17
|
+
//
|
|
18
|
+
// Both emit functions mirror their taint-engine-era predecessors'
|
|
19
|
+
// structure/wording (GDPR Art. 35 framing for the DPIA, the
|
|
20
|
+
// register-not-narrative RoPA table) but are populated from real graph
|
|
21
|
+
// rows.
|
|
22
|
+
//
|
|
23
|
+
// Filter wiring: opts.filter is the SAME {nodeIds, edgeIds} shape every
|
|
24
|
+
// other `dataflow export` format uses (export-json.js's own convention).
|
|
25
|
+
// It filters the GRAPH itself, via export-json.js's _filterGraph, BEFORE
|
|
26
|
+
// computePrivacyViewModel ever runs — never threaded into
|
|
27
|
+
// computePrivacyViewModel's own state.filters parameter, which expects a
|
|
28
|
+
// completely different shape (per-facet selected-value arrays, e.g.
|
|
29
|
+
// {dataClass: [...]}) and would silently no-op if handed {nodeIds, edgeIds}.
|
|
30
|
+
|
|
31
|
+
import { computePrivacyViewModel } from '../../../frontend/src/views/privacy-view.js';
|
|
32
|
+
import { GOVERNANCE_FIELDS, MANUAL_REQUIRED } from '../dataflow/privacy-governance.js';
|
|
33
|
+
import { _filterGraph } from './export-json.js';
|
|
34
|
+
|
|
35
|
+
function _emptyState() {
|
|
36
|
+
return { selectedId: null };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Collapse embedded newlines to spaces. Every value interpolated into a
|
|
41
|
+
* Markdown line below can originate from operator-supplied prose (a
|
|
42
|
+
* governance field from .agentic-security/privacy-governance.json) or
|
|
43
|
+
* from source-derived identifiers — neither is trusted not to contain a
|
|
44
|
+
* literal newline, which would otherwise let the value break out of its
|
|
45
|
+
* line and inject arbitrary Markdown (e.g. a fake heading) mid-document.
|
|
46
|
+
*/
|
|
47
|
+
function _mdInline(value) {
|
|
48
|
+
return String(value).replace(/\r\n|\r|\n/g, ' ');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** _mdInline, plus pipe-escaping for a Markdown table cell — an
|
|
52
|
+
* unescaped `|` in a cell value shifts every later column in that row.
|
|
53
|
+
* Backslashes are escaped FIRST: a value already containing a literal
|
|
54
|
+
* `\|` (e.g. a Windows path fragment, a regex snippet) would otherwise
|
|
55
|
+
* become `\\|` — in Markdown that reads as an escaped backslash followed
|
|
56
|
+
* by a still-live, still-unescaped `|` column delimiter. */
|
|
57
|
+
function _mdCell(value) {
|
|
58
|
+
return _mdInline(value).replace(/\\/g, '\\\\').replace(/\|/g, '\\|');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Wrap a value in a Markdown inline-code span, safe even when the value
|
|
62
|
+
* itself contains backticks. CommonMark's own general rule: the fence
|
|
63
|
+
* must be one backtick longer than the longest run of consecutive
|
|
64
|
+
* backticks anywhere in the content, padded with a space on each side —
|
|
65
|
+
* a fixed double-backtick fence (this function's own earlier version)
|
|
66
|
+
* is not safe against a value containing 2+ CONSECUTIVE backticks, since
|
|
67
|
+
* the value's own run would then close the span early. */
|
|
68
|
+
function _mdCode(value) {
|
|
69
|
+
const s = _mdInline(value);
|
|
70
|
+
const runs = s.match(/`+/g);
|
|
71
|
+
const maxRun = runs ? Math.max(...runs.map((r) => r.length)) : 0;
|
|
72
|
+
if (maxRun === 0) return `\`${s}\``;
|
|
73
|
+
const fence = '`'.repeat(maxRun + 1);
|
|
74
|
+
return `${fence} ${s} ${fence}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function _scopedViewModel(graph, filter) {
|
|
78
|
+
const scopedGraph = filter ? _filterGraph(graph, filter) : graph;
|
|
79
|
+
return computePrivacyViewModel(scopedGraph, _emptyState(), null);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Group computePrivacyViewModel's rows by data class, mirroring
|
|
84
|
+
* emitDpiaArtifact's own grouping.
|
|
85
|
+
*
|
|
86
|
+
* Task-2 review finding (non-blocking, fixed): the old taint-engine
|
|
87
|
+
* emitDpiaArtifact's identical grouping code was safe only because its
|
|
88
|
+
* input (piiFields) was pre-filtered to classified fields by
|
|
89
|
+
* construction — it never saw an unclassified field at all. This
|
|
90
|
+
* function draws from EVERY graph flow via computePrivacyViewModel, so a
|
|
91
|
+
* bare `for (const cls of row.dataClasses)` silently drops any flow
|
|
92
|
+
* whose dataClasses is [] from the whole document — not merely
|
|
93
|
+
* ungrouped, genuinely INVISIBLE, with no count, no mention, nothing —
|
|
94
|
+
* for a document whose entire purpose is a complete inventory. RoPA's
|
|
95
|
+
* own sibling loop already falls back to a '(unclassified)' bucket
|
|
96
|
+
* (mirroring the same sentinel resolveGovernanceRefs's default hook
|
|
97
|
+
* uses in coverage.js, Task 1's own fix round); mirrored here so the two
|
|
98
|
+
* artifacts never disagree about how many real flows exist in scope.
|
|
99
|
+
*/
|
|
100
|
+
function _groupRowsByClass(rows) {
|
|
101
|
+
const grouped = new Map();
|
|
102
|
+
for (const row of rows) {
|
|
103
|
+
const classes = row.dataClasses.length ? row.dataClasses : ['(unclassified)'];
|
|
104
|
+
for (const cls of classes) {
|
|
105
|
+
let g = grouped.get(cls);
|
|
106
|
+
if (!g) { g = []; grouped.set(cls, g); }
|
|
107
|
+
g.push(row);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return grouped;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Graph-derived DPIA (Data Protection Impact Assessment) — narrative
|
|
115
|
+
* Markdown, mirroring dataflow/privacy-taint.js#emitDpiaArtifact's own
|
|
116
|
+
* structure. opts.filter scopes to a subset of the graph (the "filtered
|
|
117
|
+
* ... from the selected graph scope" requirement, PRD 7.4).
|
|
118
|
+
*/
|
|
119
|
+
export function emitGraphDpiaArtifact(graph, opts = {}) {
|
|
120
|
+
const viewModel = _scopedViewModel(graph, opts.filter);
|
|
121
|
+
const visibleRows = viewModel.rows.filter((r) => r.visible !== false);
|
|
122
|
+
const grouped = _groupRowsByClass(visibleRows);
|
|
123
|
+
|
|
124
|
+
const lines = [];
|
|
125
|
+
lines.push('# Data Protection Impact Assessment (DPIA)');
|
|
126
|
+
lines.push('');
|
|
127
|
+
lines.push(`Generated by agentic-security scanner (graph-derived) on ${(opts.generatedAt ?? new Date().toISOString()).slice(0, 10)}.`);
|
|
128
|
+
lines.push('');
|
|
129
|
+
lines.push('This is an automated DPIA scaffold derived from real data-flow analysis');
|
|
130
|
+
lines.push('(field identity, path provenance, and protection verdicts). It must be');
|
|
131
|
+
lines.push('reviewed and completed by a privacy officer before use.');
|
|
132
|
+
lines.push('');
|
|
133
|
+
lines.push('Governance fields reflect .agentic-security/privacy-governance.json as');
|
|
134
|
+
lines.push('of the scan that produced this graph, not as of the moment this document');
|
|
135
|
+
lines.push('was exported — re-scan (with lineage deep mode enabled) after editing');
|
|
136
|
+
lines.push('that config, then re-export, to pick up the change.');
|
|
137
|
+
lines.push('');
|
|
138
|
+
if (grouped.size === 0) {
|
|
139
|
+
lines.push('No regulated data classes were identified in this graph scope.');
|
|
140
|
+
return lines.join('\n');
|
|
141
|
+
}
|
|
142
|
+
lines.push('## Data classes identified');
|
|
143
|
+
lines.push('');
|
|
144
|
+
for (const [cls, rows] of grouped) {
|
|
145
|
+
lines.push(`### ${_mdInline(cls)} (${rows.length} flow(s))`);
|
|
146
|
+
lines.push('');
|
|
147
|
+
for (const row of rows.slice(0, 20)) {
|
|
148
|
+
const crossClassNote = row.dataClasses.length > 1 ? ` (also: ${row.dataClasses.filter((c) => c !== cls).map(_mdInline).join(', ')})` : '';
|
|
149
|
+
lines.push(`- ${_mdCode(row.dataElementName)} — ${_mdInline(row.sourceCategory ?? 'unknown source')} -> ${_mdInline(row.sinkCategory ?? 'unknown sink')} (protection: ${_mdInline(row.protectionSummary)})${crossClassNote}`);
|
|
150
|
+
}
|
|
151
|
+
if (rows.length > 20) lines.push(`- ... and ${rows.length - 20} more`);
|
|
152
|
+
lines.push('');
|
|
153
|
+
lines.push(`**Governance fields for ${_mdInline(cls)}:**`);
|
|
154
|
+
lines.push('');
|
|
155
|
+
// Worst-case-wins across this class's own rows, the same tie-break
|
|
156
|
+
// Task 1's resolveGovernanceRefs default uses — never silently pick
|
|
157
|
+
// whichever row happened to be iterated last.
|
|
158
|
+
const merged = {};
|
|
159
|
+
for (const row of rows) {
|
|
160
|
+
for (const field of GOVERNANCE_FIELDS) {
|
|
161
|
+
const r = row.governanceRefs?.[field];
|
|
162
|
+
if (!r) continue;
|
|
163
|
+
if (!merged[field] || merged[field].source === 'manual_required') merged[field] = r;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// A flow with >1 dataClasses already carries a single governanceRefs
|
|
167
|
+
// record merged (worst-case-wins) across ALL of its own classes back
|
|
168
|
+
// at mint time (coverage.js#resolveGovernanceRefs) — this section can
|
|
169
|
+
// only re-merge that already-merged record across rows, never recover
|
|
170
|
+
// per-class distinctness that was lost earlier. Disclose it rather
|
|
171
|
+
// than silently presenting a cross-class value as if it were specific
|
|
172
|
+
// to this one class.
|
|
173
|
+
if (rows.some((row) => row.dataClasses.length > 1)) {
|
|
174
|
+
lines.push(`_Note: some flows in this section also belong to other data classes; the governance values below are each flow's own record merged across ALL of its classes, not verified as specific to ${_mdInline(cls)} alone._`);
|
|
175
|
+
lines.push('');
|
|
176
|
+
}
|
|
177
|
+
for (const field of GOVERNANCE_FIELDS) {
|
|
178
|
+
const r = merged[field] ?? { value: MANUAL_REQUIRED, source: 'manual_required' };
|
|
179
|
+
lines.push(`- ${field}: ${_mdCode(r.value)}${r.source === 'operator_provided' ? ' (operator-provided)' : ''}`);
|
|
180
|
+
}
|
|
181
|
+
lines.push('');
|
|
182
|
+
}
|
|
183
|
+
lines.push('## Regulatory framework mapping');
|
|
184
|
+
lines.push('');
|
|
185
|
+
lines.push('- **GDPR Art. 35** — DPIA required when processing is likely to result in high risk to data subjects.');
|
|
186
|
+
lines.push('- **CCPA 1798.130** — Notice + access rights for collected personal information.');
|
|
187
|
+
if (grouped.has('PHI')) lines.push('- **HIPAA 164.308** — Administrative safeguards for ePHI access.');
|
|
188
|
+
if (grouped.has('PCI')) lines.push('- **PCI DSS Req. 3** — Protect stored cardholder data.');
|
|
189
|
+
lines.push('');
|
|
190
|
+
lines.push('This document organizes automated technical assessment evidence. It does');
|
|
191
|
+
lines.push("not certify compliance and is not a legal determination — see the graph's");
|
|
192
|
+
lines.push('own `disclaimer`/`limitations` fields for the full caveat.');
|
|
193
|
+
return lines.join('\n');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Graph-derived RoPA (Record of Processing Activities, GDPR Art. 30) —
|
|
198
|
+
* tabular Markdown register, mirroring
|
|
199
|
+
* dataflow/privacy-governance.js#emitRopaArtifact's own format, but ONE
|
|
200
|
+
* ROW PER (dataClass x flow) rather than per dataClass alone — a real,
|
|
201
|
+
* disclosed precision improvement the graph's own per-flow sink/
|
|
202
|
+
* destination resolution makes possible (the taint-engine version has no
|
|
203
|
+
* per-flow destination-resolution concept at all).
|
|
204
|
+
*/
|
|
205
|
+
export function emitGraphRopaArtifact(graph, opts = {}) {
|
|
206
|
+
const viewModel = _scopedViewModel(graph, opts.filter);
|
|
207
|
+
const visibleRows = viewModel.rows.filter((r) => r.visible !== false);
|
|
208
|
+
|
|
209
|
+
const lines = [];
|
|
210
|
+
lines.push('# Record of Processing Activities (RoPA) — GDPR Art. 30, graph-derived');
|
|
211
|
+
lines.push('');
|
|
212
|
+
lines.push(`Generated by agentic-security scanner (graph-derived) on ${(opts.generatedAt ?? new Date().toISOString()).slice(0, 10)}. Every governance`);
|
|
213
|
+
lines.push('field below is either supplied by an operator');
|
|
214
|
+
lines.push('(.agentic-security/privacy-governance.json) or marked');
|
|
215
|
+
lines.push(`\`${MANUAL_REQUIRED}\` — none are inferable from source code, and none are`);
|
|
216
|
+
lines.push(`guessed. A privacy officer must fill in every \`${MANUAL_REQUIRED}\` cell`);
|
|
217
|
+
lines.push('before this document is usable as a real RoPA.');
|
|
218
|
+
lines.push('');
|
|
219
|
+
lines.push('Governance fields reflect .agentic-security/privacy-governance.json as');
|
|
220
|
+
lines.push('of the scan that produced this graph, not as of the moment this document');
|
|
221
|
+
lines.push('was exported — re-scan (with lineage deep mode enabled) after editing');
|
|
222
|
+
lines.push('that config, then re-export, to pick up the change.');
|
|
223
|
+
lines.push('');
|
|
224
|
+
if (visibleRows.length === 0) {
|
|
225
|
+
lines.push('No regulated data flows were identified in this graph scope.');
|
|
226
|
+
return lines.join('\n');
|
|
227
|
+
}
|
|
228
|
+
const header = ['Data class', 'Field', 'Source', 'Destination', 'Protection', ...GOVERNANCE_FIELDS];
|
|
229
|
+
lines.push(`| ${header.join(' | ')} |`);
|
|
230
|
+
lines.push(`|${header.map(() => '---').join('|')}|`);
|
|
231
|
+
let gapCount = 0;
|
|
232
|
+
let rowCount = 0;
|
|
233
|
+
let crossClassRows = false;
|
|
234
|
+
for (const row of visibleRows) {
|
|
235
|
+
for (const cls of row.dataClasses.length ? row.dataClasses : ['(unclassified)']) {
|
|
236
|
+
rowCount++;
|
|
237
|
+
const fieldValues = GOVERNANCE_FIELDS.map((f) => {
|
|
238
|
+
const r = row.governanceRefs?.[f] ?? { value: MANUAL_REQUIRED, source: 'manual_required' };
|
|
239
|
+
if (r.source === 'manual_required') gapCount++;
|
|
240
|
+
return r.value;
|
|
241
|
+
});
|
|
242
|
+
const isCrossClass = row.dataClasses.length > 1;
|
|
243
|
+
if (isCrossClass) crossClassRows = true;
|
|
244
|
+
const clsCell = isCrossClass ? `${cls}*` : cls;
|
|
245
|
+
const cells = [clsCell, row.dataElementName, row.sourceCategory ?? 'unknown source', row.sinkCategory ?? 'unknown destination', row.protectionSummary, ...fieldValues];
|
|
246
|
+
lines.push(`| ${cells.map(_mdCell).join(' | ')} |`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
lines.push('');
|
|
250
|
+
if (crossClassRows) {
|
|
251
|
+
lines.push('\\* This flow also belongs to other data classes; governance fields are');
|
|
252
|
+
lines.push("this flow's own record merged across ALL of its classes, not verified as");
|
|
253
|
+
lines.push('specific to the data class shown in this row alone.');
|
|
254
|
+
lines.push('');
|
|
255
|
+
}
|
|
256
|
+
lines.push(`${gapCount} field(s) across ${rowCount} row(s) (${visibleRows.length} flow(s)) require manual input.`);
|
|
257
|
+
return lines.join('\n');
|
|
258
|
+
}
|