@clear-capabilities/agentic-security-scanner 0.144.0 → 0.147.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (209) hide show
  1. package/CHANGELOG.md +390 -0
  2. package/bin/agentic-security.js +3813 -83
  3. package/dist/1122.index.js +702 -0
  4. package/dist/{301.index.js → 1301.index.js} +2 -2
  5. package/dist/1379.index.js +591 -0
  6. package/dist/{444.index.js → 1444.index.js} +13 -4
  7. package/dist/{660.index.js → 1660.index.js} +2 -2
  8. package/dist/{700.index.js → 1700.index.js} +2 -2
  9. package/dist/{905.index.js → 1905.index.js} +3 -3
  10. package/dist/{920.index.js → 1920.index.js} +3 -3
  11. package/dist/{238.index.js → 2238.index.js} +3 -3
  12. package/dist/2271.index.js +165 -0
  13. package/dist/{985.index.js → 2376.index.js} +1260 -340
  14. package/dist/2432.index.js +793 -0
  15. package/dist/2659.index.js +93 -0
  16. package/dist/{826.index.js → 2826.index.js} +2 -2
  17. package/dist/{830.index.js → 2830.index.js} +2 -2
  18. package/dist/2923.index.js +298 -0
  19. package/dist/{1.index.js → 3001.index.js} +5 -5
  20. package/dist/{117.index.js → 3117.index.js} +3 -3
  21. package/dist/3180.index.js +307 -0
  22. package/dist/3276.index.js +117 -0
  23. package/dist/{415.index.js → 3415.index.js} +2 -2
  24. package/dist/{499.index.js → 3499.index.js} +2 -2
  25. package/dist/3518.index.js +450 -0
  26. package/dist/{526.index.js → 3526.index.js} +14 -6
  27. package/dist/{736.index.js → 3736.index.js} +4 -4
  28. package/dist/{839.index.js → 3839.index.js} +4 -4
  29. package/dist/{113.index.js → 4113.index.js} +14 -6
  30. package/dist/{265.index.js → 4265.index.js} +2 -2
  31. package/dist/{384.index.js → 4384.index.js} +3 -3
  32. package/dist/4547.index.js +268 -0
  33. package/dist/4863.index.js +422 -0
  34. package/dist/{970.index.js → 4970.index.js} +67 -3
  35. package/dist/5051.index.js +770 -0
  36. package/dist/{144.index.js → 5144.index.js} +5 -5
  37. package/dist/{333.index.js → 5333.index.js} +3 -3
  38. package/dist/5343.index.js +185 -0
  39. package/dist/5350.index.js +866 -0
  40. package/dist/5561.index.js +436 -0
  41. package/dist/{637.index.js → 5637.index.js} +29 -7
  42. package/dist/{449.index.js → 5830.index.js} +78 -14
  43. package/dist/6626.index.js +532 -0
  44. package/dist/6662.index.js +297 -0
  45. package/dist/{675.index.js → 6675.index.js} +5 -5
  46. package/dist/{730.index.js → 6730.index.js} +6 -6
  47. package/dist/6829.index.js +225 -0
  48. package/dist/6944.index.js +130 -0
  49. package/dist/{178.index.js → 7178.index.js} +26 -8
  50. package/dist/{227.index.js → 7227.index.js} +2 -2
  51. package/dist/7310.index.js +520 -0
  52. package/dist/{552.index.js → 7552.index.js} +4 -4
  53. package/dist/7709.index.js +78 -0
  54. package/dist/8218.index.js +160 -0
  55. package/dist/{476.index.js → 8476.index.js} +4 -4
  56. package/dist/{513.index.js → 8513.index.js} +5 -5
  57. package/dist/{520.index.js → 8520.index.js} +2 -2
  58. package/dist/{718.index.js → 8718.index.js} +2 -2
  59. package/dist/{752.index.js → 8752.index.js} +2 -2
  60. package/dist/8846.index.js +100 -0
  61. package/dist/{435.index.js → 9091.index.js} +580 -187
  62. package/dist/{207.index.js → 9207.index.js} +2 -2
  63. package/dist/{220.index.js → 9220.index.js} +2 -2
  64. package/dist/9390.index.js +163 -0
  65. package/dist/{503.index.js → 9503.index.js} +2 -2
  66. package/dist/{801.index.js → 9801.index.js} +2 -2
  67. package/dist/{824.index.js → 9824.index.js} +2 -2
  68. package/dist/agentic-security.mjs +16 -16
  69. package/dist/agentic-security.mjs.sha256 +1 -1
  70. package/dist/compliance-frameworks/hipaa-security-rule.json +3 -2
  71. package/package.json +23 -10
  72. package/src/compare.js +6 -1
  73. package/src/dataflow/CLAUDE.md +2 -2
  74. package/src/dataflow/catalog.js +42 -0
  75. package/src/dataflow/orm-write-catalog.js +175 -0
  76. package/src/engine.js +580 -30
  77. package/src/fix/apply-fix-service.js +1 -0
  78. package/src/history-scan.js +22 -5
  79. package/src/ir/CLAUDE.md +2 -1
  80. package/src/ir/chrome-probe.mjs +150 -0
  81. package/src/ir/parser-js.js +94 -7
  82. package/src/lineage/CLAUDE.md +1203 -0
  83. package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
  84. package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
  85. package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
  86. package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
  87. package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
  88. package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
  89. package/src/lineage/DESIGN_REGISTRIES.md +880 -0
  90. package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
  91. package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
  92. package/src/lineage/classification.js +56 -0
  93. package/src/lineage/coverage.js +658 -0
  94. package/src/lineage/cross-repo-link.js +107 -0
  95. package/src/lineage/dataflow-graph.schema.json +184 -0
  96. package/src/lineage/decision-story.js +206 -0
  97. package/src/lineage/drift-policy.js +279 -0
  98. package/src/lineage/driver.js +135 -0
  99. package/src/lineage/engine.js +992 -0
  100. package/src/lineage/export-briefing.js +628 -0
  101. package/src/lineage/export-csv.js +62 -0
  102. package/src/lineage/export-json.js +238 -0
  103. package/src/lineage/export-privacy.js +258 -0
  104. package/src/lineage/federation-loader.js +111 -0
  105. package/src/lineage/field-identity.js +78 -0
  106. package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
  107. package/src/lineage/fixtures/flagship-graph.json +1453 -0
  108. package/src/lineage/flow-grade.js +221 -0
  109. package/src/lineage/governance-edit.js +169 -0
  110. package/src/lineage/graph-builder.js +1114 -0
  111. package/src/lineage/graph-diff.js +431 -0
  112. package/src/lineage/graph-snapshot.js +180 -0
  113. package/src/lineage/handling-analyzer.js +168 -0
  114. package/src/lineage/ids.js +349 -0
  115. package/src/lineage/impact-assessment.js +76 -0
  116. package/src/lineage/impact-engine.js +268 -0
  117. package/src/lineage/index.js +281 -0
  118. package/src/lineage/language-coverage-tiers.js +58 -0
  119. package/src/lineage/obligation-mapping.js +126 -0
  120. package/src/lineage/obligation-predicates.js +235 -0
  121. package/src/lineage/observation-adapters.js +282 -0
  122. package/src/lineage/observation-correlation.js +622 -0
  123. package/src/lineage/observation-store.js +497 -0
  124. package/src/lineage/path-query.js +410 -0
  125. package/src/lineage/path-store.js +400 -0
  126. package/src/lineage/protection.js +53 -0
  127. package/src/lineage/recipient-profile.js +192 -0
  128. package/src/lineage/recipient-registry.js +394 -0
  129. package/src/lineage/redact-graph.js +224 -0
  130. package/src/lineage/remediation.js +417 -0
  131. package/src/lineage/resolve-destination.js +91 -0
  132. package/src/lineage/runtime-observation.js +464 -0
  133. package/src/lineage/scenario-diff.js +84 -0
  134. package/src/lineage/scenario-engine.js +251 -0
  135. package/src/lineage/scenario.js +101 -0
  136. package/src/lineage/schema.js +167 -0
  137. package/src/lineage/sink-registry.js +427 -0
  138. package/src/lineage/source-registry.js +357 -0
  139. package/src/lineage/source-seeding.js +212 -0
  140. package/src/lineage/summaries.js +590 -0
  141. package/src/lineage/transform-catalog.js +397 -0
  142. package/src/lineage/transit-protection.js +150 -0
  143. package/src/lineage/validate.js +285 -0
  144. package/src/lsp/server.js +49 -2
  145. package/src/mcp/CLAUDE.md +7 -1
  146. package/src/mcp/dataflow-tools.js +160 -0
  147. package/src/mcp/server.js +1 -1
  148. package/src/mcp/tools.js +22 -1
  149. package/src/pipeline/assurance-mode.js +64 -1
  150. package/src/pipeline/finding-schema.js +8 -1
  151. package/src/pipeline/scan-health.js +19 -1
  152. package/src/posture/CLAUDE.md +140 -0
  153. package/src/posture/accuracy-scorecard.js +60 -0
  154. package/src/posture/artifact-registry.js +76 -0
  155. package/src/posture/auditor-walkthrough.js +192 -13
  156. package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
  157. package/src/posture/compliance-policy.js +12 -2
  158. package/src/posture/cross-repo-memory.js +7 -2
  159. package/src/posture/fix-history.js +25 -2
  160. package/src/posture/fix-verify.js +9 -1
  161. package/src/posture/fleet.js +0 -0
  162. package/src/posture/git-history.js +13 -5
  163. package/src/posture/material-change.js +21 -2
  164. package/src/posture/mttr.js +75 -12
  165. package/src/posture/obligation-evidence-pack.js +202 -0
  166. package/src/posture/pre-incident-archaeology.js +39 -7
  167. package/src/posture/privacy-framework.js +14 -0
  168. package/src/posture/provenance/ai-authorship.js +68 -0
  169. package/src/posture/provenance/branch-entry.js +80 -0
  170. package/src/posture/provenance/cache.js +143 -0
  171. package/src/posture/provenance/confidence.js +36 -0
  172. package/src/posture/provenance/coordinator.js +786 -0
  173. package/src/posture/provenance/dag-walk.js +249 -0
  174. package/src/posture/provenance/evidence-attribution.js +59 -0
  175. package/src/posture/provenance/git-evidence.js +310 -0
  176. package/src/posture/provenance/lifecycle.js +208 -0
  177. package/src/posture/provenance/missing-control-resolver.js +137 -0
  178. package/src/posture/provenance/origin-resolver.js +342 -0
  179. package/src/posture/provenance/predicate-replay.js +133 -0
  180. package/src/posture/provenance/providers/config.js +39 -0
  181. package/src/posture/provenance/providers/github.js +62 -0
  182. package/src/posture/provenance/providers/gitlab.js +58 -0
  183. package/src/posture/provenance/repo-lineage.js +74 -0
  184. package/src/posture/provenance/sca-origin.js +139 -0
  185. package/src/posture/provenance/schema.js +255 -0
  186. package/src/posture/provenance/transitive-sca.js +147 -0
  187. package/src/posture/provenance/validate.js +30 -0
  188. package/src/posture/provenance-evidence-bundle.js +144 -0
  189. package/src/posture/remediation-ledger.js +337 -0
  190. package/src/posture/sbom-diff.js +15 -2
  191. package/src/posture/secret-history.js +10 -2
  192. package/src/posture/state-dir.js +38 -14
  193. package/src/posture/vuln-archaeology.js +8 -2
  194. package/src/pr-delta.js +25 -4
  195. package/src/report/index.js +197 -3
  196. package/src/runScan.js +34 -5
  197. package/src/sast/rate-limit.js +33 -3
  198. package/src/server/CLAUDE.md +47 -0
  199. package/src/server/graph-loader.js +141 -0
  200. package/src/server/http-server.js +325 -0
  201. package/src/server/routes.js +129 -0
  202. package/src/server/security.js +111 -0
  203. package/src/server/static-assets.js +139 -0
  204. package/src/util/git-hardening.js +128 -0
  205. package/dist/11.index.js +0 -353
  206. package/dist/259.index.js +0 -975
  207. package/dist/317.index.js +0 -300
  208. package/dist/609.index.js +0 -741
  209. 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
+ }