@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.
- package/CHANGELOG.md +390 -0
- package/bin/agentic-security.js +3813 -83
- package/dist/1122.index.js +702 -0
- package/dist/{301.index.js → 1301.index.js} +2 -2
- package/dist/1379.index.js +591 -0
- package/dist/{444.index.js → 1444.index.js} +13 -4
- 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/2271.index.js +165 -0
- package/dist/{985.index.js → 2376.index.js} +1260 -340
- package/dist/2432.index.js +793 -0
- package/dist/2659.index.js +93 -0
- package/dist/{826.index.js → 2826.index.js} +2 -2
- package/dist/{830.index.js → 2830.index.js} +2 -2
- package/dist/2923.index.js +298 -0
- package/dist/{1.index.js → 3001.index.js} +5 -5
- package/dist/{117.index.js → 3117.index.js} +3 -3
- package/dist/3180.index.js +307 -0
- package/dist/3276.index.js +117 -0
- package/dist/{415.index.js → 3415.index.js} +2 -2
- package/dist/{499.index.js → 3499.index.js} +2 -2
- package/dist/3518.index.js +450 -0
- package/dist/{526.index.js → 3526.index.js} +14 -6
- 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} +14 -6
- 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} +67 -3
- package/dist/5051.index.js +770 -0
- package/dist/{144.index.js → 5144.index.js} +5 -5
- package/dist/{333.index.js → 5333.index.js} +3 -3
- package/dist/5343.index.js +185 -0
- package/dist/5350.index.js +866 -0
- package/dist/5561.index.js +436 -0
- package/dist/{637.index.js → 5637.index.js} +29 -7
- package/dist/{449.index.js → 5830.index.js} +78 -14
- 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} +26 -8
- 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} +580 -187
- package/dist/{207.index.js → 9207.index.js} +2 -2
- package/dist/{220.index.js → 9220.index.js} +2 -2
- package/dist/9390.index.js +163 -0
- package/dist/{503.index.js → 9503.index.js} +2 -2
- package/dist/{801.index.js → 9801.index.js} +2 -2
- package/dist/{824.index.js → 9824.index.js} +2 -2
- package/dist/agentic-security.mjs +16 -16
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/compliance-frameworks/hipaa-security-rule.json +3 -2
- package/package.json +23 -10
- package/src/compare.js +6 -1
- package/src/dataflow/CLAUDE.md +2 -2
- package/src/dataflow/catalog.js +42 -0
- package/src/dataflow/orm-write-catalog.js +175 -0
- package/src/engine.js +580 -30
- package/src/fix/apply-fix-service.js +1 -0
- package/src/history-scan.js +22 -5
- package/src/ir/CLAUDE.md +2 -1
- 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/lsp/server.js +49 -2
- 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 +22 -1
- package/src/pipeline/assurance-mode.js +64 -1
- package/src/pipeline/finding-schema.js +8 -1
- package/src/pipeline/scan-health.js +19 -1
- package/src/posture/CLAUDE.md +140 -0
- package/src/posture/accuracy-scorecard.js +60 -0
- package/src/posture/artifact-registry.js +76 -0
- package/src/posture/auditor-walkthrough.js +192 -13
- package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
- package/src/posture/compliance-policy.js +12 -2
- package/src/posture/cross-repo-memory.js +7 -2
- package/src/posture/fix-history.js +25 -2
- package/src/posture/fix-verify.js +9 -1
- package/src/posture/fleet.js +0 -0
- package/src/posture/git-history.js +13 -5
- package/src/posture/material-change.js +21 -2
- package/src/posture/mttr.js +75 -12
- package/src/posture/obligation-evidence-pack.js +202 -0
- package/src/posture/pre-incident-archaeology.js +39 -7
- package/src/posture/privacy-framework.js +14 -0
- package/src/posture/provenance/ai-authorship.js +68 -0
- package/src/posture/provenance/branch-entry.js +80 -0
- package/src/posture/provenance/cache.js +143 -0
- package/src/posture/provenance/confidence.js +36 -0
- package/src/posture/provenance/coordinator.js +786 -0
- package/src/posture/provenance/dag-walk.js +249 -0
- package/src/posture/provenance/evidence-attribution.js +59 -0
- package/src/posture/provenance/git-evidence.js +310 -0
- package/src/posture/provenance/lifecycle.js +208 -0
- package/src/posture/provenance/missing-control-resolver.js +137 -0
- package/src/posture/provenance/origin-resolver.js +342 -0
- package/src/posture/provenance/predicate-replay.js +133 -0
- package/src/posture/provenance/providers/config.js +39 -0
- package/src/posture/provenance/providers/github.js +62 -0
- package/src/posture/provenance/providers/gitlab.js +58 -0
- package/src/posture/provenance/repo-lineage.js +74 -0
- package/src/posture/provenance/sca-origin.js +139 -0
- package/src/posture/provenance/schema.js +255 -0
- package/src/posture/provenance/transitive-sca.js +147 -0
- package/src/posture/provenance/validate.js +30 -0
- package/src/posture/provenance-evidence-bundle.js +144 -0
- package/src/posture/remediation-ledger.js +337 -0
- package/src/posture/sbom-diff.js +15 -2
- package/src/posture/secret-history.js +10 -2
- package/src/posture/state-dir.js +38 -14
- package/src/posture/vuln-archaeology.js +8 -2
- package/src/pr-delta.js +25 -4
- package/src/report/index.js +197 -3
- package/src/runScan.js +34 -5
- package/src/sast/rate-limit.js +33 -3
- package/src/server/CLAUDE.md +47 -0
- package/src/server/graph-loader.js +141 -0
- package/src/server/http-server.js +325 -0
- package/src/server/routes.js +129 -0
- package/src/server/security.js +111 -0
- package/src/server/static-assets.js +139 -0
- package/src/util/git-hardening.js +128 -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,1114 @@
|
|
|
1
|
+
//
|
|
2
|
+
// graph-builder.js — Sub-project E, increment 3 (E3).
|
|
3
|
+
//
|
|
4
|
+
// Extracts the already-designed-and-proven graph-projection mechanism out of
|
|
5
|
+
// `DESIGN_GRAPH_BUILDER.md` §4-§8 (binding ADR) and its throwaway
|
|
6
|
+
// proof-of-concept (`test/lineage/graph-builder-poc.test.js`'s
|
|
7
|
+
// `receiverJustified`/`resolveSinkAtCallSite`/`enumerateSinkSites`/
|
|
8
|
+
// `degradedTerminals`/`calleeDescriptor`/`buildDataFlowGraph`, `E1/6`-`E1/13`)
|
|
9
|
+
// into a real, permanent, shipped module. This produces, for the first time
|
|
10
|
+
// in this codebase, a real, `validateGraph()`-clean `DataFlowGraph v1`
|
|
11
|
+
// document from an actual repository's real code.
|
|
12
|
+
//
|
|
13
|
+
// This is mechanical porting of an already-reviewed design, not a redesign
|
|
14
|
+
// — see `DESIGN_GRAPH_BUILDER.md` §4-§9.3 in full for the binding rules this
|
|
15
|
+
// file implements, and its own header for why the PoC (not this document)
|
|
16
|
+
// is authoritative if the two ever disagree.
|
|
17
|
+
//
|
|
18
|
+
// **Signature discrepancy, resolved per that stated policy**:
|
|
19
|
+
// `DESIGN_GRAPH_BUILDER.md` §9.3 item 1 originally stated the signature as
|
|
20
|
+
// `buildDataFlowGraph(perFileIR, callGraph, opts)` (three arguments), but
|
|
21
|
+
// the PoC's own shipped, tested implementation is
|
|
22
|
+
// `buildDataFlowGraph(callGraph, opts)` (two arguments) — it never used a
|
|
23
|
+
// separate `perFileIR` parameter, reading everything it needs from
|
|
24
|
+
// `callGraph.functions[*].cfg`. Per §9.1's own policy ("where this document
|
|
25
|
+
// and that PoC disagree, the PoC is right and this document is stale — fix
|
|
26
|
+
// it here, do not fork it"), this module ships the PoC's actual
|
|
27
|
+
// two-argument signature; §9.3 item 1's own prose was corrected to match in
|
|
28
|
+
// the same commit that shipped this file.
|
|
29
|
+
//
|
|
30
|
+
// E4 addition (DESIGN_GRAPH_BUILDER.md §9.4 item 5b): `opts.resolveSiteDecision`
|
|
31
|
+
// is the chosen resolution of that item's open hook-vs-post-processing-pass
|
|
32
|
+
// question. A post-processing pass over the BUILT GRAPH was rejected: FR-203
|
|
33
|
+
// changes a decision's `kind`/`externality`, which are part of a node's own
|
|
34
|
+
// identity discriminator (§6.1) — adjusting them after nodes are already
|
|
35
|
+
// minted would mean re-minting nodes and re-linking every edge/dataElement
|
|
36
|
+
// reference by hand, in effect reimplementing this function a second time.
|
|
37
|
+
// Intercepting the DECISION before minting, once, right after
|
|
38
|
+
// `enumerateSinkSites`, is the only point where an override is both
|
|
39
|
+
// consistent (every later read of `site.decision` sees it) and cheap
|
|
40
|
+
// (one small hook, zero structural changes to the projection below).
|
|
41
|
+
//
|
|
42
|
+
// Milestone 2, Sub-project A, increment 1 addition (FR-202, DESIGN_
|
|
43
|
+
// DESTINATION_RESOLVER.md): `opts.resolveDestination`, a SEPARATE,
|
|
44
|
+
// additive hook applied at the exact same point as `opts.resolveSiteDecision`
|
|
45
|
+
// above, right after it — same reasoning (a node's `destination` field is
|
|
46
|
+
// set once, at mint time, so intercepting before minting is the only
|
|
47
|
+
// consistent point). Composes with `resolveSiteDecision`, never collapses
|
|
48
|
+
// into it: the two hooks answer independent questions (is this sink's
|
|
49
|
+
// CLASSIFICATION resolvable vs. what does its DESTINATION EXPRESSION look
|
|
50
|
+
// like) and a site can carry both an `unresolved` decision and a `dynamic`
|
|
51
|
+
// destination at once.
|
|
52
|
+
//
|
|
53
|
+
// Milestone 2, Sub-project D, increment 1 addition (FR-403, DESIGN_
|
|
54
|
+
// HANDLING_ANALYZER.md): every emitted `flow` gains a `flow.handling`
|
|
55
|
+
// taxonomy label (`classifyHandling(p, callGraph)`, `./handling-analyzer.js`
|
|
56
|
+
// — a new import, mirroring `recognizeTransformation`'s own reuse boundary
|
|
57
|
+
// one line above), computed once inside the `groupsByFlowKey` flow-
|
|
58
|
+
// construction loop below, from that flow's own representative
|
|
59
|
+
// reconstructed `Path`. This is the same attachment-point discipline
|
|
60
|
+
// `emptyProtection()` already established one loop up for `edge.protection`
|
|
61
|
+
// — set once, at mint time, never a post-processing pass. See
|
|
62
|
+
// `DESIGN_HANDLING_ANALYZER.md` for the exact `transform-catalog.js` `kind`
|
|
63
|
+
// -> `HANDLING_VALUES` mapping and why this is NOT the same field as
|
|
64
|
+
// `protection.js`'s own `PROTECTION_DIMENSIONS`' `handling` dimension.
|
|
65
|
+
//
|
|
66
|
+
// Milestone 2, Sub-project B, increment 2 addition (FR-401, DESIGN_
|
|
67
|
+
// TRANSIT_PROTECTION.md §6): `opts.resolveTransitProtection`, a SEPARATE,
|
|
68
|
+
// additive hook applied at the exact same edge-construction point as
|
|
69
|
+
// `opts.resolveDestination` above — composing into the edge's `protection`
|
|
70
|
+
// object (`protection: { ...emptyProtection(), transit: resolved ??
|
|
71
|
+
// emptyProtection().transit }`) rather than replacing it. Writes ONLY
|
|
72
|
+
// `protection.transit`; `.atRest`/`.handling` stay `emptyProtection()`'s
|
|
73
|
+
// own defaults. A no-op when omitted, mirroring every prior additive
|
|
74
|
+
// hook's own "byte-identical when the hook is absent" contract.
|
|
75
|
+
//
|
|
76
|
+
// M5 deliverable #7 (FR-505 §7.12, AC-29, Runtime-Corroborated Digital Twin,
|
|
77
|
+
// "7b" only): `opts.correlateObservations(graph) -> correlationResult |
|
|
78
|
+
// undefined`, a FIFTH additive hook of the identical shape as the four
|
|
79
|
+
// above — applied once every graph array (`nodes`/`edges`/`flows`/
|
|
80
|
+
// `dataElements`) AND `recipientProfiles` are populated (the same
|
|
81
|
+
// placement lesson `opts.buildRecipientProfile`'s own fix round already
|
|
82
|
+
// established: a hook that runs before those arrays exist gets an empty
|
|
83
|
+
// envelope to reason over). Assigned to `graph.runtimeCorroboration` ONLY
|
|
84
|
+
// when the hook returns truthy — the key is genuinely ABSENT, never `null`,
|
|
85
|
+
// when the hook is omitted or returns falsy, which is what keeps the output
|
|
86
|
+
// byte-identical to a build with no hook at all (`M2A1/hook-1`'s own proven
|
|
87
|
+
// contract) and is also AC-29 clause 2's `not_evaluated` state expressed at
|
|
88
|
+
// the top level. See this hook's own inline comment below for the full
|
|
89
|
+
// reasoning, including why it never touches `edge.provenance`.
|
|
90
|
+
//
|
|
91
|
+
//
|
|
92
|
+
// M5 deliverable #8 (FR-304 "declared" half only, §10.10): `opts.
|
|
93
|
+
// crossRepoLinks(graph) -> CrossRepoLink[]`, a SIXTH additive hook of
|
|
94
|
+
// the identical shape — applied once every graph array AND
|
|
95
|
+
// `recipientProfiles` are populated, mirroring `opts.buildRecipientProfile`'s
|
|
96
|
+
// own placement exactly (the hook can validate a declared
|
|
97
|
+
// `local.nodeId` against the CURRENT graph's own real node set).
|
|
98
|
+
// `graph.crossRepoLinks` is always present — mirrors
|
|
99
|
+
// `graph.recipientProfiles[]`'s own "always an array, possibly empty"
|
|
100
|
+
// shape, NOT `graph.runtimeCorroboration`'s own "genuinely absent when
|
|
101
|
+
// the hook is omitted" shape, since a CrossRepoLink array has no
|
|
102
|
+
// `not_evaluated` state to preserve the way runtime corroboration does.
|
|
103
|
+
//
|
|
104
|
+
// Reuse boundary (§12, confirmed against the source): imports ONLY
|
|
105
|
+
// `matchSource`/`matchSinkOrSanitizer` from `../dataflow/catalog.js`,
|
|
106
|
+
// `matchPrivacySink` from `../dataflow/privacy-catalog.js`, and
|
|
107
|
+
// `accessPathOf` from `../dataflow/access-paths.js` — never
|
|
108
|
+
// `dataflow/engine.js`'s live taint state, never `dataflow/summaries.js`'s
|
|
109
|
+
// SummaryCache, never `dataflow/index.js`'s `runDeepAnalysis` (PRD §18.1).
|
|
110
|
+
// This module MIRRORS that shape (an opt-in, best-effort orchestration
|
|
111
|
+
// entry point), it does not wrap it.
|
|
112
|
+
|
|
113
|
+
import { matchSinkOrSanitizer } from '../dataflow/catalog.js';
|
|
114
|
+
import { matchPrivacySink } from '../dataflow/privacy-catalog.js';
|
|
115
|
+
import { matchOrmWrite } from '../dataflow/orm-write-catalog.js';
|
|
116
|
+
// Milestone 2, Sub-project G, increment 1 (FR-408/AC-09): `isSinkPermitted`/
|
|
117
|
+
// `permittingRules` are pure, vocabulary-agnostic functions (no hardcoded
|
|
118
|
+
// sink-name check anywhere in that module — confirmed by direct read) that
|
|
119
|
+
// this module reuses UNMODIFIED, mirroring dataflow/privacy-taint.js's own
|
|
120
|
+
// real usage precedent exactly. See the flow-construction loop below for
|
|
121
|
+
// the actual `flow.policyVerdict` computation.
|
|
122
|
+
import { isSinkPermitted, permittingRules } from '../dataflow/privacy-sink-policy.js';
|
|
123
|
+
|
|
124
|
+
import { runFieldIdentityAnalysis } from './driver.js';
|
|
125
|
+
import { PathStore } from './path-store.js';
|
|
126
|
+
import { reconstructPaths } from './path-query.js';
|
|
127
|
+
import { gradePath, DEGRADED_LOSS_REASONS } from './flow-grade.js';
|
|
128
|
+
import { reclassifySink, reclassifyPrivacySink, reclassifyOrmWrite } from './sink-registry.js';
|
|
129
|
+
import { recognizeTransformation } from './transform-catalog.js';
|
|
130
|
+
import { classifyHandling } from './handling-analyzer.js';
|
|
131
|
+
import { emptyGraphEnvelope } from './schema.js';
|
|
132
|
+
import { emptyProtection, aggregateVerdicts } from './protection.js';
|
|
133
|
+
import * as ids from './ids.js';
|
|
134
|
+
import { planSeeds, seedEntryStateFactory, exprRoots, walkExpr } from './source-seeding.js';
|
|
135
|
+
import { RECIPIENT_FACT_FIELDS } from './recipient-profile.js';
|
|
136
|
+
|
|
137
|
+
// =========================================================================
|
|
138
|
+
// §4.2 / §4.3 — registry-backed sink enumeration, multi-candidate resolution
|
|
139
|
+
// =========================================================================
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* §4.2. Did the matcher accept this candidate BECAUSE of a textual receiver
|
|
143
|
+
* constraint it declared? `receiverTypeIn` deliberately does NOT count: it
|
|
144
|
+
* gates on a CHA-resolved class TYPE and is vacuously allowed whenever no
|
|
145
|
+
* `receiverType` is supplied, which is always, here (D5 measured exactly
|
|
146
|
+
* this trap). Reading a returned candidate's own declared constraint is not
|
|
147
|
+
* re-deriving the match — `matchSinkOrSanitizer` already ran it.
|
|
148
|
+
*/
|
|
149
|
+
function receiverJustified(entry) {
|
|
150
|
+
return Boolean(entry.match && (entry.match.receiver || entry.match.receiverBase));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** §4.3. Multi-candidate sink resolution: represent, never silently pick. */
|
|
154
|
+
function resolveSinkAtCallSite(calleeExpr, file) {
|
|
155
|
+
const general = (matchSinkOrSanitizer(calleeExpr, file, undefined) ?? []).filter((h) => h.kind === 'sink');
|
|
156
|
+
const privacy = matchPrivacySink(calleeExpr, file, undefined) ?? [];
|
|
157
|
+
const cands = [
|
|
158
|
+
...general.map((e) => ({ entry: e, decision: reclassifySink(e) })),
|
|
159
|
+
...privacy.map((e) => ({ entry: e, decision: reclassifyPrivacySink(e) })),
|
|
160
|
+
];
|
|
161
|
+
if (cands.length === 0) return null;
|
|
162
|
+
if (cands.length === 1) return { ...cands[0], ambiguity: null };
|
|
163
|
+
|
|
164
|
+
const justified = cands.filter((c) => receiverJustified(c.entry));
|
|
165
|
+
if (justified.length === 1) {
|
|
166
|
+
return {
|
|
167
|
+
...justified[0],
|
|
168
|
+
ambiguity: { resolvedBy: 'receiver', alternatives: cands.filter((c) => c !== justified[0]).map((c) => c.entry.id).sort() },
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
const pool = justified.length > 1 ? justified : cands;
|
|
172
|
+
const cats = [...new Set(pool.map((c) => c.decision.category))];
|
|
173
|
+
if (cats.length === 1) {
|
|
174
|
+
return { ...pool[0], ambiguity: { resolvedBy: 'unanimous-category', alternatives: pool.slice(1).map((c) => c.entry.id).sort() } };
|
|
175
|
+
}
|
|
176
|
+
const counts = new Map();
|
|
177
|
+
for (const c of pool) counts.set(c.decision.category, (counts.get(c.decision.category) ?? 0) + 1);
|
|
178
|
+
const winner = [...counts.entries()].sort((a, b) => (b[1] - a[1]) || (String(a[0]) < String(b[0]) ? -1 : 1))[0][0];
|
|
179
|
+
const chosen = pool.find((c) => c.decision.category === winner);
|
|
180
|
+
const others = [...counts.keys()].filter((c) => c !== winner).sort();
|
|
181
|
+
return {
|
|
182
|
+
entry: chosen.entry,
|
|
183
|
+
decision: {
|
|
184
|
+
...chosen.decision,
|
|
185
|
+
coverageStatus: 'partial',
|
|
186
|
+
reason: `${chosen.decision.reason}; AMBIGUOUS at this call site: also matches ${others.join(', ')} (candidates: ${pool.map((c) => c.entry.id).sort().join(', ')})`,
|
|
187
|
+
},
|
|
188
|
+
ambiguity: { resolvedBy: 'plurality', alternatives: others },
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Milestone 2, Sub-project E, increment 1 (ORM-write sink recognition,
|
|
194
|
+
* `docs/superpowers/plans/2026-08-31-data-flow-explorer-m2-subproject-e1-plan.md`).
|
|
195
|
+
* A SEPARATE resolver from `resolveSinkAtCallSite` above, deliberately —
|
|
196
|
+
* `ORM_WRITE_CATALOG` is its own isolated catalog (never merged into
|
|
197
|
+
* `CATALOG`/`PRIVACY_SINK_CATALOG`; see `dataflow/orm-write-catalog.js`'s
|
|
198
|
+
* own header) and needs a precision signal `resolveSinkAtCallSite`'s two
|
|
199
|
+
* matchers never do: the call's FIRST ARGUMENT must be an object-literal
|
|
200
|
+
* expression (`kind: 'object'` — confirmed against `parser-js.js`'s real
|
|
201
|
+
* `ObjectExpression` lowering, NOT `resolve-destination.js`'s `isLiteral`
|
|
202
|
+
* helper, which tests for the unrelated `kind: 'literal'`). That check
|
|
203
|
+
* cannot live inside `matchOrmWrite` itself (its signature mirrors
|
|
204
|
+
* `matchPrivacySink`'s and never receives the call's arguments), so it is
|
|
205
|
+
* applied HERE, as a hard exclusion, before a match is returned at all —
|
|
206
|
+
* not a `coverageStatus` downgrade the way `resolveSinkAtCallSite`'s own
|
|
207
|
+
* ambiguity handling works. A call whose first argument is not an object
|
|
208
|
+
* literal (`User.create(req.body)`, a bare identifier) never becomes an
|
|
209
|
+
* ORM-write sink candidate.
|
|
210
|
+
*
|
|
211
|
+
* No multi-candidate ambiguity resolution: `ORM_WRITE_CATALOG`'s entries
|
|
212
|
+
* never share a callee name with each other or with `CATALOG`/
|
|
213
|
+
* `PRIVACY_SINK_CATALOG` (verified directly — no bare `create`/`save`/
|
|
214
|
+
* `update`/`upsert` entry with an unconstrained or capitalized-identifier
|
|
215
|
+
* receiver exists in either catalog today), so `ambiguity` is always
|
|
216
|
+
* `null` here, unlike `resolveSinkAtCallSite`'s return shape.
|
|
217
|
+
*/
|
|
218
|
+
/**
|
|
219
|
+
* Milestone 2, Sub-project E, increment 2 (`node.storeDetail.operation`,
|
|
220
|
+
* DESIGN_STORE_DETAIL.md §4). Mapping logic, not an enum — lives here, not
|
|
221
|
+
* `schema.js`, per this package's own established separation. `save` maps
|
|
222
|
+
* to `'upsert'`, NOT `'create'` — a deliberate, disclosed judgment call:
|
|
223
|
+
* Mongoose's `.save()` performs an INSERT on a new document but an UPDATE
|
|
224
|
+
* on one loaded from the database, which is genuinely undecidable
|
|
225
|
+
* statically from the call site alone; `'upsert'` is the honest umbrella
|
|
226
|
+
* covering both, not a guess at which one it is.
|
|
227
|
+
*/
|
|
228
|
+
const ORM_OPERATION_MAP = Object.freeze({
|
|
229
|
+
create: 'create',
|
|
230
|
+
update: 'update',
|
|
231
|
+
upsert: 'upsert',
|
|
232
|
+
save: 'upsert',
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Milestone 2, Sub-project E, increment 2 (DESIGN_STORE_DETAIL.md §5).
|
|
237
|
+
* `arg0.props` entries are EITHER `{key: <string>, value: <exprDesc>}` (a
|
|
238
|
+
* real or literal-computed property) OR `{spread: true, value: <exprDesc>}`
|
|
239
|
+
* (an object spread — no key at all), per `parser-js.js`'s real
|
|
240
|
+
* `ObjectExpression` lowering (confirmed by direct read, ~line 116-159). A
|
|
241
|
+
* spread entry has no key to report; a `'*'`-keyed entry is a genuinely
|
|
242
|
+
* UNKNOWN column name (a non-literal computed key, `resolveObjectKey`'s
|
|
243
|
+
* own convention), not a literal column named `"*"` — reporting either
|
|
244
|
+
* would be a fabrication. Deduplicated since two distinct-value properties
|
|
245
|
+
* can share a key in real (if unusual) source (`{email: a, email: b}`).
|
|
246
|
+
*/
|
|
247
|
+
function ormWriteColumns(arg0) {
|
|
248
|
+
const props = Array.isArray(arg0?.props) ? arg0.props : [];
|
|
249
|
+
const keys = props
|
|
250
|
+
.filter((p) => !p.spread && typeof p.key === 'string' && p.key !== '*')
|
|
251
|
+
.map((p) => p.key);
|
|
252
|
+
return [...new Set(keys)];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Milestone 2, Sub-project E, increment 3 (`node.queueDetail.topic`,
|
|
257
|
+
* DESIGN_QUEUE_DETAIL.md §3). A small local copy of `resolve-destination
|
|
258
|
+
* .js`'s `isLiteral` check shape (not imported — this package's own
|
|
259
|
+
* established "small local copy over cross-module dependency" precedent,
|
|
260
|
+
* already used by `ormWriteColumns`/`calleeDescriptor` elsewhere in this
|
|
261
|
+
* file), scoped to this file's own extraction needs.
|
|
262
|
+
*/
|
|
263
|
+
function isLiteral(e) {
|
|
264
|
+
return Boolean(e) && typeof e === 'object' && e.kind === 'literal';
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Milestone 2, Sub-project E, increment 3 (DESIGN_QUEUE_DETAIL.md §3.1). A
|
|
268
|
+
// short, disclosed alias list — deliberately not widened speculatively.
|
|
269
|
+
// Checked in this order: the first alias with a matching, literal-valued
|
|
270
|
+
// property wins.
|
|
271
|
+
const QUEUE_TOPIC_KEY_ALIASES = Object.freeze(['QueueUrl', 'TopicArn', 'topic', 'queueName']);
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* `extractQueueDetail(args)` — only needs `args`: `operation` is always
|
|
275
|
+
* `'publish'` when this is called at all (every real
|
|
276
|
+
* `PRIVACY_SINK_CATALOG` queue entry — `sendMessage`/`publish` — is
|
|
277
|
+
* unambiguously a write), so no `calleeExpr`/callee-name parameter is
|
|
278
|
+
* needed, unlike `resolveOrmWriteAtCallSite`'s `table`/`operation`
|
|
279
|
+
* extraction, which did need the callee.
|
|
280
|
+
*
|
|
281
|
+
* `topic` extraction reuses `ormWriteColumns`'s exact filter shape
|
|
282
|
+
* (exclude spread entries, exclude `'*'`-keyed computed entries) but wants
|
|
283
|
+
* the VALUE of one specific matching key, not every key name — a
|
|
284
|
+
* different, smaller function, not a call to `ormWriteColumns` itself.
|
|
285
|
+
* Covers `privacy-js-queue-sendMessage`'s shape
|
|
286
|
+
* (`sqs.sendMessage({QueueUrl: '...', MessageBody: ...})`) directly. For
|
|
287
|
+
* `privacy-js-queue-publish`'s shape (`topic.publish(...)`), the topic
|
|
288
|
+
* identity typically lives in a SEPARATE, earlier statement that
|
|
289
|
+
* constructed the receiver — a cross-statement lookup this package has no
|
|
290
|
+
* primitive for — so that shape's own call arguments never carry a
|
|
291
|
+
* matching key here, and `topic` stays `null`, honestly, exactly as it
|
|
292
|
+
* would for any other call whose object-literal argument (if any) carries
|
|
293
|
+
* none of the recognized aliases. This is a disclosed, deferred gap, not a
|
|
294
|
+
* half-attempt — see DESIGN_QUEUE_DETAIL.md §3.2.
|
|
295
|
+
*/
|
|
296
|
+
function extractQueueDetail(args) {
|
|
297
|
+
const arg0 = Array.isArray(args) ? args[0] : undefined;
|
|
298
|
+
const props = Array.isArray(arg0?.props) ? arg0.props : [];
|
|
299
|
+
let topic = null;
|
|
300
|
+
for (const alias of QUEUE_TOPIC_KEY_ALIASES) {
|
|
301
|
+
const prop = props.find((p) => !p.spread && p.key === alias);
|
|
302
|
+
if (prop && isLiteral(prop.value)) {
|
|
303
|
+
topic = String(prop.value.value);
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return { provider: null, topic, operation: 'publish' };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function resolveOrmWriteAtCallSite(calleeExpr, args, file) {
|
|
311
|
+
const hits = matchOrmWrite(calleeExpr, file);
|
|
312
|
+
if (!hits) return null;
|
|
313
|
+
const arg0 = Array.isArray(args) ? args[0] : undefined;
|
|
314
|
+
if (!arg0 || arg0.kind !== 'object') return null;
|
|
315
|
+
const entry = hits[0];
|
|
316
|
+
// DESIGN_STORE_DETAIL.md §3: `table` re-verifies defensively rather than
|
|
317
|
+
// assuming the shape `_ormReceiverIsCapitalizedIdent` already checked
|
|
318
|
+
// inside `matchOrmWrite` survived unchanged into this file.
|
|
319
|
+
const table = typeof calleeExpr?.object?.name === 'string' ? calleeExpr.object.name : null;
|
|
320
|
+
const operation = ORM_OPERATION_MAP[entry.match?.callee] ?? null;
|
|
321
|
+
const storeDetail = {
|
|
322
|
+
provider: null, host: null, database: null, schema: null,
|
|
323
|
+
table, operation, columns: ormWriteColumns(arg0),
|
|
324
|
+
};
|
|
325
|
+
return { entry, decision: reclassifyOrmWrite(entry), ambiguity: null, storeDetail };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* §4.1. Registry-backed sink enumeration — the replacement for
|
|
330
|
+
* `sinkCandidates()`. A CFG `call` STATEMENT node is the only shape that
|
|
331
|
+
* produces an `escape` provenance node (engine.js `step()` case 'call' →
|
|
332
|
+
* `write-out`/`call-arg`), so it is the only shape a sink-rooted
|
|
333
|
+
* reconstruction can start from. Every OTHER call expression is counted,
|
|
334
|
+
* never silently dropped.
|
|
335
|
+
*/
|
|
336
|
+
export function enumerateSinkSites(callGraph) {
|
|
337
|
+
const sites = [];
|
|
338
|
+
const nonStatementSites = [];
|
|
339
|
+
for (const fn of callGraph.functions.values()) {
|
|
340
|
+
for (const [nid, node] of Object.entries(fn.cfg?.nodes ?? {})) {
|
|
341
|
+
if (node.kind === 'call' && node.callee) {
|
|
342
|
+
const r = resolveSinkAtCallSite(node.callee, fn.file);
|
|
343
|
+
if (r) {
|
|
344
|
+
sites.push({ file: fn.file, qid: fn.qid, nodeId: nid, line: node.line ?? null, calleeExpr: node.callee, args: node.args ?? [], ...r });
|
|
345
|
+
// Milestone 2, Sub-project E, increment 3: queue/topic identity
|
|
346
|
+
// extraction, a conditional POST-step on the already-pushed site
|
|
347
|
+
// object — not a change to `resolveSinkAtCallSite`'s own
|
|
348
|
+
// signature (that function has no `args` parameter today, and
|
|
349
|
+
// adding one would be a wider, unnecessary change for a fact
|
|
350
|
+
// only the queue category needs).
|
|
351
|
+
if (r.decision.category === 'queue') {
|
|
352
|
+
const site = sites[sites.length - 1];
|
|
353
|
+
site.queueDetail = extractQueueDetail(node.args ?? []);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
// Milestone 2, Sub-project E, increment 1: ORM-write recognition,
|
|
357
|
+
// additive and independent of the general/privacy match above —
|
|
358
|
+
// see `resolveOrmWriteAtCallSite`'s own header for why no
|
|
359
|
+
// interaction between the two is expected or handled.
|
|
360
|
+
const ormR = resolveOrmWriteAtCallSite(node.callee, node.args ?? [], fn.file);
|
|
361
|
+
if (ormR) sites.push({ file: fn.file, qid: fn.qid, nodeId: nid, line: node.line ?? null, calleeExpr: node.callee, args: node.args ?? [], ...ormR });
|
|
362
|
+
}
|
|
363
|
+
for (const root of exprRoots(node)) {
|
|
364
|
+
walkExpr(root, (e) => {
|
|
365
|
+
if (e.kind !== 'call') return;
|
|
366
|
+
const r = resolveSinkAtCallSite(e.callee, fn.file);
|
|
367
|
+
if (!r) return;
|
|
368
|
+
if (sites.some((x) => x.qid === fn.qid && x.nodeId === nid && x.entry.id === r.entry.id && x.calleeExpr === e.callee)) return;
|
|
369
|
+
nonStatementSites.push({ file: fn.file, qid: fn.qid, nodeId: nid, line: node.line ?? null, nodeKind: node.kind, entryId: r.entry.id });
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return { sites, nonStatementSites };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* §5. `DESIGN_PATH_PROVENANCE.md` §16.7 Finding 2's enumerator half, via
|
|
379
|
+
* the `diagnostics()`-union mechanism rather than a sixth `path-store.js`
|
|
380
|
+
* node kind: "a `path` node with zero out-edges whose in-edges carry a
|
|
381
|
+
* context-cap-degraded annotation". Computed from the PUBLIC read API only
|
|
382
|
+
* — `nodes()`, `edgesFrom()`, `edgesTo()` — with no change to
|
|
383
|
+
* `path-store.js` whatsoever.
|
|
384
|
+
*/
|
|
385
|
+
export function degradedTerminals(store) {
|
|
386
|
+
const out = [];
|
|
387
|
+
for (const n of store.nodes()) {
|
|
388
|
+
if (store.edgesFrom(n.id).length !== 0) continue;
|
|
389
|
+
const inEdges = store.edgesTo(n.id);
|
|
390
|
+
if (inEdges.length === 0) continue;
|
|
391
|
+
const degraded = inEdges.some((e) =>
|
|
392
|
+
(e.lossReasons ?? []).some((r) => DEGRADED_LOSS_REASONS.includes(r))
|
|
393
|
+
|| (e.annotations ?? []).some((a) => DEGRADED_LOSS_REASONS.includes(a.lossReason)));
|
|
394
|
+
if (degraded) out.push(n);
|
|
395
|
+
}
|
|
396
|
+
return out;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function calleeDescriptor(calleeExpr) {
|
|
400
|
+
if (typeof calleeExpr === 'string') return { type: 'call', callee: calleeExpr };
|
|
401
|
+
if (!calleeExpr) return null;
|
|
402
|
+
if (calleeExpr.kind === 'ident' && calleeExpr.name) return { type: 'call', callee: calleeExpr.name };
|
|
403
|
+
if (calleeExpr.kind === 'member' && typeof calleeExpr.prop === 'string') {
|
|
404
|
+
const obj = calleeExpr.object && calleeExpr.object.kind === 'ident' ? calleeExpr.object.name : null;
|
|
405
|
+
return obj ? { type: 'member-call', object: obj, method: calleeExpr.prop } : { type: 'call', callee: calleeExpr.prop };
|
|
406
|
+
}
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
const calleeDisplay = (d) => (!d ? null : d.type === 'member-call' ? `${d.object}.${d.method}` : d.callee);
|
|
410
|
+
|
|
411
|
+
const nodeLabel = (kind, category, reason) => (category
|
|
412
|
+
? `${category} (${kind})`
|
|
413
|
+
: `unsupported ${kind}: ${String(reason).split(';')[0].slice(0, 80)}`);
|
|
414
|
+
|
|
415
|
+
// =========================================================================
|
|
416
|
+
// §6-§8 — THE PROJECTION. A `DataFlowGraph v1` node is a REGISTRY DECISION,
|
|
417
|
+
// not a provenance node and not a call site: its identity is
|
|
418
|
+
// `(kind, subtypeKey, coverageStatus, externality, destination)`. Edges and
|
|
419
|
+
// flows stay field- and path-granular, so nothing FR-305 protects is lost.
|
|
420
|
+
// =========================================================================
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* `buildDataFlowGraph(callGraph, opts)` — mirrors `dataflow/index.js`'s
|
|
424
|
+
* `runDeepAnalysis` SHAPE (an opt-in, best-effort orchestration entry
|
|
425
|
+
* point) and imports NOTHING from it. Seeds and drives the field-identity
|
|
426
|
+
* analysis, builds a `PathStore`, enumerates registry-backed sink
|
|
427
|
+
* candidates, resolves multi-candidate matches, reconstructs paths per
|
|
428
|
+
* sink, and projects the result into a `validateGraph()`-clean
|
|
429
|
+
* `DataFlowGraph v1` envelope.
|
|
430
|
+
*
|
|
431
|
+
* See the module header for the two-argument-vs-three-argument signature
|
|
432
|
+
* discrepancy this ships against, and the module's DESIGN_GRAPH_BUILDER.md
|
|
433
|
+
* §6-§8 for the projection rules implemented below.
|
|
434
|
+
*/
|
|
435
|
+
export function buildDataFlowGraph(callGraph, opts = {}) {
|
|
436
|
+
const repository = opts.repository ?? 'repo';
|
|
437
|
+
const { seeds, unseedable } = planSeeds(callGraph, { repository });
|
|
438
|
+
const hops = [];
|
|
439
|
+
runFieldIdentityAnalysis(callGraph, {
|
|
440
|
+
recordHop: (h) => hops.push(h),
|
|
441
|
+
seedEntryState: seedEntryStateFactory(seeds),
|
|
442
|
+
...(opts.maxContextsPerFn === undefined ? {} : { maxContextsPerFn: opts.maxContextsPerFn }),
|
|
443
|
+
});
|
|
444
|
+
const store = new PathStore();
|
|
445
|
+
store.addHops(hops);
|
|
446
|
+
|
|
447
|
+
const cfgByQid = new Map();
|
|
448
|
+
for (const fn of callGraph.functions.values()) cfgByQid.set(fn.qid, { fn, nodes: fn.cfg?.nodes ?? {} });
|
|
449
|
+
|
|
450
|
+
const seedByDe = new Map();
|
|
451
|
+
const seedSitesByDe = new Map();
|
|
452
|
+
for (const s of seeds) {
|
|
453
|
+
if (!seedByDe.has(s.dataElementId)) seedByDe.set(s.dataElementId, s);
|
|
454
|
+
if (!seedSitesByDe.has(s.dataElementId)) seedSitesByDe.set(s.dataElementId, []);
|
|
455
|
+
seedSitesByDe.get(s.dataElementId).push(s);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const graph = emptyGraphEnvelope({
|
|
459
|
+
graphId: ids.graphId({ repository, commit: opts.commit ?? 'uncommitted', configHash: opts.configHash ?? 'default' }),
|
|
460
|
+
// Determinism (§9.3 item 4): `generatedAt` must be injectable via
|
|
461
|
+
// `opts.generatedAt` rather than defaulting to `new Date().toISOString()`
|
|
462
|
+
// (which `emptyGraphEnvelope()` itself would fall back to if this
|
|
463
|
+
// module ever passed `undefined`/omitted the key entirely).
|
|
464
|
+
generatedAt: opts.generatedAt ?? '1970-01-01T00:00:00.000Z',
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
const nodesById = new Map();
|
|
468
|
+
const edgesById = new Map();
|
|
469
|
+
const flowsById = new Map();
|
|
470
|
+
const transformsById = new Map();
|
|
471
|
+
const deById = new Map();
|
|
472
|
+
// Every registry decision that landed on each node id — the raw material
|
|
473
|
+
// for the "no two DIFFERENT decisions collided onto one node" check.
|
|
474
|
+
const decisionsByNodeId = new Map();
|
|
475
|
+
// Milestone 2, Sub-project G, increment 1 (FR-408/AC-09): the first real
|
|
476
|
+
// populator of `graph.evidence[]` (`evidenceId` — `ids.js` — was minted
|
|
477
|
+
// but never called until this increment). One entry per DISTINCT
|
|
478
|
+
// permitting policy rule actually applied to a flow, deduplicated by
|
|
479
|
+
// content hash exactly like `transformsById`/`edgesById` above.
|
|
480
|
+
const evidenceById = new Map();
|
|
481
|
+
// Milestone 4, FR-506 (Third-Party and Cross-Border Intelligence): every
|
|
482
|
+
// non-null `opts.buildRecipientProfile(site, graph)` result, deduplicated
|
|
483
|
+
// by the record's own `id` — the same recipient reached by two different
|
|
484
|
+
// sink sites in one scan must not mint two records; a duplicate
|
|
485
|
+
// resolution merges its `site.nodeId` into the first record's
|
|
486
|
+
// `contributingGraphIds` instead of overwriting it.
|
|
487
|
+
const recipientProfilesById = new Map();
|
|
488
|
+
|
|
489
|
+
const mintNode = ({ kind, category, coverageStatus, externality, coverageReason, subtypeKey, lifecycleStages, destination, storeDetail, queueDetail }) => {
|
|
490
|
+
// NOTE (Milestone 2, Sub-project A, increment 1; Sub-project E,
|
|
491
|
+
// increments 2 and 3): neither `destination` nor `storeDetail` nor
|
|
492
|
+
// `queueDetail` is part of this discriminator — the node identity
|
|
493
|
+
// model stays exactly what it was in Milestone 1 (a registry decision,
|
|
494
|
+
// never a per-call-site fact), so two sites sharing one
|
|
495
|
+
// (kind, subtypeKey, coverageStatus, externality) tuple still mint/
|
|
496
|
+
// collide onto ONE node, and that node's `destination`/`storeDetail`/
|
|
497
|
+
// `queueDetail` is whichever site's resolution was applied FIRST
|
|
498
|
+
// (mintNode only sets it at creation, same as every other field
|
|
499
|
+
// below) — a known, disclosed coarsening, not a bug; see
|
|
500
|
+
// DESIGN_DESTINATION_RESOLVER.md, DESIGN_STORE_DETAIL.md, and
|
|
501
|
+
// DESIGN_QUEUE_DETAIL.md respectively.
|
|
502
|
+
const id = ids.nodeId(kind, [repository, subtypeKey ?? category ?? '', coverageStatus, externality, /* destination, always null in M1 */ '']);
|
|
503
|
+
let n = nodesById.get(id);
|
|
504
|
+
if (!n) {
|
|
505
|
+
n = {
|
|
506
|
+
id, kind,
|
|
507
|
+
// Decision 1 (DESIGN_REGISTRIES.md §9.0): the registry's `category`
|
|
508
|
+
// becomes the node's `subtype`; a null category becomes a null
|
|
509
|
+
// subtype and `kind` + the reason carry the meaning.
|
|
510
|
+
subtype: category ?? null,
|
|
511
|
+
label: nodeLabel(kind, category, coverageReason),
|
|
512
|
+
aliases: [],
|
|
513
|
+
// A category-granular node has no single source location. The
|
|
514
|
+
// flagship fixture sets `location: null` on all 14 of its own
|
|
515
|
+
// nodes, so this matches the one shipped precedent.
|
|
516
|
+
location: null,
|
|
517
|
+
system: { application: repository, environment: null },
|
|
518
|
+
destination: destination ?? null,
|
|
519
|
+
storeDetail: storeDetail ?? null,
|
|
520
|
+
queueDetail: queueDetail ?? null,
|
|
521
|
+
externality: { value: externality, evidenceRefs: [] },
|
|
522
|
+
lifecycleStages, governanceRefs: {},
|
|
523
|
+
dataElementIds: [], evidenceRefs: [],
|
|
524
|
+
confidence: coverageStatus === 'modeled' ? { score: 0.9, tier: 'high' } : { score: 0.6, tier: 'medium' },
|
|
525
|
+
coverageStatus,
|
|
526
|
+
coverageReason,
|
|
527
|
+
};
|
|
528
|
+
nodesById.set(id, n);
|
|
529
|
+
decisionsByNodeId.set(id, []);
|
|
530
|
+
}
|
|
531
|
+
decisionsByNodeId.get(id).push({ kind, category, coverageStatus, externality });
|
|
532
|
+
return n;
|
|
533
|
+
};
|
|
534
|
+
const sourceNodeFor = (s) => mintNode({
|
|
535
|
+
kind: 'source', category: s.category, coverageStatus: s.coverageStatus, externality: s.externality,
|
|
536
|
+
coverageReason: s.reason, subtypeKey: s.category ?? `unsupported-source:${s.entryId}`,
|
|
537
|
+
lifecycleStages: ['collection'],
|
|
538
|
+
});
|
|
539
|
+
const sinkNodeFor = (site) => mintNode({
|
|
540
|
+
kind: site.decision.kind, category: site.decision.category,
|
|
541
|
+
coverageStatus: site.decision.coverageStatus, externality: site.decision.externality,
|
|
542
|
+
coverageReason: site.decision.reason,
|
|
543
|
+
subtypeKey: site.decision.category ?? `unsupported-sink:${site.entry.vuln?.cwe ?? site.entry.id}`,
|
|
544
|
+
lifecycleStages: [site.decision.externality === 'external' ? 'sharing' : 'storage'],
|
|
545
|
+
destination: site.destination ?? null,
|
|
546
|
+
storeDetail: site.storeDetail ?? null,
|
|
547
|
+
queueDetail: site.queueDetail ?? null,
|
|
548
|
+
});
|
|
549
|
+
const mintDataElement = (s) => {
|
|
550
|
+
let d = deById.get(s.dataElementId);
|
|
551
|
+
if (!d) {
|
|
552
|
+
d = {
|
|
553
|
+
id: s.dataElementId, name: s.canonicalName, aliases: [], declaredType: null,
|
|
554
|
+
dataClasses: s.dataClasses, aiContexts: [], sourceLocations: [],
|
|
555
|
+
dataSubjectCategory: null, classificationEvidence: [], manualOverride: false,
|
|
556
|
+
};
|
|
557
|
+
deById.set(s.dataElementId, d);
|
|
558
|
+
for (const site of seedSitesByDe.get(s.dataElementId) ?? []) {
|
|
559
|
+
const loc = { file: site.file, line: site.line, scope: site.qid, path: site.seedPath };
|
|
560
|
+
if (!d.sourceLocations.some((l) => l.file === loc.file && l.line === loc.line && l.path === loc.path)) d.sourceLocations.push(loc);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
return d;
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
const { sites, nonStatementSites } = enumerateSinkSites(callGraph);
|
|
567
|
+
// E4's FR-203 closure hook (DESIGN_GRAPH_BUILDER.md §9.4 item 5b): a
|
|
568
|
+
// caller may substitute an adjusted decision for a site — e.g.
|
|
569
|
+
// coverage.js's `resolveSiteDecision`, which reclassifies a sink whose
|
|
570
|
+
// destination expression is not statically resolvable. Applied once,
|
|
571
|
+
// before anything else reads `site.decision`, so every later use in
|
|
572
|
+
// this build (node minting, coverage counting, flow limitations) sees
|
|
573
|
+
// the same, consistent decision. A no-op when omitted — every existing
|
|
574
|
+
// caller's behavior is unchanged.
|
|
575
|
+
if (typeof opts.resolveSiteDecision === 'function') {
|
|
576
|
+
for (const site of sites) {
|
|
577
|
+
const override = opts.resolveSiteDecision(site);
|
|
578
|
+
if (override) site.decision = override;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
// Milestone 2, Sub-project A, increment 1 (FR-202): a SEPARATE,
|
|
582
|
+
// additive hook — composes with, never replaces, `resolveSiteDecision`
|
|
583
|
+
// above. A site can be BOTH `kind: 'unresolved'` (FR-203's node-
|
|
584
|
+
// classification answer) AND carry a `resolutionStatus: 'dynamic'`
|
|
585
|
+
// destination (this hook's own, narrower "what does the destination
|
|
586
|
+
// expression look like" answer) — the two questions are independent,
|
|
587
|
+
// per DESIGN_DESTINATION_RESOLVER.md. Applied at the exact same point in
|
|
588
|
+
// the pipeline, right after `resolveSiteDecision`, for the same reason:
|
|
589
|
+
// once, before anything else reads `site.destination`
|
|
590
|
+
// (`sinkNodeFor`/the edge-protocol block below). A no-op when omitted —
|
|
591
|
+
// `site.destination` stays `undefined`, `sinkNodeFor` normalizes that to
|
|
592
|
+
// `null`, and the edge's `protocol.destinationResolution` stays
|
|
593
|
+
// `'unknown'` — byte-identical to pre-M2 behavior.
|
|
594
|
+
if (typeof opts.resolveDestination === 'function') {
|
|
595
|
+
for (const site of sites) {
|
|
596
|
+
const destination = opts.resolveDestination(site);
|
|
597
|
+
if (destination) site.destination = destination;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
const escapesBySite = new Map();
|
|
601
|
+
for (const e of store.nodes()) {
|
|
602
|
+
if (e.kind !== 'escape') continue;
|
|
603
|
+
const k = `${e.scope}|${e.siteNodeId}`;
|
|
604
|
+
if (!escapesBySite.has(k)) escapesBySite.set(k, []);
|
|
605
|
+
escapesBySite.get(k).push(e);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const stats = { connectedSinkSites: 0, pathsEnumerated: 0, pathsProjected: 0, truncatedQueries: 0, unknownTransforms: 0 };
|
|
609
|
+
const groupsByFlowKey = new Map();
|
|
610
|
+
|
|
611
|
+
for (const site of sites) {
|
|
612
|
+
let connected = false;
|
|
613
|
+
for (const esc of escapesBySite.get(`${site.qid}|${site.nodeId}`) ?? []) {
|
|
614
|
+
const r = reconstructPaths(store, esc.id, opts.budget ?? {});
|
|
615
|
+
if (r.truncated) stats.truncatedQueries += 1;
|
|
616
|
+
stats.pathsEnumerated += r.enumeratedPathCount;
|
|
617
|
+
for (const p of r.paths) {
|
|
618
|
+
const seed = seedByDe.get(p.dataElementId);
|
|
619
|
+
if (!seed) continue;
|
|
620
|
+
connected = true;
|
|
621
|
+
stats.pathsProjected += 1;
|
|
622
|
+
const g = gradePath(p);
|
|
623
|
+
|
|
624
|
+
// ---- §7. transformations on this path ----
|
|
625
|
+
const tIds = [];
|
|
626
|
+
const unattributed = [];
|
|
627
|
+
for (const h of p.hops) {
|
|
628
|
+
const cfg = cfgByQid.get(h.scope);
|
|
629
|
+
const cn = cfg?.nodes?.[h.siteNodeId];
|
|
630
|
+
if (!cn) continue;
|
|
631
|
+
const calls = [];
|
|
632
|
+
for (const root of exprRoots(cn)) walkExpr(root, (e) => { if (e.kind === 'call') calls.push(e); });
|
|
633
|
+
if (cn.kind === 'call' && cn.callee) calls.unshift({ kind: 'call', callee: cn.callee, args: cn.args ?? [] });
|
|
634
|
+
const widened = (h.widenReasons ?? []).includes('unresolved-call')
|
|
635
|
+
|| (h.annotations ?? []).some((a) => a.widenReason === 'unresolved-call');
|
|
636
|
+
|
|
637
|
+
let d = null;
|
|
638
|
+
let rec = null;
|
|
639
|
+
for (const c of calls) {
|
|
640
|
+
const d0 = calleeDescriptor(c.callee);
|
|
641
|
+
const r0 = d0 && recognizeTransformation(d0);
|
|
642
|
+
if (r0) { d = d0; rec = r0; break; }
|
|
643
|
+
}
|
|
644
|
+
if (!rec) {
|
|
645
|
+
// §7.2's `unknown` case, scoped so attribution is never a
|
|
646
|
+
// guess: emit an entity only when the widening hop's CFG node
|
|
647
|
+
// carries exactly ONE call expression.
|
|
648
|
+
if (!widened) continue;
|
|
649
|
+
if (calls.length !== 1) {
|
|
650
|
+
unattributed.push(`an unrecognized call widened this value at ${cfg.fn.file}:${h.line ?? '?'}; ${calls.length} call expressions at that site, so the transforming callee is not attributable`);
|
|
651
|
+
continue;
|
|
652
|
+
}
|
|
653
|
+
d = calleeDescriptor(calls[0].callee);
|
|
654
|
+
if (!d) { unattributed.push(`an unrecognized call widened this value at ${cfg.fn.file}:${h.line ?? '?'}; its callee is not a resolvable name`); continue; }
|
|
655
|
+
stats.unknownTransforms += 1;
|
|
656
|
+
}
|
|
657
|
+
const anchor = sinkNodeFor(site).id;
|
|
658
|
+
const t = {
|
|
659
|
+
id: ids.transformationId(anchor, calleeDisplay(d), [cfg.fn.file, h.line ?? '', h.fromPath ?? '', h.toPath ?? '']),
|
|
660
|
+
inputPath: h.fromPath ?? null, outputPath: h.toPath ?? null,
|
|
661
|
+
callee: calleeDisplay(d), function: h.scope,
|
|
662
|
+
location: { file: cfg.fn.file, line: h.line ?? null },
|
|
663
|
+
kind: rec ? rec.kind : 'unknown',
|
|
664
|
+
reversibility: rec ? rec.reversibility : 'unknown',
|
|
665
|
+
algorithm: rec ? rec.algorithm : null,
|
|
666
|
+
confidence: rec
|
|
667
|
+
? { score: rec.confidence === 'high' ? 0.9 : 0.6, tier: rec.confidence }
|
|
668
|
+
: { score: 0.3, tier: 'low' },
|
|
669
|
+
evidence: rec
|
|
670
|
+
? rec.evidence
|
|
671
|
+
: `a call to ${calleeDisplay(d)} widened this value's identity (hop record: unresolved-call); transform-catalog.js recognizes no transformation for this callee`,
|
|
672
|
+
// §7.3: honest absences at MINT time. `appliesToAllPaths`
|
|
673
|
+
// needs FR-307's all-path proof, which nothing in this
|
|
674
|
+
// per-hop loop does — it can only be answered once every
|
|
675
|
+
// flow group sharing this transform's coarse (source, sink,
|
|
676
|
+
// dataElement) key is known, which is not true yet at this
|
|
677
|
+
// point in the build. Milestone 2, Sub-project D, increment 2
|
|
678
|
+
// overwrites this `null` with a real `true`/`false` in the
|
|
679
|
+
// aggregation pass below (after `groupsByFlowKey` is fully
|
|
680
|
+
// populated) — see DESIGN_HANDLING_ANALYZER.md §5. There is
|
|
681
|
+
// still NO separate control-credit key of any kind — not even
|
|
682
|
+
// a bare `false` here would read as "considered and denied"
|
|
683
|
+
// this early (Decision 2); the real answer is written once,
|
|
684
|
+
// later, by the pass that can actually prove it.
|
|
685
|
+
appliesToAllPaths: null,
|
|
686
|
+
};
|
|
687
|
+
if (!transformsById.has(t.id)) transformsById.set(t.id, t);
|
|
688
|
+
if (!tIds.includes(t.id)) tIds.push(t.id);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const src = sourceNodeFor(seed);
|
|
692
|
+
const snk = sinkNodeFor(site);
|
|
693
|
+
const de = mintDataElement(seed);
|
|
694
|
+
if (!src.dataElementIds.includes(de.id)) src.dataElementIds.push(de.id);
|
|
695
|
+
if (!snk.dataElementIds.includes(de.id)) snk.dataElementIds.push(de.id);
|
|
696
|
+
|
|
697
|
+
const mappingType = tIds.length > 0 ? 'transformation' : (g.grade === 'explicit' ? 'identity' : 'unknown');
|
|
698
|
+
const fromPath = seed.seedPath;
|
|
699
|
+
// A call ARGUMENT is not an access path. Never fabricate one —
|
|
700
|
+
// DESIGN_PATH_PROVENANCE.md Decision 5's forbidden bug class.
|
|
701
|
+
const toPath = null;
|
|
702
|
+
const sortedT = [...tIds].sort();
|
|
703
|
+
const edgeIdStr = ids.edgeId(src.id, snk.id, 'data_flow', [fromPath, toPath ?? '', de.id, mappingType, ...sortedT]);
|
|
704
|
+
if (!edgesById.has(edgeIdStr)) {
|
|
705
|
+
edgesById.set(edgeIdStr, {
|
|
706
|
+
id: edgeIdStr, from: src.id, to: snk.id, relationship: 'data_flow',
|
|
707
|
+
fieldMappings: [{ fromPath, toPath, dataElementIds: [de.id], mappingType, transformationIds: sortedT }],
|
|
708
|
+
protocol: { name: 'in-process', destinationResolution: site.destination?.resolutionStatus ?? 'unknown' },
|
|
709
|
+
boundaryCrossings: [],
|
|
710
|
+
// Milestone 2, Sub-project F, increment 1 (FR-304): which
|
|
711
|
+
// mechanism discovered this edge. Every edge minted here today
|
|
712
|
+
// is genuinely code-derived — this is an unconditional, honest
|
|
713
|
+
// literal, never a guess (see schema.js's EDGE_PROVENANCE_VALUES
|
|
714
|
+
// comment for the full rationale).
|
|
715
|
+
provenance: 'code',
|
|
716
|
+
// Milestone 2, Sub-project B, increment 2 (FR-401):
|
|
717
|
+
// `opts.resolveTransitProtection(site) -> {verdict, evidenceGrade}
|
|
718
|
+
// | undefined`, applied at this exact point — the same block
|
|
719
|
+
// that already reads `site.destination` above — mirroring
|
|
720
|
+
// `opts.resolveDestination`'s own additive-hook contract
|
|
721
|
+
// exactly: composes with `emptyProtection()`'s default, never
|
|
722
|
+
// replaces it wholesale, and is a no-op (byte-identical output)
|
|
723
|
+
// when the hook is omitted or returns falsy. This increment
|
|
724
|
+
// writes ONLY `.transit` — `.atRest`/`.handling` stay
|
|
725
|
+
// `emptyProtection()`'s own defaults, Sub-project C's and a
|
|
726
|
+
// later increment's own jobs respectively.
|
|
727
|
+
protection: { ...emptyProtection(), transit: opts.resolveTransitProtection?.(site) ?? emptyProtection().transit },
|
|
728
|
+
evidenceRefs: [], coverageStatus: snk.coverageStatus,
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
// §6.4 / FR-305: the flow key carries every signal that makes two
|
|
732
|
+
// paths MATERIALLY different (shape, evidence grade, the ordered
|
|
733
|
+
// transformation set). Everything else collapses — including the
|
|
734
|
+
// per-function entry CONTEXT dimension, which is absent from this
|
|
735
|
+
// key by design and therefore never splits a flow.
|
|
736
|
+
const flowKey = [src.id, snk.id, de.id, p.shape, g.grade, sortedT.join(',')].join('|');
|
|
737
|
+
if (!groupsByFlowKey.has(flowKey)) groupsByFlowKey.set(flowKey, []);
|
|
738
|
+
groupsByFlowKey.get(flowKey).push({ p, g, src, snk, de, edgeIdStr, sortedT, site, unattributed, truncated: r.truncated, truncationReasons: r.truncationReasons });
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
site.connected = connected;
|
|
742
|
+
if (connected) stats.connectedSinkSites += 1;
|
|
743
|
+
// AC-11's coarse half: a sink node exists whether or not anything
|
|
744
|
+
// reached it. A disconnected sink is a node with no flow, never absent.
|
|
745
|
+
sinkNodeFor(site);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
for (const [, group] of [...groupsByFlowKey.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
|
|
749
|
+
const { p, g, src, snk, de, edgeIdStr, sortedT, site } = group[0];
|
|
750
|
+
const fId = ids.flowId(src.id, snk.id, [de.id], [p.shape, g.grade, sortedT.join(',')]);
|
|
751
|
+
const limitations = [...new Set([
|
|
752
|
+
...g.factors.map((f) => `evidence: ${f}`),
|
|
753
|
+
...group.flatMap((x) => x.unattributed),
|
|
754
|
+
...group.flatMap((x) => (x.truncated ? x.truncationReasons.map((t) => `reconstruction truncated: ${t}`) : [])),
|
|
755
|
+
...(g.complete ? [] : [`path incomplete: terminal reason ${p.terminal.reason}`]),
|
|
756
|
+
...(site.ambiguity && site.ambiguity.resolvedBy === 'plurality'
|
|
757
|
+
? [`sink category ambiguous at ${site.file}:${site.line}: also matches ${site.ambiguity.alternatives.join(', ')}`]
|
|
758
|
+
: []),
|
|
759
|
+
])].sort();
|
|
760
|
+
// Milestone 2, Sub-project D, increment 1 (FR-403): computed once,
|
|
761
|
+
// here, from this flow's own representative reconstructed path — never
|
|
762
|
+
// `null` in this increment (DESIGN_HANDLING_ANALYZER.md §4's own
|
|
763
|
+
// disclosed simplification: every flow gets a real taxonomy label,
|
|
764
|
+
// including the honest `'raw'` answer, since nothing here yet
|
|
765
|
+
// distinguishes a sink category with no natural "handling" concept
|
|
766
|
+
// from one that does).
|
|
767
|
+
const handlingResult = classifyHandling(p, callGraph).handling;
|
|
768
|
+
// Milestone 2, Sub-project C, increment 1 (FR-402, application-layer
|
|
769
|
+
// at-rest evidence): reuses the SAME `classifyHandling` result above
|
|
770
|
+
// for `flow.handling` — never a second call — to also decide
|
|
771
|
+
// `edge.protection.atRest`. `'encrypted'` is the only `HANDLING_VALUES`
|
|
772
|
+
// member that is at-rest PROTECTION evidence per FR-402 (see
|
|
773
|
+
// DESIGN_HANDLING_ANALYZER.md's new §7); every other value (including
|
|
774
|
+
// `'masked'`/`'hashed'`/`'tokenized'`) leaves `atRest` at
|
|
775
|
+
// `emptyProtection()`'s own honest default. Gated to `snk.kind ===
|
|
776
|
+
// 'store'` (`CATEGORY_NODE_KIND`'s `database`/`file`/`object-storage`/
|
|
777
|
+
// `cache`/`client-storage`/`backup`/`export` — `queue` has its own
|
|
778
|
+
// distinct `kind: 'queue'` and is deliberately excluded here). FR-402's
|
|
779
|
+
// own anti-pattern guard ("a cipher present anywhere in the same file
|
|
780
|
+
// or repository cannot alone establish protection for an unrelated
|
|
781
|
+
// store") holds by construction, not by extra code here:
|
|
782
|
+
// `classifyHandling` walks THIS flow's own reconstructed path only,
|
|
783
|
+
// never the whole file/repo, so an unrelated `encrypt()` call
|
|
784
|
+
// elsewhere is structurally invisible to it.
|
|
785
|
+
if (handlingResult === 'encrypted' && snk.kind === 'store') {
|
|
786
|
+
const edge = edgesById.get(edgeIdStr);
|
|
787
|
+
// Defensive only — `edgeIdStr` is the SAME id this flow's own
|
|
788
|
+
// `edgeIds: [edgeIdStr]` uses below, minted earlier in this same
|
|
789
|
+
// `buildDataFlowGraph` call, so `edge` should always be found. Never
|
|
790
|
+
// assume, per this package's own established convention.
|
|
791
|
+
if (edge) edge.protection.atRest = { verdict: 'protected', evidenceGrade: 'code' };
|
|
792
|
+
}
|
|
793
|
+
// Milestone 2, Sub-project G, increment 1 (FR-408/AC-09):
|
|
794
|
+
// `flow.policyVerdict` — real computed logic, replacing the
|
|
795
|
+
// `'not_evaluated'` literal §8 shipped as a Milestone-1 honest default.
|
|
796
|
+
// `classes`/`sinkKind` reuse fields this loop's own earlier passes
|
|
797
|
+
// already read for other purposes (`de.dataClasses`, `snk.subtype` —
|
|
798
|
+
// the FINE-grained SINK_CATEGORIES value; `snk.kind` is the coarser
|
|
799
|
+
// `'store'`/`'log'`/etc. Sub-project C1's own atRest gate above uses).
|
|
800
|
+
// `ctx.destination` reuses Sub-project A's `site.destination
|
|
801
|
+
// ?.literalValue` — never fabricated when unresolved, per
|
|
802
|
+
// `_matchesDestination`'s own fail-closed contract in
|
|
803
|
+
// privacy-sink-policy.js.
|
|
804
|
+
const policyClasses = de.dataClasses ?? [];
|
|
805
|
+
const policySinkKind = snk.subtype;
|
|
806
|
+
const policyCtx = {
|
|
807
|
+
environment: opts.environment || process.env.AGENTIC_SECURITY_ENVIRONMENT || null,
|
|
808
|
+
destination: site.destination?.literalValue ?? null,
|
|
809
|
+
};
|
|
810
|
+
// `policyLoaded` gates on the OPTS FIELD being present (`!= null`),
|
|
811
|
+
// never on `opts.privacySinkPolicy.allow.length > 0` — a policy that
|
|
812
|
+
// genuinely exists but permits nothing yet must still read
|
|
813
|
+
// `'prohibited'` (deny-by-default), while an opt that was never
|
|
814
|
+
// supplied at all (no policy evaluation attempted) must read
|
|
815
|
+
// `'not_evaluated'`. `index.js` is what keeps this distinction real —
|
|
816
|
+
// it only sets `opts.privacySinkPolicy` when a policy file genuinely
|
|
817
|
+
// exists on disk, never coercing a missing file to the loader's own
|
|
818
|
+
// `{allow: []}` "no policy configured" default (see that file's own
|
|
819
|
+
// comment for why `loadPrivacySinkPolicy`'s return value alone cannot
|
|
820
|
+
// make this distinction).
|
|
821
|
+
const policyLoaded = opts.privacySinkPolicy != null;
|
|
822
|
+
let policyVerdict = 'not_evaluated';
|
|
823
|
+
const policyEvidenceRefs = [];
|
|
824
|
+
// A flow whose data element carries NO recognized data class has
|
|
825
|
+
// nothing for a policy engine to have an opinion about — `'prohibited'`
|
|
826
|
+
// would overstate a judgment that never happened (isSinkPermitted's own
|
|
827
|
+
// `if (!classes.length) return false` early return exists for a
|
|
828
|
+
// DIFFERENT reason, precision on the FINDING side, not this field), so
|
|
829
|
+
// this stays the honest `'not_evaluated'` default rather than calling
|
|
830
|
+
// into the policy at all.
|
|
831
|
+
if (policyLoaded && policyClasses.length && policySinkKind) {
|
|
832
|
+
if (isSinkPermitted(policyClasses, policySinkKind, opts.privacySinkPolicy, policyCtx)) {
|
|
833
|
+
policyVerdict = 'permitted';
|
|
834
|
+
const rules = permittingRules(policyClasses, policySinkKind, opts.privacySinkPolicy, policyCtx);
|
|
835
|
+
for (const r of rules) {
|
|
836
|
+
const claim = `Privacy policy permits ${r.class ?? 'any data class'} data to reach sink "${r.sink}"`
|
|
837
|
+
+ (r.environment ? ` in environment "${r.environment}"` : '')
|
|
838
|
+
+ (r.destination ? ` for a destination matching /${r.destination}/` : '')
|
|
839
|
+
+ (r.reason ? `: ${r.reason}` : '');
|
|
840
|
+
const evId = ids.evidenceId(claim, `${site.file}:${site.line}`, [de.id, r.sink, r.class ?? '', r.environment ?? '', r.destination ?? '']);
|
|
841
|
+
if (!evidenceById.has(evId)) {
|
|
842
|
+
evidenceById.set(evId, {
|
|
843
|
+
id: evId, claim, evidenceType: 'policy',
|
|
844
|
+
location: { file: site.file, line: site.line },
|
|
845
|
+
producer: 'privacy-sink-policy', confidenceTier: null,
|
|
846
|
+
snippet: null, timestamp: null, commit: null,
|
|
847
|
+
limitations: [], conflict: null,
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
if (!policyEvidenceRefs.includes(evId)) policyEvidenceRefs.push(evId);
|
|
851
|
+
}
|
|
852
|
+
} else {
|
|
853
|
+
policyVerdict = 'prohibited';
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
// Milestone 2, Sub-project I, increment 1 (PRD line 909): replaces
|
|
857
|
+
// the §8 `protectionSummary: 'not_assessed'` literal — that line
|
|
858
|
+
// stored an "unsupported independent claim", exactly what PRD line
|
|
859
|
+
// 909 forbids ("must be derived from the individual edge verdicts").
|
|
860
|
+
// `aggregateVerdicts`'s own `_PRECEDENCE` table (protection.js) is
|
|
861
|
+
// documented as built for CROSS-BRANCH, same-dimension aggregation
|
|
862
|
+
// (PRD §8.4's own wording: "one branch protected, one branch
|
|
863
|
+
// unprotected") — NOT for combining one edge's own three DIFFERENT
|
|
864
|
+
// dimensions. Reusing it here is safe ONLY because, for every real
|
|
865
|
+
// edge today, at most ONE of transit/atRest/handling can ever be
|
|
866
|
+
// non-default: `resolveTransitProtectionForSite` is gated to
|
|
867
|
+
// `category === 'external-api'` (-> `kind: 'external'` only) and the
|
|
868
|
+
// atRest block above is gated to `snk.kind === 'store'` only — these
|
|
869
|
+
// two node kinds are mutually exclusive by construction, and
|
|
870
|
+
// `edge.protection.handling` is never written by any code at all.
|
|
871
|
+
// So this reduces, in practice, to "whichever single dimension
|
|
872
|
+
// actually applies to this edge, use its own real verdict; the
|
|
873
|
+
// others are honestly not_assessed and never mask it." **If a
|
|
874
|
+
// future analyzer ever makes TWO of these three dimensions
|
|
875
|
+
// genuinely co-applicable to the SAME edge, this reasoning breaks**
|
|
876
|
+
// — a `protected` dimension could then mask a genuinely-unassessed,
|
|
877
|
+
// RELEVANT other dimension, exactly the false-protected bug class
|
|
878
|
+
// Sub-project H's own gate exists to catch (PRD line 121: "missing
|
|
879
|
+
// evidence is displayed as unknown or not assessed, never as
|
|
880
|
+
// protected"). Revisit this call (a filter-to-evaluated-dimensions
|
|
881
|
+
// rule, or a dedicated cross-dimension precedence) before that
|
|
882
|
+
// happens — do not assume this reasoning still holds.
|
|
883
|
+
//
|
|
884
|
+
// A second, separate disclosed fragility: `PROTECTION_VERDICTS`
|
|
885
|
+
// includes `'not_applicable'`, which is NOT a `FLOW_SUMMARY_VALUES`
|
|
886
|
+
// member (schema.js) — confirmed directly. No producer today ever
|
|
887
|
+
// sets a dimension's verdict to `'not_applicable'`, so
|
|
888
|
+
// `aggregateVerdicts` can never actually return it here — but if a
|
|
889
|
+
// future analyzer ever does, `flow.protectionSummary` could fail
|
|
890
|
+
// `validateGraph`. See `test/lineage/protection-summary.test.js`'s
|
|
891
|
+
// `I1/5` for the currently-true, narrower claim this rests on.
|
|
892
|
+
const flowEdge = edgesById.get(edgeIdStr);
|
|
893
|
+
// Defensive only, mirroring this loop's own established convention
|
|
894
|
+
// (see the atRest block above) — `flowEdge` should always be found,
|
|
895
|
+
// the SAME id this flow's own `edgeIds: [edgeIdStr]` uses below.
|
|
896
|
+
const protectionSummary = flowEdge
|
|
897
|
+
? aggregateVerdicts([
|
|
898
|
+
flowEdge.protection.transit.verdict,
|
|
899
|
+
flowEdge.protection.atRest.verdict,
|
|
900
|
+
flowEdge.protection.handling.verdict,
|
|
901
|
+
])
|
|
902
|
+
: 'not_assessed';
|
|
903
|
+
flowsById.set(fId, {
|
|
904
|
+
id: fId, dataElementIds: [de.id], source: src.id, sink: snk.id,
|
|
905
|
+
edgeIds: [edgeIdStr], transformationIds: sortedT,
|
|
906
|
+
alternatePathCount: group.length - 1,
|
|
907
|
+
policyVerdict,
|
|
908
|
+
protectionSummary,
|
|
909
|
+
evidenceRefs: policyEvidenceRefs,
|
|
910
|
+
confidence: g.grade === 'explicit' ? { score: 0.8, tier: 'high' } : { score: 0.5, tier: 'medium' },
|
|
911
|
+
// Deliverable #10 (DFG-020, graph-derived DPIA/RoPA migration):
|
|
912
|
+
// opts.resolveGovernanceRefs(dataClasses) -> governance-field record,
|
|
913
|
+
// applied at this exact mint point — same additive-hook shape every
|
|
914
|
+
// sibling hook in this file uses (resolveSiteDecision/resolveDestination/
|
|
915
|
+
// resolveTransitProtection), byte-identical graph when omitted. Never
|
|
916
|
+
// fabricates a governance fact — the hook itself (composed by
|
|
917
|
+
// coverage.js's default) only ever attaches operator-supplied config or
|
|
918
|
+
// the MANUAL_REQUIRED sentinel dataflow/privacy-governance.js already
|
|
919
|
+
// establishes; this mint site has no opinion of its own.
|
|
920
|
+
coverageStatus: snk.coverageStatus, findingRefs: [],
|
|
921
|
+
governanceRefs: opts.resolveGovernanceRefs?.(de.dataClasses ?? []) ?? {},
|
|
922
|
+
limitations,
|
|
923
|
+
evidenceGrade: g.grade,
|
|
924
|
+
handling: handlingResult,
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// Milestone 2, Sub-project D, increment 2 (FR-307): appliesToAllPaths.
|
|
929
|
+
// Must run AFTER groupsByFlowKey is fully populated (every flow group for
|
|
930
|
+
// every sink has been discovered) and BEFORE transformsById is read into
|
|
931
|
+
// graph.transformations — see DESIGN_HANDLING_ANALYZER.md §5 for the full
|
|
932
|
+
// rule and why no special-casing is needed for a truncated/incomplete path.
|
|
933
|
+
const coarseGroups = new Map();
|
|
934
|
+
for (const [, group] of groupsByFlowKey) {
|
|
935
|
+
const { src, snk, de, sortedT } = group[0];
|
|
936
|
+
const coarseKey = `${src.id}|${snk.id}|${de.id}`;
|
|
937
|
+
if (!coarseGroups.has(coarseKey)) coarseGroups.set(coarseKey, []);
|
|
938
|
+
coarseGroups.get(coarseKey).push(sortedT);
|
|
939
|
+
}
|
|
940
|
+
for (const flowsSortedT of coarseGroups.values()) {
|
|
941
|
+
const relevantIds = new Set(flowsSortedT.flat());
|
|
942
|
+
for (const tid of relevantIds) {
|
|
943
|
+
const appliesToAll = flowsSortedT.every((st) => st.includes(tid));
|
|
944
|
+
const t = transformsById.get(tid);
|
|
945
|
+
t.appliesToAllPaths = t.appliesToAllPaths === null ? appliesToAll : (t.appliesToAllPaths && appliesToAll);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// AC-11's coarse half, source side: every seeded source is a node and a
|
|
950
|
+
// data element even when nothing reached a sink from it.
|
|
951
|
+
for (const s of seeds) {
|
|
952
|
+
const n = sourceNodeFor(s);
|
|
953
|
+
const d = mintDataElement(s);
|
|
954
|
+
if (!n.dataElementIds.includes(d.id)) n.dataElementIds.push(d.id);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// §5's enumerator union: §16.7's degraded terminals become `unresolved`
|
|
958
|
+
// nodes with the vocabulary DESIGN_REGISTRIES.md already fixed.
|
|
959
|
+
const degraded = degradedTerminals(store);
|
|
960
|
+
for (const dn of degraded) {
|
|
961
|
+
const n = mintNode({
|
|
962
|
+
kind: 'unresolved', category: null, coverageStatus: 'partial', externality: 'unknown',
|
|
963
|
+
coverageReason: 'analysis degraded: a context-cap-degraded hop ends here, so this endpoint\'s continuation was never analyzed (DESIGN_PATH_PROVENANCE.md §16.7 Finding 2)',
|
|
964
|
+
subtypeKey: 'context-cap-degraded', lifecycleStages: [],
|
|
965
|
+
});
|
|
966
|
+
const seed = seedByDe.get(dn.dataElementId);
|
|
967
|
+
if (seed) {
|
|
968
|
+
const d = mintDataElement(seed);
|
|
969
|
+
if (!n.dataElementIds.includes(d.id)) n.dataElementIds.push(d.id);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
const byId = (a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
974
|
+
graph.nodes = [...nodesById.values()].sort(byId);
|
|
975
|
+
graph.dataElements = [...deById.values()].sort(byId);
|
|
976
|
+
graph.edges = [...edgesById.values()].sort(byId);
|
|
977
|
+
graph.transformations = [...transformsById.values()].sort(byId);
|
|
978
|
+
graph.flows = [...flowsById.values()].sort(byId);
|
|
979
|
+
// Milestone 2, Sub-project G, increment 1: the first real populator of
|
|
980
|
+
// `graph.evidence[]` — every permitting policy rule minted above.
|
|
981
|
+
graph.evidence = [...evidenceById.values()].sort(byId);
|
|
982
|
+
// Milestone 4, FR-506 (Third-Party and Cross-Border Intelligence): a
|
|
983
|
+
// THIRD, SEPARATE, additive hook of the identical shape — composes with,
|
|
984
|
+
// never replaces, `resolveDestination`/`resolveSiteDecision` above.
|
|
985
|
+
// Deliberately NOT gated on `site.destination` being present: unlike a
|
|
986
|
+
// protection/policy verdict, a recipient's technical-provider match can
|
|
987
|
+
// resolve from `site.entry.framework` alone (e.g. a receiver-based
|
|
988
|
+
// `anthropic.messages.create()` SDK call, AC-07's own real fixture shape,
|
|
989
|
+
// whose destination frequently never resolves past 'unknown'/'dynamic' —
|
|
990
|
+
// there is no literal URL to resolve) — skipping every site with no
|
|
991
|
+
// destination would silently drop exactly that real-world case. Runs
|
|
992
|
+
// here, after nodes/edges/flows/dataElements are populated (not right
|
|
993
|
+
// after resolveDestination, where it originally ran), specifically so
|
|
994
|
+
// `computeGraphDigest(graph)` inside `buildRecipientProfile` hashes real
|
|
995
|
+
// graph content instead of the still-empty envelope (fix-round-1, B2). A
|
|
996
|
+
// no-op when omitted, mirroring every sibling hook's own proven
|
|
997
|
+
// contract.
|
|
998
|
+
if (typeof opts.buildRecipientProfile === 'function') {
|
|
999
|
+
for (const site of sites) {
|
|
1000
|
+
const profile = opts.buildRecipientProfile(site, graph);
|
|
1001
|
+
if (profile) {
|
|
1002
|
+
// fix-round-1, B3: `sinkNodeFor(site).id` is the real, stable
|
|
1003
|
+
// graph node id (`mintNode` dedups by id, so calling it again
|
|
1004
|
+
// here is idempotent) — `site.nodeId` is a CFG-parse-local
|
|
1005
|
+
// counter value (`parser-js.js`'s `_nodeIdSeq`, e.g. `"n6"`),
|
|
1006
|
+
// never a real graph node id, which made `--filter` a permanent
|
|
1007
|
+
// no-op against `contributingGraphIds`.
|
|
1008
|
+
const nodeId = sinkNodeFor(site).id;
|
|
1009
|
+
const existing = recipientProfilesById.get(profile.id);
|
|
1010
|
+
if (existing) {
|
|
1011
|
+
existing.contributingGraphIds = [...new Set([...existing.contributingGraphIds, nodeId])];
|
|
1012
|
+
// fix-round-1, M6: an order-dependent dedup previously kept only
|
|
1013
|
+
// the FIRST-seen site's whole profile, silently dropping a
|
|
1014
|
+
// later site's non-null facts (e.g. a `technicalEndpoint` the
|
|
1015
|
+
// first site never resolved). Merge any fact field the existing
|
|
1016
|
+
// record left empty from this later, non-empty profile.
|
|
1017
|
+
for (const field of RECIPIENT_FACT_FIELDS) {
|
|
1018
|
+
const existingEmpty = existing[field] == null || (Array.isArray(existing[field]) && existing[field].length === 0);
|
|
1019
|
+
const incomingPopulated = profile[field] != null && !(Array.isArray(profile[field]) && profile[field].length === 0);
|
|
1020
|
+
if (existingEmpty && incomingPopulated) {
|
|
1021
|
+
existing[field] = profile[field];
|
|
1022
|
+
existing.fieldEvidence[field] = profile.fieldEvidence[field];
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
} else {
|
|
1026
|
+
recipientProfilesById.set(profile.id, { ...profile, contributingGraphIds: [nodeId] });
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
// Milestone 4, FR-506: unlike `graph.evidence[]` immediately above
|
|
1032
|
+
// (which IS required/core-schema, validated by `validate.js`'s
|
|
1033
|
+
// `_validateEvidence`/`EVIDENCE_TYPES`), `graph.recipientProfiles` is
|
|
1034
|
+
// the FIRST §10.10 extension-record array ever attached directly to
|
|
1035
|
+
// the graph object — every prior extension contract
|
|
1036
|
+
// (ObligationMapping/DecisionStory/GraphSnapshot) is a wholly separate
|
|
1037
|
+
// artifact, never stored on the built graph itself. Never in
|
|
1038
|
+
// `dataflow-graph.schema.json`, never routed through `validateGraph()`.
|
|
1039
|
+
graph.recipientProfiles = [...recipientProfilesById.values()].sort(byId);
|
|
1040
|
+
// M5 deliverable #8 (FR-304 "declared" half only, §10.10): a SIXTH
|
|
1041
|
+
// additive hook of the identical shape — `opts.crossRepoLinks(graph) ->
|
|
1042
|
+
// CrossRepoLink[]`. Runs here, after nodes/edges/flows/dataElements AND
|
|
1043
|
+
// recipientProfiles are populated, mirroring `opts.buildRecipientProfile`'s
|
|
1044
|
+
// own placement exactly: the hook can validate a declared `local.nodeId`
|
|
1045
|
+
// against the CURRENT graph's own real node set (a stale declaration, from
|
|
1046
|
+
// before a node was renamed/removed in a later rescan, is DROPPED and
|
|
1047
|
+
// reported by the hook itself, never silently kept stale — matching
|
|
1048
|
+
// `applyScenario`'s own "skippedOperations, never thrown" honesty
|
|
1049
|
+
// precedent; see `coverage.js`'s default hook for where that drop/report
|
|
1050
|
+
// logic lives). `graph.crossRepoLinks` is always present (mirrors
|
|
1051
|
+
// `graph.recipientProfiles`'s own "always an array, possibly empty" shape,
|
|
1052
|
+
// not `graph.runtimeCorroboration`'s own "genuinely absent when the hook
|
|
1053
|
+
// is omitted" shape — a CrossRepoLink array has no not_evaluated-vs-empty
|
|
1054
|
+
// distinction to preserve the way runtime corroboration does). Never in
|
|
1055
|
+
// `dataflow-graph.schema.json`, never routed through `validateGraph()` —
|
|
1056
|
+
// the SECOND §10.10 extension array ever attached directly to the graph
|
|
1057
|
+
// object (after `graph.recipientProfiles[]`).
|
|
1058
|
+
graph.crossRepoLinks = typeof opts.crossRepoLinks === 'function'
|
|
1059
|
+
? [...(opts.crossRepoLinks(graph) || [])].sort(byId)
|
|
1060
|
+
: [];
|
|
1061
|
+
// M5 deliverable #7 (FR-505 §7.12, AC-29): a FIFTH additive hook of the
|
|
1062
|
+
// identical shape — `opts.correlateObservations(graph) -> correlationResult
|
|
1063
|
+
// | undefined`. Runs here, after nodes/edges/flows/dataElements AND
|
|
1064
|
+
// recipientProfiles are populated, for the same reason
|
|
1065
|
+
// `opts.buildRecipientProfile` was moved here in its own fix round: the hook
|
|
1066
|
+
// reads the real, finished graph, never a still-empty envelope.
|
|
1067
|
+
//
|
|
1068
|
+
// Assigned ONLY when the hook returns truthy — `graph.runtimeCorroboration`
|
|
1069
|
+
// is genuinely ABSENT otherwise, never `null` and never `{}`. That is what
|
|
1070
|
+
// makes the output byte-identical to a build with no hook at all (the
|
|
1071
|
+
// contract `M2A1/hook-1` proved for `resolveDestination`), and it is also
|
|
1072
|
+
// AC-29 clause 2's `not_evaluated` state expressed at the top level: a graph
|
|
1073
|
+
// with no `runtimeCorroboration` key was never evaluated against any
|
|
1074
|
+
// observation store, which is a DIFFERENT answer from a graph that was
|
|
1075
|
+
// evaluated and found nothing in the window (PRD line 2098 — non-observation
|
|
1076
|
+
// is never non-occurrence).
|
|
1077
|
+
//
|
|
1078
|
+
// Deliberately does NOT touch `edge.provenance`, which stays `'code'` on
|
|
1079
|
+
// every edge. Corroboration is ADDITIVE: an observed edge is still a
|
|
1080
|
+
// code-derived edge that was ALSO observed. Flipping `edge.provenance` needs
|
|
1081
|
+
// a deliberate provenance-partitioning pass through impact-engine.js,
|
|
1082
|
+
// decision-story.js, obligation-predicates.js and the export family first —
|
|
1083
|
+
// that is M2 Sub-project F2/F3's job, not this one's. See the M5 #7 scoping
|
|
1084
|
+
// doc §4.5 and §3's "edge.provenance has zero consumers" finding.
|
|
1085
|
+
if (typeof opts.correlateObservations === 'function') {
|
|
1086
|
+
const corroboration = opts.correlateObservations(graph);
|
|
1087
|
+
if (corroboration) graph.runtimeCorroboration = corroboration;
|
|
1088
|
+
}
|
|
1089
|
+
// §10's SKETCH of the coverage ledger. E4 owns the finished contract.
|
|
1090
|
+
graph.coverage = {
|
|
1091
|
+
languages: [], parseFailures: [],
|
|
1092
|
+
destinationResolutionStatus: 'not-attempted',
|
|
1093
|
+
pathBudgetTruncation: stats.truncatedQueries > 0,
|
|
1094
|
+
sources: { matched: seeds.length, unseedable: unseedable.length, dataElements: deById.size },
|
|
1095
|
+
sinks: {
|
|
1096
|
+
callStatementSites: sites.length,
|
|
1097
|
+
connected: stats.connectedSinkSites,
|
|
1098
|
+
disconnected: sites.length - stats.connectedSinkSites,
|
|
1099
|
+
nonStatementSitesNotEnumerable: nonStatementSites.length,
|
|
1100
|
+
},
|
|
1101
|
+
degradedTerminals: degraded.length,
|
|
1102
|
+
paths: { enumerated: stats.pathsEnumerated, projected: stats.pathsProjected, truncatedQueries: stats.truncatedQueries },
|
|
1103
|
+
provenance: { hops: hops.length, pnodes: store.stats().nodes, pedges: store.stats().edges },
|
|
1104
|
+
};
|
|
1105
|
+
graph.limitations = [
|
|
1106
|
+
'Protection verdicts are not assessed in Milestone 1: every edge carries not_assessed/none on all three dimensions.',
|
|
1107
|
+
'Policy verdicts are not evaluated in Milestone 1.',
|
|
1108
|
+
'External destinations are not resolved (FR-202 is Milestone 2); protocol.destinationResolution is "unknown" on every edge.',
|
|
1109
|
+
];
|
|
1110
|
+
graph.scanHealth = { status: 'complete', reason: 'lineage-graph-builder' };
|
|
1111
|
+
graph.taxonomy = { version: '1.0.0', source: 'built-in + CONFIDENTIAL extension' };
|
|
1112
|
+
|
|
1113
|
+
return { graph, store, hops, seeds, unseedable, sites, nonStatementSites, degraded, stats, decisionsByNodeId };
|
|
1114
|
+
}
|