@clear-capabilities/agentic-security-scanner 0.145.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 +139 -0
- package/bin/agentic-security.js +3508 -69
- 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} +2 -2
- package/dist/{660.index.js → 1660.index.js} +2 -2
- package/dist/{700.index.js → 1700.index.js} +2 -2
- package/dist/{905.index.js → 1905.index.js} +3 -3
- package/dist/{920.index.js → 1920.index.js} +3 -3
- package/dist/{238.index.js → 2238.index.js} +3 -3
- package/dist/{271.index.js → 2271.index.js} +2 -2
- package/dist/{985.index.js → 2376.index.js} +1260 -340
- package/dist/2432.index.js +793 -0
- package/dist/2659.index.js +93 -0
- package/dist/{826.index.js → 2826.index.js} +2 -2
- package/dist/{830.index.js → 2830.index.js} +2 -2
- package/dist/2923.index.js +298 -0
- package/dist/{1.index.js → 3001.index.js} +5 -5
- package/dist/{117.index.js → 3117.index.js} +3 -3
- package/dist/3180.index.js +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} +5 -5
- package/dist/{736.index.js → 3736.index.js} +4 -4
- package/dist/{839.index.js → 3839.index.js} +4 -4
- package/dist/{113.index.js → 4113.index.js} +5 -5
- package/dist/{265.index.js → 4265.index.js} +2 -2
- package/dist/{384.index.js → 4384.index.js} +3 -3
- package/dist/4547.index.js +268 -0
- package/dist/4863.index.js +422 -0
- package/dist/{970.index.js → 4970.index.js} +2 -2
- package/dist/5051.index.js +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} +3 -3
- package/dist/{449.index.js → 5830.index.js} +2 -2
- package/dist/6626.index.js +532 -0
- package/dist/6662.index.js +297 -0
- package/dist/{675.index.js → 6675.index.js} +5 -5
- package/dist/{730.index.js → 6730.index.js} +6 -6
- package/dist/6829.index.js +225 -0
- package/dist/6944.index.js +130 -0
- package/dist/{178.index.js → 7178.index.js} +3 -3
- package/dist/{227.index.js → 7227.index.js} +2 -2
- package/dist/7310.index.js +520 -0
- package/dist/{552.index.js → 7552.index.js} +4 -4
- package/dist/7709.index.js +78 -0
- package/dist/8218.index.js +160 -0
- package/dist/{476.index.js → 8476.index.js} +4 -4
- package/dist/{513.index.js → 8513.index.js} +5 -5
- package/dist/{520.index.js → 8520.index.js} +2 -2
- package/dist/{718.index.js → 8718.index.js} +2 -2
- package/dist/{752.index.js → 8752.index.js} +2 -2
- package/dist/8846.index.js +100 -0
- package/dist/{435.index.js → 9091.index.js} +560 -189
- package/dist/{207.index.js → 9207.index.js} +2 -2
- package/dist/{220.index.js → 9220.index.js} +2 -2
- package/dist/9390.index.js +163 -0
- package/dist/{503.index.js → 9503.index.js} +2 -2
- package/dist/{801.index.js → 9801.index.js} +2 -2
- package/dist/{824.index.js → 9824.index.js} +2 -2
- package/dist/agentic-security.mjs +16 -16
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/compliance-frameworks/hipaa-security-rule.json +3 -2
- package/package.json +12 -5
- package/src/dataflow/CLAUDE.md +1 -1
- package/src/dataflow/catalog.js +42 -0
- package/src/dataflow/orm-write-catalog.js +175 -0
- package/src/engine.js +92 -1
- package/src/ir/CLAUDE.md +1 -0
- package/src/ir/chrome-probe.mjs +150 -0
- package/src/ir/parser-js.js +94 -7
- package/src/lineage/CLAUDE.md +1203 -0
- package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
- package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
- package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
- package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
- package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
- package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
- package/src/lineage/DESIGN_REGISTRIES.md +880 -0
- package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
- package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
- package/src/lineage/classification.js +56 -0
- package/src/lineage/coverage.js +658 -0
- package/src/lineage/cross-repo-link.js +107 -0
- package/src/lineage/dataflow-graph.schema.json +184 -0
- package/src/lineage/decision-story.js +206 -0
- package/src/lineage/drift-policy.js +279 -0
- package/src/lineage/driver.js +135 -0
- package/src/lineage/engine.js +992 -0
- package/src/lineage/export-briefing.js +628 -0
- package/src/lineage/export-csv.js +62 -0
- package/src/lineage/export-json.js +238 -0
- package/src/lineage/export-privacy.js +258 -0
- package/src/lineage/federation-loader.js +111 -0
- package/src/lineage/field-identity.js +78 -0
- package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
- package/src/lineage/fixtures/flagship-graph.json +1453 -0
- package/src/lineage/flow-grade.js +221 -0
- package/src/lineage/governance-edit.js +169 -0
- package/src/lineage/graph-builder.js +1114 -0
- package/src/lineage/graph-diff.js +431 -0
- package/src/lineage/graph-snapshot.js +180 -0
- package/src/lineage/handling-analyzer.js +168 -0
- package/src/lineage/ids.js +349 -0
- package/src/lineage/impact-assessment.js +76 -0
- package/src/lineage/impact-engine.js +268 -0
- package/src/lineage/index.js +281 -0
- package/src/lineage/language-coverage-tiers.js +58 -0
- package/src/lineage/obligation-mapping.js +126 -0
- package/src/lineage/obligation-predicates.js +235 -0
- package/src/lineage/observation-adapters.js +282 -0
- package/src/lineage/observation-correlation.js +622 -0
- package/src/lineage/observation-store.js +497 -0
- package/src/lineage/path-query.js +410 -0
- package/src/lineage/path-store.js +400 -0
- package/src/lineage/protection.js +53 -0
- package/src/lineage/recipient-profile.js +192 -0
- package/src/lineage/recipient-registry.js +394 -0
- package/src/lineage/redact-graph.js +224 -0
- package/src/lineage/remediation.js +417 -0
- package/src/lineage/resolve-destination.js +91 -0
- package/src/lineage/runtime-observation.js +464 -0
- package/src/lineage/scenario-diff.js +84 -0
- package/src/lineage/scenario-engine.js +251 -0
- package/src/lineage/scenario.js +101 -0
- package/src/lineage/schema.js +167 -0
- package/src/lineage/sink-registry.js +427 -0
- package/src/lineage/source-registry.js +357 -0
- package/src/lineage/source-seeding.js +212 -0
- package/src/lineage/summaries.js +590 -0
- package/src/lineage/transform-catalog.js +397 -0
- package/src/lineage/transit-protection.js +150 -0
- package/src/lineage/validate.js +285 -0
- package/src/mcp/CLAUDE.md +7 -1
- package/src/mcp/dataflow-tools.js +160 -0
- package/src/mcp/server.js +1 -1
- package/src/mcp/tools.js +2 -1
- package/src/pipeline/scan-health.js +19 -1
- package/src/posture/CLAUDE.md +19 -0
- package/src/posture/artifact-registry.js +52 -0
- package/src/posture/auditor-walkthrough.js +76 -0
- package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
- package/src/posture/obligation-evidence-pack.js +202 -0
- package/src/posture/remediation-ledger.js +337 -0
- package/src/server/CLAUDE.md +47 -0
- package/src/server/graph-loader.js +141 -0
- package/src/server/http-server.js +325 -0
- package/src/server/routes.js +129 -0
- package/src/server/security.js +111 -0
- package/src/server/static-assets.js +139 -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,938 @@
|
|
|
1
|
+
# DESIGN_GRAPH_BUILDER.md — Sub-project E's binding design record
|
|
2
|
+
|
|
3
|
+
**Status:** landed as Sub-project E, increment **E1** (design spike). Binding
|
|
4
|
+
on E2, E3, E4 and E5, the same way `DESIGN_INTRAPROCEDURAL.md` binds
|
|
5
|
+
Sub-projects A/B, `DESIGN_PATH_PROVENANCE.md` binds C, and
|
|
6
|
+
`DESIGN_REGISTRIES.md` binds D.
|
|
7
|
+
|
|
8
|
+
Every number in this document was produced by running real code against the
|
|
9
|
+
live tree in the increment that wrote it — never quoted from an upstream
|
|
10
|
+
scoping document. The proofs live in `test/lineage/graph-builder-poc.test.js`
|
|
11
|
+
(deliberately throwaway; see §9.1) and `test/lineage/driver.test.js`'s three
|
|
12
|
+
`E1/driver-*` tests. Where this document and that PoC disagree, the PoC is
|
|
13
|
+
right and this document is stale — fix it here, do not fork it.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## 1. What this sub-project actually is
|
|
18
|
+
|
|
19
|
+
A-D built machinery. **Nothing calls it.** `grep -rn "lineage/" src bin` from
|
|
20
|
+
`scanner/`, excluding `src/lineage/` itself, returns exactly one hit and it is
|
|
21
|
+
a comment. Every one of the fifteen shipped modules is reachable only from
|
|
22
|
+
`test/lineage/*`.
|
|
23
|
+
|
|
24
|
+
Sub-project E is the first increment family whose deliverable is an
|
|
25
|
+
**artifact** rather than a capability: a `DataFlowGraph v1` document, produced
|
|
26
|
+
from a real repository, that `validateGraph()` accepts and that a human can
|
|
27
|
+
read without being lied to.
|
|
28
|
+
|
|
29
|
+
Two mechanisms had to be settled before any of E2-E5 could be written, and
|
|
30
|
+
both are settled here:
|
|
31
|
+
|
|
32
|
+
1. **Seeding** (§3) — how a real call site becomes a field-identity entry
|
|
33
|
+
state. Today the shipped driver hardcodes `emptyState()` and produces
|
|
34
|
+
**exactly zero hops** on real code. Measured, twice, in `E1/1`.
|
|
35
|
+
2. **Projection** (§6) — how a provenance DAG that is *variable*-granular
|
|
36
|
+
(494 pnodes for a 33-file project) becomes a `DataFlowGraph v1` document
|
|
37
|
+
that is *system*-granular (14 nodes for a whole synthetic platform).
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## 2. Ground truth, re-measured in this increment
|
|
42
|
+
|
|
43
|
+
### 2.1 The shipped driver, unchanged, on real code
|
|
44
|
+
|
|
45
|
+
| Project | files | functions | **hops** | pnodes / pedges | `sinkCandidates()` |
|
|
46
|
+
|---|---|---|---|---|---|
|
|
47
|
+
| `test/fixtures/vulnerable-js` | 1 | 8 | **0** | 0 / 0 | 0 |
|
|
48
|
+
| `frontend/` | 33 | 441 | **0** | 0 / 0 | 0 |
|
|
49
|
+
|
|
50
|
+
Reproduced exactly. `E1/1` pins it as a shipped test so the gap cannot close
|
|
51
|
+
by accident.
|
|
52
|
+
|
|
53
|
+
### 2.2 The registry matchers, at real call sites in `vulnerable-js`
|
|
54
|
+
|
|
55
|
+
- `matchSource` — **9 call-site matches, 3 distinct entries**
|
|
56
|
+
(`js-req-body`, `js-req-params`, `js-req-query`). Reproduced exactly.
|
|
57
|
+
- `matchSinkOrSanitizer` (sink-kind hits only) — **12 matches, 6 distinct
|
|
58
|
+
entries**, *when counted once per CFG `call` statement node*. Counting every
|
|
59
|
+
call sub-expression instead gives **13**, because `res.send(eval(x))` and
|
|
60
|
+
the separate CFG node for `eval(x)` are the same call twice. The scoping
|
|
61
|
+
doc's 12 is the per-CFG-node count, and **the per-CFG-node count is the one
|
|
62
|
+
the design uses** (§4.1) — not for agreement's sake, but because a CFG
|
|
63
|
+
`call` statement node is the only shape that produces an `escape`
|
|
64
|
+
provenance node to reconstruct from.
|
|
65
|
+
|
|
66
|
+
### 2.3 The synthetic-seed comparison, and one honest non-reproduction
|
|
67
|
+
|
|
68
|
+
`vulnerable-js` reproduces the scoping doc's synthetic-seed table **in every
|
|
69
|
+
column**: 7 functions seeded, 21/21 hops offered/accepted, 9 join groups,
|
|
70
|
+
16 pnodes / 9 pedges, 9 sink candidates (all `escape`), 9 paths, all complete.
|
|
71
|
+
|
|
72
|
+
`frontend/` does **not** reproduce (doc: 1253/1048 hops, 369 groups, 494/418,
|
|
73
|
+
234 candidates). The IR is provably identical — 33 files, 441 functions, both
|
|
74
|
+
runs — so the divergence is entirely in the scoping doc's own unspecified
|
|
75
|
+
seeding harness. Six variants were tried (per-parameter vs. one shared
|
|
76
|
+
identity × `cache.set` keyed on the seeded state / on `emptyState()` / not at
|
|
77
|
+
all); the closest was per-parameter + seeded key at **1305/1133**. This is
|
|
78
|
+
recorded rather than chased: the synthetic seed is exactly what §3 replaces,
|
|
79
|
+
and the fixture the design is actually proven against reproduces to the digit.
|
|
80
|
+
|
|
81
|
+
**One variant pair is a real finding, not harness noise:** on `frontend/`,
|
|
82
|
+
keying `cache.set` on `emptyState()` (the shipped line) rather than on the
|
|
83
|
+
state actually analyzed changes accepted hops from **1133 to 1448** and sink
|
|
84
|
+
candidates from **247 to 348** — a 27% swing. §3.4 is why.
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## 3. Seeding
|
|
89
|
+
|
|
90
|
+
### 3.1 Where the expressions come from
|
|
91
|
+
|
|
92
|
+
Walk `fn.cfg.nodes` and, per node, the **same expression roots
|
|
93
|
+
`engine.js`'s own `step()` switch reads**: `assign` → `source`, `call` →
|
|
94
|
+
`callee` + `args`, `return` → `value`. Then recurse into every
|
|
95
|
+
sub-expression.
|
|
96
|
+
|
|
97
|
+
Deliberately **not** `fn.reads` / `fn.calls`: D5 already measured that a call
|
|
98
|
+
used as an assignment RHS never reaches `fn.calls[]` at all. Those
|
|
99
|
+
side-channels are incomplete for this purpose and using them would silently
|
|
100
|
+
lose sources. `fn.cfg.nodes` is a plain `Object` at runtime, not a `Map`
|
|
101
|
+
(also D5's finding — `ir/CLAUDE.md`'s header is wrong about this).
|
|
102
|
+
|
|
103
|
+
### 3.2 The seed-path rule — seed the FIELD, not the container
|
|
104
|
+
|
|
105
|
+
`matchSource` matches `req.body`. The thing that has a field identity is
|
|
106
|
+
`req.body.card_number`. **Extend the matched expression outward through every
|
|
107
|
+
enclosing pure-member access, then take `accessPathOf` of the outermost
|
|
108
|
+
node.** Fall back to the matched expression's own path when it is not the
|
|
109
|
+
object of a member access (`User.create(req.body)`) — that container-level
|
|
110
|
+
seed is the honest answer for that shape.
|
|
111
|
+
|
|
112
|
+
This is not cosmetic. On `vulnerable-js` it is the difference between six
|
|
113
|
+
data elements named `body`/`params`/`query` and six named
|
|
114
|
+
`id`/`password`/`name`/`expr`/`body`/`host` — and
|
|
115
|
+
`classifyDataElementName('password')` returns `['CREDENTIALS']` while
|
|
116
|
+
`classifyDataElementName('body')` returns `[]`. **Classification is
|
|
117
|
+
impossible without this rule.** `E1/2` pins both halves.
|
|
118
|
+
|
|
119
|
+
Seeding at the deeper path is sound: `identitiesAt` aggregates
|
|
120
|
+
bidirectionally, so a later read of `req.body` as a whole still sees every
|
|
121
|
+
field seeded under it, while a read of an *unmatched* sibling correctly sees
|
|
122
|
+
nothing.
|
|
123
|
+
|
|
124
|
+
### 3.3 The seed record
|
|
125
|
+
|
|
126
|
+
One record per matched call site:
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
{ file, qid, nodeId, line, entryId, seedPath, canonicalName,
|
|
130
|
+
category, coverageStatus, externality, reason, // <- reclassifySource(entry)
|
|
131
|
+
dataElementId, dataClasses } // <- ids.js + classification.js
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
A matched expression with **no** access path (`accessPathOf` → `null`) is
|
|
135
|
+
recorded in an `unseedable[]` list and counted in the coverage ledger — never
|
|
136
|
+
dropped. Measured on `vulnerable-js`: zero unseedable.
|
|
137
|
+
|
|
138
|
+
### 3.4 `dataElementId` minting (E1 item **(b)**)
|
|
139
|
+
|
|
140
|
+
```
|
|
141
|
+
dataElementId(canonicalName, [repository, file, seedPath, category ?? ''])
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
- `canonicalName` is the **last segment of the seed path** (`card_number`),
|
|
145
|
+
which is what `classifyDataElementName` needs.
|
|
146
|
+
- PRD §10.4 forbids an identifier based only on the field name. The
|
|
147
|
+
discriminator carries a **system proxy** (`repository` + `file`), the
|
|
148
|
+
**access path**, and the **category**.
|
|
149
|
+
- `file` is Milestone 1's only available proxy for §10.4's "service or
|
|
150
|
+
schema". When a real service/module boundary signal exists (Milestone 2's
|
|
151
|
+
system attribution), it belongs in the discriminator *ahead of* `file`, not
|
|
152
|
+
instead of it.
|
|
153
|
+
- **Function scope (`qid`) is deliberately NOT in the discriminator.** Two
|
|
154
|
+
handlers in one file reading `req.body.email` are one logical field far more
|
|
155
|
+
often than two; §10.4's rule is about not merging across *services*, and it
|
|
156
|
+
is already satisfied. Over-splitting is not free — it fragments the graph
|
|
157
|
+
without evidence in either direction.
|
|
158
|
+
|
|
159
|
+
`E1/3` pins all four directions: same name in two files → two ids; two fields
|
|
160
|
+
in one file → two ids; the same field read twice in one file → one id; and
|
|
161
|
+
`dataElementId(name, [])` never equals the real id.
|
|
162
|
+
|
|
163
|
+
### 3.5 Where seeding lives (E1 item **(a)**) — `driver.js` gets an additive hook
|
|
164
|
+
|
|
165
|
+
**Decision: `opts.seedEntryState(fn) -> state | falsy`, on
|
|
166
|
+
`runFieldIdentityAnalysis`. Implemented in this increment.**
|
|
167
|
+
|
|
168
|
+
The alternative — a wrapper that calls `driver.js`'s lower-level pieces —
|
|
169
|
+
was rejected for three measured reasons:
|
|
170
|
+
|
|
171
|
+
1. The wrapper would have to duplicate the per-function
|
|
172
|
+
`createCallGraphLookup` / `createCallSummaryResolver` construction
|
|
173
|
+
verbatim. That is a fork that will drift. B4 extracted
|
|
174
|
+
`summaryFromAnalysisResult` precisely to avoid a second, drifting copy of
|
|
175
|
+
a smaller piece of logic than this.
|
|
176
|
+
2. **The cache-overwrite hazard is inside `driver.js` and a wrapper cannot
|
|
177
|
+
fix it** — it would have to reimplement the loop anyway, i.e. become the
|
|
178
|
+
fork in point 1.
|
|
179
|
+
3. `opts.recordHop` already established exactly this additive-hook contract
|
|
180
|
+
for exactly this file.
|
|
181
|
+
|
|
182
|
+
**Backward compatibility is proven, not asserted.** With the hook omitted,
|
|
183
|
+
`entryState` *is* `emptyState()`, so both the `analyzeFunctionFieldIdentity`
|
|
184
|
+
call and the `cache.set` below it are byte-identical to the pre-hook line.
|
|
185
|
+
`E1/driver-1` pins the results and the cache keys against a **hardcoded
|
|
186
|
+
golden literal captured from the pre-hook `driver.js`** — never against the
|
|
187
|
+
shipped implementation, which is C3 §13.2a's vacuous-test trap.
|
|
188
|
+
|
|
189
|
+
### 3.6 The cache-overwrite hazard, and the half nobody had named
|
|
190
|
+
|
|
191
|
+
`driver.js`'s own comment disclosed that `cache.set(fn.qid, emptyState(), …)`
|
|
192
|
+
can overwrite a lazily-computed entry with a *different* value, and that this
|
|
193
|
+
"becomes load-bearing the moment entry states carry real identities."
|
|
194
|
+
|
|
195
|
+
It named the wrong half. The worse half is the **KEY**. Writing a summary
|
|
196
|
+
under `emptyState()` claims "this is what `fn` does when nothing flows in."
|
|
197
|
+
Once a seed puts real identities into `fn`'s entry state that claim is
|
|
198
|
+
**false**, and a later call site resolving `fn` with clean arguments builds an
|
|
199
|
+
empty callee entry state, hits that key, and is handed return facts that exist
|
|
200
|
+
only because the driver seeded them.
|
|
201
|
+
|
|
202
|
+
Measured on a two-function real-parser fixture (`E1/driver-3`):
|
|
203
|
+
|
|
204
|
+
| keying | `caller`'s local `v` after `const v = helper(o)` |
|
|
205
|
+
|---|---|
|
|
206
|
+
| `cache.set(qid, emptyState(), …)` — pre-fix | `['data:SEEDED']` — **fabricated** |
|
|
207
|
+
| `cache.set(qid, entryState, …)` — shipped | `[]` — correct |
|
|
208
|
+
|
|
209
|
+
The fix is one identifier, and it is a no-op when no hook is supplied. It also
|
|
210
|
+
improves `FieldIdentitySummaryCache.compute`'s cap-degradation fallback
|
|
211
|
+
(`this._cache.get(_key(qid, emptyState())) ?? emptyFieldSummary()`), which
|
|
212
|
+
previously degraded to a *seeded* summary and now degrades to an honestly
|
|
213
|
+
empty one.
|
|
214
|
+
|
|
215
|
+
`E1/driver-3` proves **both** directions: a local replica of the pre-fix loop
|
|
216
|
+
(the mutant) fabricates; the shipped driver does not.
|
|
217
|
+
|
|
218
|
+
### 3.7 What real seeding measures
|
|
219
|
+
|
|
220
|
+
| Project | seeds | hops | pnodes / pedges | node kinds reached |
|
|
221
|
+
|---|---|---|---|---|
|
|
222
|
+
| `vulnerable-js` | 9 | **23** | 15 / 9 | `path`, `escape` |
|
|
223
|
+
| `frontend/` | **0** | 0 | 0 / 0 | — |
|
|
224
|
+
|
|
225
|
+
(Re-measured after the `lineage-engine-receiver-identity-hotfix` — task
|
|
226
|
+
review MF-2. The receiver-identity fix adds real intraprocedural hops that
|
|
227
|
+
were previously silently dropped, so these numbers moved from the design
|
|
228
|
+
task's own original measurement: **19** hops / 14 pnodes / 8 pedges. See
|
|
229
|
+
§11 item 1's RESOLVED note.)
|
|
230
|
+
|
|
231
|
+
Two things follow, and E2/F must not be surprised by either:
|
|
232
|
+
|
|
233
|
+
- **A real seed's `pnodes` count is narrower than the synthetic
|
|
234
|
+
per-parameter seed's, though `hops` and `pedges` are not** (task review
|
|
235
|
+
MF-3 corrected this claim's original overreach): post-fix, `pnodes` is
|
|
236
|
+
15 vs the synthetic seed's 16 (narrower, by 1); `hops` is 23 vs 21
|
|
237
|
+
(WIDER, since the receiver fix surfaces real hops the synthetic seed's
|
|
238
|
+
own per-parameter approach never modeled); `pedges` is 9 vs 9 (tied). The
|
|
239
|
+
real seed still seeds only what a registry matched — that property
|
|
240
|
+
holds — but "strictly narrower on every dimension" no longer does, and
|
|
241
|
+
this table states the true per-dimension comparison rather than the
|
|
242
|
+
original, now-false blanket claim.
|
|
243
|
+
- **`frontend/` produces zero seeds and therefore zero hops** — not a bug. It
|
|
244
|
+
is a browser prototype with no HTTP-request-shaped source in the catalog.
|
|
245
|
+
Every non-zero number the scoping doc measured for `frontend/` came from the
|
|
246
|
+
synthetic seed. **`frontend/` is not a usable Sub-project E benchmark
|
|
247
|
+
target**; F must pick corpora that contain real sources.
|
|
248
|
+
- Still **zero `loss`, zero `origin`, zero `return`** node kinds on real code,
|
|
249
|
+
matching §2.1's own finding. A "complete" path terminates at an ordinary
|
|
250
|
+
`path` node; §6.3 is how the projection knows which of those is a source.
|
|
251
|
+
|
|
252
|
+
---
|
|
253
|
+
|
|
254
|
+
## 4. Registry-backed sink enumeration
|
|
255
|
+
|
|
256
|
+
### 4.1 The enumeration unit is a CFG `call` STATEMENT node
|
|
257
|
+
|
|
258
|
+
`engine.js`'s `step()` emits `write-out`/`call-arg` — the hop that becomes an
|
|
259
|
+
`escape` provenance node — **only** for a bare `call` CFG node. So that is the
|
|
260
|
+
only shape a sink-rooted `reconstructPaths` can start from.
|
|
261
|
+
|
|
262
|
+
Every other call expression (an assignment RHS, a return value, a nested
|
|
263
|
+
argument) that matches a sink entry is **counted into
|
|
264
|
+
`coverage.sinks.nonStatementSitesNotEnumerable`, never silently dropped**.
|
|
265
|
+
Measured on `vulnerable-js`: 11 statement sites, 1 non-statement site. This is
|
|
266
|
+
a real, named gap for E2/E3 (§11, item 3), not a design choice.
|
|
267
|
+
|
|
268
|
+
Locating a site's escape nodes needs no scan of the store's internals:
|
|
269
|
+
`escape` nodes carry `(scope, siteNodeId)` and are keyed by `(fn.qid,
|
|
270
|
+
cfgNodeId)`. Multiple contexts of one function simply yield multiple escapes
|
|
271
|
+
at the same key, which is correct.
|
|
272
|
+
|
|
273
|
+
### 4.2 Both catalogs, and one precision cost
|
|
274
|
+
|
|
275
|
+
The enumerator consults `matchSinkOrSanitizer` **and** `matchPrivacySink`.
|
|
276
|
+
Dropping the privacy catalog would drop `logger.info`, i.e. AC-02's own worked
|
|
277
|
+
example.
|
|
278
|
+
|
|
279
|
+
The cost is real and must be in the ledger: `matchPrivacySink` supports no
|
|
280
|
+
receiver constraint at all, so `privacy-js-axios-post` matches
|
|
281
|
+
`app.post('/ping', handler)` on the bare callee name. **3 of `vulnerable-js`'s
|
|
282
|
+
11 sink sites are that false match**, and they surface as a disconnected
|
|
283
|
+
`external`/`external-api` node with zero data elements. That is honest
|
|
284
|
+
behaviour (a matched sink nothing reached), but E4 must count it and Sub-project
|
|
285
|
+
F must not treat it as recall.
|
|
286
|
+
|
|
287
|
+
### 4.3 Multi-candidate resolution (E1 item **(d)**) — represent, never pick
|
|
288
|
+
|
|
289
|
+
Three steps, in order:
|
|
290
|
+
|
|
291
|
+
1. **Promote via the receiver that actually matched.** Keep the candidates
|
|
292
|
+
whose entry declares `match.receiver` or `match.receiverBase` — the
|
|
293
|
+
matcher already verified those textually. If exactly one survives, use it,
|
|
294
|
+
**coverageStatus unchanged**.
|
|
295
|
+
- `match.receiverTypeIn` deliberately does **not** count: it gates on a
|
|
296
|
+
CHA-resolved class type and is vacuously allowed whenever no
|
|
297
|
+
`receiverType` is supplied, which is always here. D5 already measured
|
|
298
|
+
this exact trap; treating it as specificity would promote on evidence
|
|
299
|
+
that was never checked.
|
|
300
|
+
- This reads a returned candidate's own declared constraint. It does not
|
|
301
|
+
re-run any matching, so the Global Constraint against re-deriving
|
|
302
|
+
`catalog.js`'s work holds.
|
|
303
|
+
2. **Unanimous category** among the survivors → use it.
|
|
304
|
+
3. **Plurality**, otherwise: one node at the plurality category,
|
|
305
|
+
`coverageStatus: 'partial'`, and a `reason` naming every alternative
|
|
306
|
+
category *and* every candidate entry id — `sink-registry.js`'s own
|
|
307
|
+
`thirdPartySdk` convention, reused rather than a second convention
|
|
308
|
+
invented.
|
|
309
|
+
|
|
310
|
+
Measured (`E1/9`):
|
|
311
|
+
|
|
312
|
+
| call site | candidates | resolution |
|
|
313
|
+
|---|---|---|
|
|
314
|
+
| `res.send(x)` | `js-express-res-send`, `js-koa-send`, `privacy-js-res-send` | **receiver** → `http-response`, `modeled` |
|
|
315
|
+
| `ctx.send(x)` | `js-koa-send`, `privacy-js-res-send` — task review MF-3: `js-express-res-send` is filtered out here by `matchSinkOrSanitizer`'s own `_receiverAllowed`, since the receiver isn't `res`; only TWO candidates, not three | **plurality** → `file`, `partial`, alternatives named |
|
|
316
|
+
| bare `send(x)` | same two as `ctx.send(x)` | **plurality** → `file`, `partial`, alternatives named |
|
|
317
|
+
|
|
318
|
+
**Disclosed weakness:** step 3's "plurality" is frequently a 1-1 tie, and the
|
|
319
|
+
tie is broken by lexicographic category order. That is deterministic but
|
|
320
|
+
arbitrary. It is mitigated, not solved, by the mandatory demotion to `partial`
|
|
321
|
+
and by naming every alternative in the reason — a reader is never shown a
|
|
322
|
+
confident wrong answer. A real fix needs per-call-site type information the
|
|
323
|
+
lineage package does not have.
|
|
324
|
+
|
|
325
|
+
---
|
|
326
|
+
|
|
327
|
+
## 5. §16.7 Finding 2's enumerator (E1 item **(e)**) — RESOLVED, `diagnostics()`-union
|
|
328
|
+
|
|
329
|
+
`DESIGN_PATH_PROVENANCE.md` §16.7 offers two mechanisms. **E uses the union
|
|
330
|
+
mechanism**, and the question it left open — "is this genuinely computable
|
|
331
|
+
from `path-store.js`'s public read API alone?" — is answered **yes**,
|
|
332
|
+
measured, not argued:
|
|
333
|
+
|
|
334
|
+
```js
|
|
335
|
+
store.nodes()
|
|
336
|
+
.filter((n) => store.edgesFrom(n.id).length === 0)
|
|
337
|
+
.filter((n) => store.edgesTo(n.id).length > 0)
|
|
338
|
+
.filter((n) => store.edgesTo(n.id).some((e) =>
|
|
339
|
+
e.lossReasons.some((r) => DEGRADED_LOSS_REASONS.includes(r))
|
|
340
|
+
|| e.annotations.some((a) => DEGRADED_LOSS_REASONS.includes(a.lossReason))))
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
Three public methods, one exported constant from `flow-grade.js`. **No change
|
|
344
|
+
to `path-store.js`, and no sixth node kind** (which would have moved every
|
|
345
|
+
`pnode:`/`pedge:`/`ppath:` id in the tree).
|
|
346
|
+
|
|
347
|
+
The `annotations[]` half of the check is not optional: §16.5 measured that a
|
|
348
|
+
genuine degradation reason can live *only* there, with the edge's own
|
|
349
|
+
`lossReasons` empty.
|
|
350
|
+
|
|
351
|
+
Proven live in `E1/10` on real parsed code, in both directions: at
|
|
352
|
+
`maxContextsPerFn: 16` there is no degradation and the enumerator finds
|
|
353
|
+
**zero** terminals (it does not over-fire); at `maxContextsPerFn: 2` a real
|
|
354
|
+
`context-cap-degraded` hop appears and the enumerator finds **exactly one**
|
|
355
|
+
`path` node with zero out-edges — a node `sinkCandidates()` cannot return,
|
|
356
|
+
because its kind is not `return`/`escape`/`loss`. That is §16.7 Finding 2
|
|
357
|
+
exactly, closed.
|
|
358
|
+
|
|
359
|
+
The node minted for it uses **the vocabulary already fixed by
|
|
360
|
+
`DESIGN_REGISTRIES.md`'s closing section**, re-derived nowhere:
|
|
361
|
+
`kind: 'unresolved'`, `coverageStatus: 'partial'`, `externality: 'unknown'`,
|
|
362
|
+
with a `reason` naming the context-cap degradation.
|
|
363
|
+
|
|
364
|
+
**Keep it structurally distinct from FR-203's `unresolved`.** FR-203 (E4's
|
|
365
|
+
other closure) is `reclassifySink(entry, {destinationUnresolved,
|
|
366
|
+
blockingExpression})`: classification *succeeded*, only the destination is
|
|
367
|
+
dynamic, so `coverageStatus` carries over **unchanged**. §16.7's is
|
|
368
|
+
`partial` **unconditionally**, because the analysis itself is incomplete.
|
|
369
|
+
`sink-registry.js`'s header warns about conflating them at length; do not.
|
|
370
|
+
|
|
371
|
+
---
|
|
372
|
+
|
|
373
|
+
## 6. The projection (E1 item **(c)**)
|
|
374
|
+
|
|
375
|
+
### 6.1 The rule, in one sentence
|
|
376
|
+
|
|
377
|
+
**A `DataFlowGraph v1` node is a REGISTRY DECISION. An edge and a flow are a
|
|
378
|
+
PATH.**
|
|
379
|
+
|
|
380
|
+
Node identity is
|
|
381
|
+
`nodeId(kind, [repository, subtypeKey, coverageStatus, externality, destination])`
|
|
382
|
+
where `subtypeKey` is the registry `category`, or — for a null-category
|
|
383
|
+
decision — `unsupported-sink:<CWE>` / `unsupported-source:<entryId>` so
|
|
384
|
+
`js-exec` (CWE-78) and `js-eval` (CWE-95) stay two nodes rather than
|
|
385
|
+
collapsing into one anonymous `process`.
|
|
386
|
+
|
|
387
|
+
`destination` is in the discriminator and is **always `''` in Milestone 1**.
|
|
388
|
+
It is there so that when FR-202 resolves destinations in Milestone 2,
|
|
389
|
+
"PostgreSQL prod" and "PostgreSQL analytics" split into two nodes without a
|
|
390
|
+
discriminator change — the same way the flagship fixture already distinguishes
|
|
391
|
+
Payment API from Analytics API.
|
|
392
|
+
|
|
393
|
+
### 6.2 Why not per-provenance-node, and why not per-call-site
|
|
394
|
+
|
|
395
|
+
- **Per-pnode** is what §2.1 already rejected: 494 nodes for one 33-file
|
|
396
|
+
project.
|
|
397
|
+
- **Per-call-site** was measured and rejected here: `vulnerable-js` alone
|
|
398
|
+
would produce ~14 nodes — the *same count as the entire flagship platform*,
|
|
399
|
+
for a 42-line file.
|
|
400
|
+
- **Per-registry-decision** gives a node count bounded by the **taxonomy**,
|
|
401
|
+
not by the repository: at most `|SOURCE_CATEGORIES| + |SINK_CATEGORIES| +
|
|
402
|
+
(distinct unsupported CWEs) + 1`, i.e. under ~60 for any repository of any
|
|
403
|
+
size.
|
|
404
|
+
|
|
405
|
+
Measured (`E1/7`), replicating `vulnerable-js` 1× / 10× / 50×:
|
|
406
|
+
|
|
407
|
+
| copies | functions | **nodes** | edges | flows | dataElements |
|
|
408
|
+
|---|---|---|---|---|---|
|
|
409
|
+
| 1 | 8 | **9** | 6 | 6 | 6 |
|
|
410
|
+
| 10 | 80 | **9** | 60 | 60 | 60 |
|
|
411
|
+
| 50 | 400 | **9** | 300 | 300 | 300 |
|
|
412
|
+
| 200 | 1600 | **9** | 1200 | 1200 | 1200 |
|
|
413
|
+
|
|
414
|
+
(the 200× row was measured during the spike at 104 ms; the shipped test stops
|
|
415
|
+
at 50× to keep `test:lineage` fast.)
|
|
416
|
+
|
|
417
|
+
Nodes are invariant; everything field- or path-granular scales linearly. That
|
|
418
|
+
invariance **is** the projection rule.
|
|
419
|
+
|
|
420
|
+
### 6.3 Source attachment
|
|
421
|
+
|
|
422
|
+
A reconstructed `Path` carries a `dataElementId`, and every data element id in
|
|
423
|
+
the store came from exactly one seed. So `seedByDataElementId.get(p.dataElementId)`
|
|
424
|
+
is the source join — an O(1) map lookup, no path-shape heuristics. A path
|
|
425
|
+
whose `dataElementId` has no seed is skipped and would be a diagnostic (none
|
|
426
|
+
observed).
|
|
427
|
+
|
|
428
|
+
This is what §2.1's "on real code every complete path terminates at an
|
|
429
|
+
ordinary `path` node, and nothing in the store says which of those is a
|
|
430
|
+
source" needed: the store never needs to know. The seed ledger knows.
|
|
431
|
+
|
|
432
|
+
### 6.4 Edges and flows, and FR-305
|
|
433
|
+
|
|
434
|
+
**One graph edge per `(sourceNode, sinkNode, fromPath, toPath, dataElementId,
|
|
435
|
+
mappingType, transformationIds)`.** `toPath` is `null` — a call argument is
|
|
436
|
+
not an access path, and fabricating one is Decision 5's forbidden bug class.
|
|
437
|
+
|
|
438
|
+
**One flow per `(sourceNode, sinkNode, dataElementId, Path.shape,
|
|
439
|
+
gradePath().grade, ordered transformation ids)`**, with
|
|
440
|
+
`alternatePathCount = collapsedPaths - 1`.
|
|
441
|
+
|
|
442
|
+
That key is FR-305 (*"deduplication may collapse identical internal segments
|
|
443
|
+
but cannot hide materially different transformations or controls"*) expressed
|
|
444
|
+
as data. Two paths differing in transformation, in evidence grade, or in
|
|
445
|
+
`shape` (complete/partial × boundary/local × widened/explicit × lossy/intact ×
|
|
446
|
+
ambiguous/correlated) are two flows. Everything else collapses.
|
|
447
|
+
|
|
448
|
+
**The `context` dimension collapses for free.** `hashState(entryState)` appears
|
|
449
|
+
nowhere in the flow key, and data element ids are context-independent by
|
|
450
|
+
construction (§3.4), so two entry contexts of one function producing the same
|
|
451
|
+
projection are one flow with a higher `alternatePathCount` — never two nodes,
|
|
452
|
+
never two flows.
|
|
453
|
+
|
|
454
|
+
A worked example this actually produces (`E1/14`): one masked-log flow yields
|
|
455
|
+
**two** flows, graded `explicit` (the real cross-scope path through
|
|
456
|
+
`maskCard`) and `ambiguous` (the caller-side bypass §14.7 marks
|
|
457
|
+
`ambiguousCorrelation`). Collapsing them would hide exactly what FR-305/FR-306
|
|
458
|
+
forbid hiding. This is the rule working, not a defect.
|
|
459
|
+
|
|
460
|
+
Since only source and sink nodes exist, a flow's `edgeIds` has length 1. That
|
|
461
|
+
is honest: the projection does not invent intermediate system nodes it has not
|
|
462
|
+
identified. Cross-scope hops are disclosed in `flow.limitations` via
|
|
463
|
+
`gradePath().factors` (`evidence: cross-scope`), never silently dropped.
|
|
464
|
+
|
|
465
|
+
### 6.5 AC-11, and a flagged unresolved question for E4/H
|
|
466
|
+
|
|
467
|
+
- **Coarse half, satisfied here:** a matched source or sink is a `nodes[]`
|
|
468
|
+
entry whether or not any flow touches it, carrying its registry
|
|
469
|
+
`coverageStatus` and a non-empty `coverageReason`. Measured on
|
|
470
|
+
`vulnerable-js`: 6 of 11 sink sites connected, and the 5 disconnected ones
|
|
471
|
+
are still nodes (`E1/13`).
|
|
472
|
+
- **Fine half, NOT satisfied by `nodes[]`:** because nodes are
|
|
473
|
+
category-granular, a category with 9 connected and 1 disconnected call site
|
|
474
|
+
reads as connected. The disconnected *call site* is visible only in
|
|
475
|
+
`graph.coverage` (§10).
|
|
476
|
+
|
|
477
|
+
**Flagged for E4 and H, honestly unresolved here:** whether AC-11's acceptance
|
|
478
|
+
test (*"the user opens the corresponding inventory … it is visible with a
|
|
479
|
+
coverage reason"*) is satisfied by a coverage-ledger row rather than a
|
|
480
|
+
`nodes[]` row is a product judgment E1 cannot settle from the code. Two
|
|
481
|
+
options were considered and one was **rejected outright**: minting a per-site
|
|
482
|
+
node *only when it is disconnected* makes node identity depend on
|
|
483
|
+
connectivity, so a node would appear and disappear as the analysis improves,
|
|
484
|
+
breaking id stability across runs. Do not do that. If a per-site inventory row
|
|
485
|
+
is required, it must be an additional, always-present entity (a ledger row, or
|
|
486
|
+
an `evidence` entity), never a conditional node.
|
|
487
|
+
|
|
488
|
+
### 6.6 `node.subtype: null` — an escalated schema divergence — RESOLVED
|
|
489
|
+
|
|
490
|
+
**RESOLVED** by the `2026-08-31-lineage-schema-subtype-nullability-hotfix`
|
|
491
|
+
Milestone-0 hotfix — this file is committed in the same commit as the fix
|
|
492
|
+
itself, so it cannot self-reference its own hash; find the exact commit via
|
|
493
|
+
`git log --grep='relax node.subtype schema nullability'` (the commit message
|
|
494
|
+
starts `fix(lineage): relax node.subtype schema nullability`).
|
|
495
|
+
|
|
496
|
+
Decision 1 (inherited, `DESIGN_REGISTRIES.md` §9.0) says a `null` category
|
|
497
|
+
becomes a `null`/absent `subtype`. `validate.js` accepted that already — it
|
|
498
|
+
never looked at `subtype` at all, which was itself half the bug: it was also
|
|
499
|
+
silently accepting genuinely wrong values (e.g. a bare number) with no check.
|
|
500
|
+
|
|
501
|
+
**`dataflow-graph.schema.json` did not.** Its node definition had `subtype` in
|
|
502
|
+
`required` with `"type": "string"`, so a null-category node was invalid
|
|
503
|
+
against the JSON-Schema twin while being valid against the runtime validator,
|
|
504
|
+
and `json-schema-parity.test.js` could not see the difference (it only ever
|
|
505
|
+
compared enum arrays and the top-level envelope's `required` keys, never a
|
|
506
|
+
per-entity `$defs.<entity>.required` array).
|
|
507
|
+
|
|
508
|
+
**The fix, applied outside Sub-project E** (this file's own node contract
|
|
509
|
+
description above is unaffected — E still emits `subtype: null` per Decision
|
|
510
|
+
1, exactly as originally written): `dataflow-graph.schema.json`'s `$defs.node`
|
|
511
|
+
now declares `"subtype": { "type": ["string", "null"] }` and no longer lists
|
|
512
|
+
`subtype` in `required` (both `null` and a fully absent field are legal, per
|
|
513
|
+
`DESIGN_REGISTRIES.md` §9.0's own "null/absent" phrasing, which never
|
|
514
|
+
committed to one representation over the other); `validate.js` gained an
|
|
515
|
+
active check — present-and-non-null-and-non-string (a number, object, array,
|
|
516
|
+
etc.) is now a validation error, present-and-null or absent are not;
|
|
517
|
+
`json-schema-parity.test.js` gained a new block comparing every
|
|
518
|
+
`$defs.<entity>.required` array against the fields `validate.js` actually
|
|
519
|
+
enforces as required, per entity, closing the exact blind spot that let this
|
|
520
|
+
divergence go undetected. The two validators no longer disagree on any of
|
|
521
|
+
`vulnerable-js`'s 9 nodes (nor on the flagship fixture's 14, which all already
|
|
522
|
+
carried string subtypes and needed no change).
|
|
523
|
+
|
|
524
|
+
---
|
|
525
|
+
|
|
526
|
+
## 7. Transformations
|
|
527
|
+
|
|
528
|
+
### 7.1 Attribution
|
|
529
|
+
|
|
530
|
+
For each hop on a projected path, resolve the CFG node at
|
|
531
|
+
`(hop.scope, hop.siteNodeId)` and build a `recognizeTransformation`
|
|
532
|
+
descriptor from its callee (`{type:'member-call', object, method}` for a
|
|
533
|
+
member callee, `{type:'call', callee}` for an identifier).
|
|
534
|
+
|
|
535
|
+
`inputPath`/`outputPath` come from the hop's `fromPath`/`toPath`;
|
|
536
|
+
`location` from the CFG node's file and the hop's line; `kind`,
|
|
537
|
+
`reversibility`, `algorithm`, `confidence`, `evidence` from D4's recognizer
|
|
538
|
+
verbatim.
|
|
539
|
+
|
|
540
|
+
### 7.2 `null` from the recognizer → `kind: 'unknown'`, and only when attributable
|
|
541
|
+
|
|
542
|
+
`NEVER_EMITTED_KINDS = ['custom', 'unknown']` forces a choice. **It is
|
|
543
|
+
`'unknown'`.** `'custom'` asserts "this is a real transform we simply cannot
|
|
544
|
+
name", which is a stronger claim than a `null` supports.
|
|
545
|
+
|
|
546
|
+
An `unknown` entity is emitted only when **the hop's edge carries
|
|
547
|
+
`widenReason: 'unresolved-call'`** (the hop record itself says a call
|
|
548
|
+
transformed this value) **and the CFG node carries exactly one call
|
|
549
|
+
expression**, so the callee attribution cannot be a guess. Otherwise a
|
|
550
|
+
`flow.limitations` string records the site and says why attribution was
|
|
551
|
+
refused.
|
|
552
|
+
|
|
553
|
+
That scoping was added because the naive rule got it wrong on real code:
|
|
554
|
+
`res.send(eval(req.body.expr))` has two call expressions at one CFG node, and
|
|
555
|
+
the naive rule attributed the widening to `res.send` when `eval` caused it.
|
|
556
|
+
|
|
557
|
+
Both cases are proven reachable on real parsed code in `E1/11`: `maskCard` →
|
|
558
|
+
`kind: 'mask'`, `reversibility: 'irreversible'`, `confidence: 'medium'`;
|
|
559
|
+
`reshapeForVendor` → `kind: 'unknown'`, `reversibility: 'unknown'`,
|
|
560
|
+
`algorithm: null`, evidence naming the `unresolved-call` hop.
|
|
561
|
+
|
|
562
|
+
### 7.3 The two §10.6 fields with no honest source
|
|
563
|
+
|
|
564
|
+
- **`appliesToAllPaths: null`** — never `true`/`false`. That is FR-307's
|
|
565
|
+
all-path proof and nothing in `src/lineage/` does path-feasibility
|
|
566
|
+
reasoning.
|
|
567
|
+
- **No control-credit field of any kind — not even `false`.** `false` reads as
|
|
568
|
+
"credit was considered and denied", a claim E has no basis for. Decision 2
|
|
569
|
+
reserves awarding credit for Milestone 2's FR-401-405. `E1/11` sweeps every
|
|
570
|
+
transformation's key set against `/credit|granted|denied|verdict|protected/i`
|
|
571
|
+
— the same structural enforcement D4 already uses on its own decisions.
|
|
572
|
+
|
|
573
|
+
---
|
|
574
|
+
|
|
575
|
+
## 8. Flow and edge defaults
|
|
576
|
+
|
|
577
|
+
- `flow.protectionSummary: 'not_assessed'` on every flow.
|
|
578
|
+
- `flow.policyVerdict: 'not_evaluated'` on every flow.
|
|
579
|
+
- `edge.protection: emptyProtection()` on every edge — all three dimensions
|
|
580
|
+
`{verdict: 'not_assessed', evidenceGrade: 'none'}`.
|
|
581
|
+
- `edge.protocol.destinationResolution: 'unknown'` on every edge (FR-202 is
|
|
582
|
+
Milestone 2; `'dynamic'` would be a claim that resolution was attempted and
|
|
583
|
+
failed).
|
|
584
|
+
|
|
585
|
+
§10.7's *"the end-to-end summary must be derived from the individual edge
|
|
586
|
+
verdicts, never stored as an unsupported independent claim"* is satisfied
|
|
587
|
+
**trivially and correctly**: every edge verdict is `not_assessed`, so the only
|
|
588
|
+
derivable summary is `not_assessed`. E3 must keep it derived, not hardcoded —
|
|
589
|
+
the moment Milestone 2 gives an edge a real verdict, the flow summary must
|
|
590
|
+
follow it.
|
|
591
|
+
|
|
592
|
+
`flow.limitations[]` is where the honesty budget goes, and it is **sourced
|
|
593
|
+
from `gradePath().factors`**, never re-derived: widening reasons, loss
|
|
594
|
+
reasons, ambiguous correlation, analysis truncation, cross-scope, plus
|
|
595
|
+
reconstruction truncation reasons and the multi-candidate ambiguity note.
|
|
596
|
+
|
|
597
|
+
---
|
|
598
|
+
|
|
599
|
+
## 9. Checklist for E2-E5
|
|
600
|
+
|
|
601
|
+
### 9.0 What already shipped in E1
|
|
602
|
+
|
|
603
|
+
- `src/lineage/driver.js`: `opts.seedEntryState` + the `cache.set` key fix.
|
|
604
|
+
**Nothing else.** No graph-builder.js, no source-seeding.js, no
|
|
605
|
+
lineage/index.js, no `runFullScan` wiring (none of the three exists in the
|
|
606
|
+
tree, so none is backtick-quoted here).
|
|
607
|
+
- `test/lineage/driver.test.js`: `E1/driver-1` … `E1/driver-3`, permanent.
|
|
608
|
+
- `test/lineage/graph-builder-poc.test.js`: throwaway, §9.1.
|
|
609
|
+
|
|
610
|
+
### 9.1 The PoC absorption protocol — stated, not implicit
|
|
611
|
+
|
|
612
|
+
D1 left D2/D3's protocol implicit until §9.1 had to spell it out. Stating it
|
|
613
|
+
up front here:
|
|
614
|
+
|
|
615
|
+
- **E2 absorbs the seeding half** — `E1/1` … `E1/5` — into
|
|
616
|
+
test/lineage/source-seeding.test.js (not backtick-quoted: it does not exist in the tree yet, and this repo's doc-drift checker flags a dangling backtick-quoted path), re-pointed at the shipped
|
|
617
|
+
source-seeding.js. It also inherits `E1/14`, the escalated engine
|
|
618
|
+
limitation (§11 item 1), because that test is about seeding reaching a sink.
|
|
619
|
+
- **E3 absorbs the projection half** — `E1/6` … `E1/13` — into
|
|
620
|
+
test/lineage/graph-builder.test.js (same reason — not yet in the tree), re-pointed at the shipped
|
|
621
|
+
graph-builder.js.
|
|
622
|
+
- **Whichever of E2/E3 lands SECOND deletes
|
|
623
|
+
`test/lineage/graph-builder-poc.test.js`**, removes it from
|
|
624
|
+
`package.json`'s `test:lineage` script, and removes its row from
|
|
625
|
+
`src/lineage/CLAUDE.md` — after confirming the other's absorption is
|
|
626
|
+
complete. Neither may delete it unilaterally. (D1 §9.1's two-lander rule.)
|
|
627
|
+
- `E1/driver-1` … `E1/driver-3` are **permanent** and are absorbed by nobody.
|
|
628
|
+
They guard `driver.js`, which is shipped code.
|
|
629
|
+
|
|
630
|
+
### 9.2 E2 — src/lineage/source-seeding.js (not yet in the tree)
|
|
631
|
+
|
|
632
|
+
1. Export `planSeeds(callGraph, {repository})` and
|
|
633
|
+
`seedEntryStateFactory(seeds)` exactly as §3.1-§3.5 specify.
|
|
634
|
+
2. Import list must be exactly
|
|
635
|
+
`['../dataflow/catalog.js', '../dataflow/access-paths.js', './source-registry.js', './classification.js', './ids.js', './field-identity.js']`
|
|
636
|
+
— pin it with the self-checking boundary test `path-query.js` established.
|
|
637
|
+
3. Ship the `unseedable[]` accounting from day one; an unseedable match that
|
|
638
|
+
is merely absent is the failure mode this whole PRD exists to prevent.
|
|
639
|
+
4. Re-measure `path-query.js`'s five uncalibrated `DEFAULTS` against real
|
|
640
|
+
seeded stores — that is E2's own deliverable per the scoping doc, and §2.3
|
|
641
|
+
above shows how easily an unspecified harness makes such numbers
|
|
642
|
+
irreproducible. Record the harness, not just the number.
|
|
643
|
+
5. Do **not** pick `frontend/` as a measurement target (§3.7).
|
|
644
|
+
|
|
645
|
+
### 9.3 E3 — src/lineage/graph-builder.js — SHIPPED, see src/lineage/CLAUDE.md
|
|
646
|
+
|
|
647
|
+
1. `buildDataFlowGraph(callGraph, opts)` — **corrected by E3, per this
|
|
648
|
+
file's own §9.1 policy** ("where this document and that PoC disagree,
|
|
649
|
+
the PoC is right and this document is stale — fix it here, do not fork
|
|
650
|
+
it"): this item originally stated the signature as
|
|
651
|
+
`buildDataFlowGraph(perFileIR, callGraph, opts)` (three arguments), but
|
|
652
|
+
the PoC's own shipped, tested implementation was always the
|
|
653
|
+
two-argument form above — it reads everything E3 itself needs from
|
|
654
|
+
`callGraph.functions[*].cfg` and never used a separate `perFileIR`
|
|
655
|
+
parameter. E3 shipped the PoC's real two-argument signature rather than
|
|
656
|
+
forking it to match this document's stale prose. **This is scoped to
|
|
657
|
+
E3, not a claim that `perFileIR` is never needed anywhere in this
|
|
658
|
+
sub-project (task review, E3's own follow-up review)**: §10's ledger —
|
|
659
|
+
E4's job — requires `languages: [{language, filesExpected,
|
|
660
|
+
filesAnalyzed}]` and `parseFailures: []`, and a parse failure is
|
|
661
|
+
structurally invisible to a `callGraph`-only builder (a file that fails
|
|
662
|
+
to parse contributes zero functions, so nothing in `callGraph` records
|
|
663
|
+
its absence). E4 will need a per-file input of its own — either
|
|
664
|
+
`opts.perFile` or an equivalent — to populate those two fields
|
|
665
|
+
honestly; it should not assume E3's two-argument precedent means it can
|
|
666
|
+
do the same. Still mirror `dataflow/index.js`'s `runDeepAnalysis`
|
|
667
|
+
**shape** and import nothing from it.
|
|
668
|
+
2. Node minting per §6.1; edge/flow keys per §6.4; `subtype` per §6.6, with
|
|
669
|
+
the schema divergence escalated in the PR, not silently emitted.
|
|
670
|
+
3. Ship the four assertions `validate.js` cannot make (`E1/8`): every
|
|
671
|
+
`subtype` in the registry vocabulary or null; every `node.dataElementIds`
|
|
672
|
+
entry referentially sound; no two *different* registry decisions collided
|
|
673
|
+
onto one node id; no `pedge:`/`ppath:` id in `flow.edgeIds`.
|
|
674
|
+
4. Determinism: sort every entity array by id before emit, and take
|
|
675
|
+
`generatedAt` from `opts` — `emptyGraphEnvelope()` defaults it to
|
|
676
|
+
`new Date().toISOString()`, which breaks `--deterministic`. E5 settles the
|
|
677
|
+
policy (§9.5); E3 must at minimum make it injectable.
|
|
678
|
+
|
|
679
|
+
### 9.4 E4 — src/lineage/coverage.js (not yet in the tree)
|
|
680
|
+
|
|
681
|
+
1. Finish §10's ledger sketch.
|
|
682
|
+
2. Close **FR-203**: call `reclassifySink(entry, {destinationUnresolved,
|
|
683
|
+
blockingExpression})` at real call sites whose destination expression is
|
|
684
|
+
not statically resolvable. Keep it structurally distinct from §5's case.
|
|
685
|
+
3. Ship §5's enumerator union as a real module function.
|
|
686
|
+
4. Prove it the way D5 was proven: an empty-but-valid graph must **fail**
|
|
687
|
+
these tests.
|
|
688
|
+
5. **Two things E3's own review confirmed E4 needs, named here so neither
|
|
689
|
+
is rediscovered mid-implementation.** (a) FR-203 needs no new export
|
|
690
|
+
from `graph-builder.js` — `enumerateSinkSites` already returns, per
|
|
691
|
+
site, `{file, qid, nodeId, line, calleeExpr, entry, decision,
|
|
692
|
+
ambiguity}`, and the raw `entry` is exactly what `reclassifySink(entry,
|
|
693
|
+
{destinationUnresolved, blockingExpression})` (item 2 above) needs;
|
|
694
|
+
confirmed live, this signature composes cleanly with no change to E3's
|
|
695
|
+
shipped module. (b) `buildDataFlowGraph` currently mints sink nodes
|
|
696
|
+
from `site.decision` internally with no hook to substitute an
|
|
697
|
+
FR-203-adjusted decision, and hardcodes `coverage.languages`/
|
|
698
|
+
`parseFailures` to `[]` with no per-file input to populate them
|
|
699
|
+
honestly (a parse failure is structurally invisible to a
|
|
700
|
+
`callGraph`-only builder — see §9.3 item 1's own note above). E4 will
|
|
701
|
+
need either an `opts` hook on `buildDataFlowGraph` (e.g.
|
|
702
|
+
`opts.resolveSiteDecision`, `opts.perFile`) or a post-processing pass
|
|
703
|
+
over the built graph — decide which explicitly, don't default to
|
|
704
|
+
whichever is easiest to hack in.
|
|
705
|
+
|
|
706
|
+
### 9.5 E5 — src/lineage/index.js (not yet in the tree) + `runFullScan`
|
|
707
|
+
|
|
708
|
+
1. Copy the `AGENTIC_SECURITY_PRIVACY_DEEP` block's shape in
|
|
709
|
+
`dataflow/index.js`: opt-in, best-effort, failure **recorded** in
|
|
710
|
+
`scanHealth`, never swallowed, never able to fail a scan.
|
|
711
|
+
**Correction (final whole-branch review, N-6): this item's own wording
|
|
712
|
+
is wrong about which block's failure-handling to copy, and was never
|
|
713
|
+
amended when item 5 below and `src/lineage/index.js`'s own header
|
|
714
|
+
comment already corrected it.** The `AGENTIC_SECURITY_PRIVACY_DEEP`
|
|
715
|
+
block's actual failure-handling is a bare `catch {}` that silently
|
|
716
|
+
swallows — the opposite of "recorded in scanHealth, never swallowed"
|
|
717
|
+
this item asks for in the same sentence. What E5 actually shipped
|
|
718
|
+
copies `_deepEnabled`'s own failure-handling instead (status
|
|
719
|
+
`'complete'`/`'failed'`/`'not_available'`, `failure` folded into
|
|
720
|
+
`scanHealth.lineageAnalysis`, never a bare `catch {}`) — see item 5's
|
|
721
|
+
own "Disclosed, deliberate difference" note for the full reasoning and
|
|
722
|
+
`src/lineage/index.js`'s header for where this is stated at the
|
|
723
|
+
implementation site.
|
|
724
|
+
2. Consume `runScan`'s existing `_sharedIR` `{perFile, callGraph}` memo. No
|
|
725
|
+
new IR pass.
|
|
726
|
+
3. Settle `generatedAt` under `--deterministic` (§2.7 of the scoping doc) and
|
|
727
|
+
where the artifact is written.
|
|
728
|
+
4. `AGENTIC_SECURITY_LINEAGE_MAX_CONTEXTS` becomes genuinely operator-facing
|
|
729
|
+
here for the first time — and per §5 it now has an observable effect on
|
|
730
|
+
output (degraded terminals), so document it.
|
|
731
|
+
5. **Disclosed, deliberate difference from `_deepEnabled`: no CI auto-disable.**
|
|
732
|
+
`_deepEnabled`'s own gate additionally checks `inCi`/`ciOverrideAllowed`
|
|
733
|
+
(deep mode defaults OFF in CI unless `AGENTIC_SECURITY_DEEP_IN_CI=1` is
|
|
734
|
+
also set) — the lineage gate has no equivalent. `AGENTIC_SECURITY_LINEAGE_DEEP=1`
|
|
735
|
+
runs unconditionally in CI once an operator sets it, with no separate
|
|
736
|
+
"and also allow it in CI" flag. This is deliberate, not an omission:
|
|
737
|
+
deep mode's CI caution exists because it is sometimes enabled implicitly
|
|
738
|
+
by other default-on machinery, where an operator might not have
|
|
739
|
+
consciously chosen "run this in CI"; lineage is reachable only via this
|
|
740
|
+
one explicit opt-in env var, so setting it already IS the operator's own
|
|
741
|
+
CI decision — there is nothing implicit to guard against.
|
|
742
|
+
|
|
743
|
+
---
|
|
744
|
+
|
|
745
|
+
## 10. The coverage ledger, sketched (E1 item **(f)**)
|
|
746
|
+
|
|
747
|
+
E4 owns the finished contract. This is the shape E1 proved out, grounded in
|
|
748
|
+
the flagship fixture's own four keys plus what AC-11, FR-203 and §16.7
|
|
749
|
+
require:
|
|
750
|
+
|
|
751
|
+
```js
|
|
752
|
+
graph.coverage = {
|
|
753
|
+
// --- the flagship fixture's own four keys, kept ---
|
|
754
|
+
languages: [{ language, filesExpected, filesAnalyzed }],
|
|
755
|
+
parseFailures: [],
|
|
756
|
+
destinationResolutionStatus: 'not-attempted', // FR-202 is Milestone 2
|
|
757
|
+
pathBudgetTruncation: <bool>,
|
|
758
|
+
|
|
759
|
+
// --- AC-11's fine half ---
|
|
760
|
+
sources: { matched, unseedable, dataElements,
|
|
761
|
+
byCategory: { <category>: { sites, coverageStatus } } },
|
|
762
|
+
sinks: { callStatementSites, connected, disconnected,
|
|
763
|
+
nonStatementSitesNotEnumerable, // §4.1's named gap
|
|
764
|
+
byCategory: { <category>: { sites, connected, coverageStatus } } },
|
|
765
|
+
|
|
766
|
+
// --- the two "must never disappear" obligations ---
|
|
767
|
+
degradedTerminals: <count>, // §16.7 Finding 2
|
|
768
|
+
unresolvedDestinations: <count>, // FR-203
|
|
769
|
+
|
|
770
|
+
// --- §18.4: budget exhaustion is never "no path" ---
|
|
771
|
+
paths: { enumerated, projected, truncatedQueries },
|
|
772
|
+
budgets: { ...path-query.js DEFAULTS actually used },
|
|
773
|
+
|
|
774
|
+
// --- provenance scale, for G's comparison report ---
|
|
775
|
+
provenance: { hops, pnodes, pedges },
|
|
776
|
+
};
|
|
777
|
+
```
|
|
778
|
+
|
|
779
|
+
**The one rule to design against explicitly:** a node E *dropped* and a node
|
|
780
|
+
with *no path* must be distinguishable in the output, and neither may look
|
|
781
|
+
like the other. `byCategory` is what makes "this category was never matched"
|
|
782
|
+
distinguishable from "this category matched and connected nothing".
|
|
783
|
+
|
|
784
|
+
Measured on `vulnerable-js`, the sketch reports: 9 sources matched, 0
|
|
785
|
+
unseedable, 6 data elements; 11 sink statement sites, 6 connected, 5
|
|
786
|
+
disconnected, 1 non-statement site not enumerable; 0 degraded terminals; 23
|
|
787
|
+
hops / 15 pnodes / 9 pedges (re-measured by E3, task review N-2 — the
|
|
788
|
+
`lineage-engine-receiver-identity-hotfix` moved this from the pre-hotfix
|
|
789
|
+
19/14/8 §3.7 already corrected; this section had not been).
|
|
790
|
+
|
|
791
|
+
---
|
|
792
|
+
|
|
793
|
+
## 11. Escalations — things E cannot fix, named here rather than discovered later
|
|
794
|
+
|
|
795
|
+
1. **`lineage/engine.js` drops RECEIVER-borne identity through a method
|
|
796
|
+
call. RESOLVED**, by a dedicated hotfix task (2026-08-31, "Lineage Engine
|
|
797
|
+
Hotfix: Unresolved-Call Receiver Identity" — see this file's git history
|
|
798
|
+
for the exact commit that landed this change). `case 'call'`'s unresolved
|
|
799
|
+
branch used to union only `expr.args`, never `expr.callee.object`.
|
|
800
|
+
Measured: `pan + 'x'` and `String(pan)` always kept the identity;
|
|
801
|
+
**`pan.slice(0, 4)` lost it**. `dataflow/engine.js` had already solved
|
|
802
|
+
exactly this with `_calleeReceiverTainted`; this package had not
|
|
803
|
+
inherited it, and no lineage design document disclosed the gap until this
|
|
804
|
+
escalation entry.
|
|
805
|
+
|
|
806
|
+
**The fix's exact mechanism, BOTH callee shapes (task review MF-1: the
|
|
807
|
+
first cut of this fix ported only half of `_calleeReceiverTainted`'s own
|
|
808
|
+
precedent and was found to still lose the identity for every non-JS
|
|
809
|
+
parser's callee shape — closed in the same hotfix, not left as a second
|
|
810
|
+
escalation):**
|
|
811
|
+
- `parser-js.js`'s structured `{kind:'member', object, prop}` callee:
|
|
812
|
+
`expr.callee.object` (the receiver) is recursively resolved via
|
|
813
|
+
`resolveExprIdentities` and its `flat` identities are unioned into the
|
|
814
|
+
branch's own `flat` result, the same way each argument's `flat`
|
|
815
|
+
identities already were.
|
|
816
|
+
- Every OTHER language parser's flat, dot-joined STRING callee
|
|
817
|
+
(`"pan.slice"`, not an exprDesc — `parser-py.js`/`parser-java.js`/
|
|
818
|
+
`parser-go.js`/`parser-php.js`/`parser-rb.js`/`parser-cs.js`/
|
|
819
|
+
`parser-kt.js`/`parser-cpp.js` all emit this shape): the receiver is
|
|
820
|
+
recovered by slicing the string after its LAST `.` and querying that
|
|
821
|
+
prefix directly via `identitiesAt`, mirroring
|
|
822
|
+
`_calleeReceiverTainted`'s own string branch (`isCoveredBy`) — this
|
|
823
|
+
one needs the actual identity SET, not a boolean.
|
|
824
|
+
- Both match `DESIGN_INTRAPROCEDURAL.md`'s structure-flattening rule for
|
|
825
|
+
`call` (an unresolved call's return is genuinely unknown structure, so
|
|
826
|
+
only `flat` participates, never `byPath`). Proven permanently in
|
|
827
|
+
`test/lineage/engine-receiver-identity.test.js` against both real
|
|
828
|
+
JS/TS (`parser-js.js`) and real Python (`parser-py.js`) parsed IR —
|
|
829
|
+
not reachable from any shipped caller today (this package's only real
|
|
830
|
+
caller, Sub-project E, is wired against `parser-js.js`'s JS/TS output
|
|
831
|
+
only), but `resolveExprIdentities` has no JS-only gate of its own, and
|
|
832
|
+
shipping the fix for one callee shape while leaving the other
|
|
833
|
+
half-inherited would have reproduced this exact bug class one
|
|
834
|
+
language over.
|
|
835
|
+
|
|
836
|
+
**The consequence was not theoretical:** it produced **zero flows** for
|
|
837
|
+
`bench/data-lineage/fixtures/js-api-to-log-masked` — the mask-then-log
|
|
838
|
+
fixture that is AC-02's own worked example and one of only three entries in
|
|
839
|
+
the lineage accuracy corpus. `E1/14` (now updated to assert the FIX, not
|
|
840
|
+
the bug) pins both halves: the real fixture now yields 2 flows (the real
|
|
841
|
+
cross-scope path through `maskCard`, graded `widened` because the call
|
|
842
|
+
itself stays unresolved, plus the caller-side bypass FR-305/§14.7 marks
|
|
843
|
+
`ambiguousCorrelation`); the same structure with a receiver-free transform
|
|
844
|
+
already yielded 2 flows (graded `explicit`/`ambiguous`) and still does.
|
|
845
|
+
|
|
846
|
+
Sub-project E's own E1 PoC tests that pinned the pre-fix (buggy) numbers —
|
|
847
|
+
`E1/4`, `E1/6`, `E1/13`'s hop counts (19 -> 23 on `vulnerable-js`; node
|
|
848
|
+
count 14 -> 15, edge count 8 -> 9) and `E1/14` itself — were updated as a
|
|
849
|
+
direct, disclosed consequence of this fix, per that PoC file's own
|
|
850
|
+
comments at each changed assertion, not silently left stale.
|
|
851
|
+
|
|
852
|
+
2. **RESOLVED.** ~~`node.subtype: null` is invalid against
|
|
853
|
+
`dataflow-graph.schema.json` while valid against `validate.js`~~ — §6.6.
|
|
854
|
+
Fixed by the `2026-08-31-lineage-schema-subtype-nullability-hotfix`
|
|
855
|
+
Milestone-0 hotfix: the schema now declares `subtype`'s type as
|
|
856
|
+
`["string", "null"]` and drops it from `required`; `validate.js` gained an
|
|
857
|
+
active check accepting `string | null | absent` and rejecting anything
|
|
858
|
+
else (a bare number, previously silently accepted); the parity test now
|
|
859
|
+
compares `$defs.<entity>.required` arrays, not just enums. Find the exact
|
|
860
|
+
commit via `git log --grep='relax node.subtype schema nullability'`.
|
|
861
|
+
|
|
862
|
+
3. **A sink in an assignment RHS, a return value, or a nested argument has no
|
|
863
|
+
`escape` provenance node**, so no sink-rooted reconstruction can start
|
|
864
|
+
there — §4.1. Counted in the ledger; closing it needs either a new hop kind
|
|
865
|
+
(a `path-store.js`/`engine.js` change, out of E's scope) or a
|
|
866
|
+
forward-from-source query `path-query.js` does not have.
|
|
867
|
+
|
|
868
|
+
4. **`matchPrivacySink` has no receiver constraint**, so bare-name over-match
|
|
869
|
+
is structural — §4.2. 3 of 11 sink sites in a 42-line fixture. Out of E's
|
|
870
|
+
scope (`privacy-catalog.js` is on the must-not-modify list); F must not
|
|
871
|
+
count these as recall.
|
|
872
|
+
|
|
873
|
+
5. **The plurality tie-break is arbitrary** — §4.3. Mitigated by `partial` +
|
|
874
|
+
named alternatives, not solved.
|
|
875
|
+
|
|
876
|
+
6. **`validate.js` does not enforce every field `dataflow-graph.schema.json`
|
|
877
|
+
declares `required`** — found and escalated by the
|
|
878
|
+
`2026-08-31-lineage-schema-subtype-nullability-hotfix` while closing
|
|
879
|
+
item 2. `test/lineage/json-schema-parity.test.js` now derives, live
|
|
880
|
+
against the flagship fixture, exactly which schema-required fields
|
|
881
|
+
`validate.js` actually enforces, and pins the rest as a documented
|
|
882
|
+
`KNOWN_REQUIRED_GAPS` allowlist so a NEW gap of this shape fails loudly.
|
|
883
|
+
Two of these are **risk-bearing, not merely opaque object bags**:
|
|
884
|
+
`edge.coverageStatus` and `flow.coverageStatus` are the SAME enum
|
|
885
|
+
`node.coverageStatus` is already checked for — AC-11's "a discovered
|
|
886
|
+
sink stays visible with a reason" rests on this field, and `validate.js`
|
|
887
|
+
checks it on a node but not on the edge or flow carrying that node's own
|
|
888
|
+
flow — and `evidence.claim` (the evidence contract's own free-text
|
|
889
|
+
assertion) is never checked at all. The full current set: `node` —
|
|
890
|
+
`system`/`externality`/`lifecycleStages`/`governanceRefs`/`confidence`;
|
|
891
|
+
`fieldMapping` — `fromPath`/`toPath`/`dataElementIds`/
|
|
892
|
+
`transformationIds`; `edge` — `protocol`/`boundaryCrossings`/
|
|
893
|
+
`evidenceRefs`/`coverageStatus`; `dataElement` —
|
|
894
|
+
`aliases`/`sourceLocations`/`classificationEvidence`/`manualOverride`;
|
|
895
|
+
`flow` — `dataElementIds`/`edgeIds`/`evidenceRefs`/`coverageStatus`;
|
|
896
|
+
`evidence` — `claim`. Out of scope for that hotfix (its own Global
|
|
897
|
+
Constraints forbade tightening any validation beyond `subtype`) and out
|
|
898
|
+
of Sub-project E's scope too (`validate.js` is Milestone-0 frozen
|
|
899
|
+
contract code) — named here so a future Milestone-0 hardening pass finds
|
|
900
|
+
it named, not rediscovered. Whoever closes this should prioritize
|
|
901
|
+
`edge.coverageStatus`/`flow.coverageStatus` first, given AC-11's own
|
|
902
|
+
stake in the property.
|
|
903
|
+
|
|
904
|
+
---
|
|
905
|
+
|
|
906
|
+
## 12. The reuse boundary (E1 item **(g)**) — confirmed against the source
|
|
907
|
+
|
|
908
|
+
`src/lineage/` may import **`matchSource`** and **`matchSinkOrSanitizer`**
|
|
909
|
+
from `../dataflow/catalog.js`, **`matchPrivacySink`** from
|
|
910
|
+
`../dataflow/privacy-catalog.js`, and **`accessPathOf`** from
|
|
911
|
+
`../dataflow/access-paths.js`.
|
|
912
|
+
|
|
913
|
+
Confirmed by reading `catalog.js`'s own module boundary, not by citing the
|
|
914
|
+
scoping doc's paraphrase:
|
|
915
|
+
|
|
916
|
+
- All four are **top-level `export function` declarations**, reachable
|
|
917
|
+
directly from the module. None is reached through `dataflow/engine.js`, and
|
|
918
|
+
none needs it.
|
|
919
|
+
- All four are **pure with respect to lineage state**. `catalog.js` holds
|
|
920
|
+
index maps and a provenance-filter memo built at module load from a frozen
|
|
921
|
+
catalog; nothing a caller passes mutates them, and nothing they return is a
|
|
922
|
+
live taint object. `accessPathOf` is a pure recursive function over an
|
|
923
|
+
exprDesc — `field-identity.js` already imports its sibling
|
|
924
|
+
`pathIsCoveredByPrefix` from the same file, which is the in-package
|
|
925
|
+
precedent.
|
|
926
|
+
- `DESIGN_REGISTRIES.md` §1's argument holds and is the reason this is the
|
|
927
|
+
right call rather than merely a permitted one: *"they never re-derive what a
|
|
928
|
+
call site matches … duplicating it would fork a matcher the corpus proves
|
|
929
|
+
against one that nothing proves."*
|
|
930
|
+
|
|
931
|
+
Still forbidden, unchanged (PRD §18.1): `dataflow/engine.js`'s live taint
|
|
932
|
+
state, `dataflow/summaries.js`'s `SummaryCache`, and `dataflow/index.js`'s
|
|
933
|
+
`runDeepAnalysis` (E **mirrors** its shape; E imports nothing from it).
|
|
934
|
+
|
|
935
|
+
`E1/12` enforces this mechanically — it reads the PoC's own source, extracts
|
|
936
|
+
every module specifier, and asserts the `dataflow/` subset is exactly those
|
|
937
|
+
three files, in the shape `path-query.js`'s own `['./ids.js']` boundary test
|
|
938
|
+
established.
|