@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,628 @@
|
|
|
1
|
+
# Intraprocedural Field-Identity Engine — Design Record
|
|
2
|
+
|
|
3
|
+
Scope: Sub-project A of Milestone 1 (see
|
|
4
|
+
`docs/superpowers/plans/2026-08-30-data-flow-explorer-m1-lineage-engine-scoping.md`).
|
|
5
|
+
Single-function analysis only. No interprocedural resolution, no path DAG,
|
|
6
|
+
no registry integration.
|
|
7
|
+
|
|
8
|
+
## 1. State shape
|
|
9
|
+
|
|
10
|
+
`Map<accessPath: string, Set<dataElementId: string>>` — for each access path
|
|
11
|
+
reachable in a function, the set of data-element IDs it carries. This is the
|
|
12
|
+
direct fix for FR-301: the existing taint engine's state
|
|
13
|
+
(`scanner/src/dataflow/access-paths.js`'s `Set<accessPath>`, boolean per
|
|
14
|
+
path) cannot distinguish "this path carries data element X" from "this path
|
|
15
|
+
carries data element Y" — only "this path is tainted." The existing
|
|
16
|
+
interprocedural summary cache (`scanner/src/dataflow/summaries.js`) is
|
|
17
|
+
explicitly documented as parameter-granularity only ("`f(obj)` with obj.foo
|
|
18
|
+
tainted is treated the same as obj.bar tainted") — reusing that state shape
|
|
19
|
+
would silently violate FR-301 the first time a benchmark case exercised two
|
|
20
|
+
distinct fields on the same object parameter. This is a hard fork, decided
|
|
21
|
+
here, not left to a later task to discover partway through implementation.
|
|
22
|
+
|
|
23
|
+
## 2. Reuse boundary (binding on every later task and sub-project)
|
|
24
|
+
|
|
25
|
+
**Reused directly, as pure utilities, unmodified:**
|
|
26
|
+
- `scanner/src/dataflow/access-paths.js`'s `accessPathOf`,
|
|
27
|
+
`pathIsCoveredByPrefix`, `isCoveredBy` — all operate purely on path
|
|
28
|
+
strings, no taint state involved. Confirmed by reading the file: none of
|
|
29
|
+
its exports take or return taint-specific state.
|
|
30
|
+
- The real JS/TS IR shape from `scanner/src/ir/parser-js.js` — consumed
|
|
31
|
+
as-is, never reimplemented.
|
|
32
|
+
|
|
33
|
+
**Structurally mirrored (same algorithm, new state type), never imported:**
|
|
34
|
+
- `scanner/src/dataflow/engine.js`'s `analyzeFunction` worklist loop (forward
|
|
35
|
+
dataflow analysis, explicit worklist, join-via-repeated-merge at CFG
|
|
36
|
+
branch-rejoin points, no separate "join node" concept — a rejoin is just
|
|
37
|
+
a point that receives more than one predecessor's merged state over the
|
|
38
|
+
worklist's lifetime). This package's own `analyzeFunctionFieldIdentity`
|
|
39
|
+
(Task 4) copies this ALGORITHM, not this file's code or state.
|
|
40
|
+
|
|
41
|
+
**Never reused, must be reimplemented for field-identity semantics:**
|
|
42
|
+
- `access-paths.js`'s `addPath`/`removePathAndDescendants`/`joinSets` — these
|
|
43
|
+
operate on `Set<string>` (presence/absence). Field identity needs
|
|
44
|
+
Map-shaped equivalents that union *label sets* per path on join, not just
|
|
45
|
+
path presence. See `field-identity.js` (Task 2).
|
|
46
|
+
- `scanner/src/dataflow/summaries.js`'s `SummaryCache` — out of scope for
|
|
47
|
+
this plan (interprocedural is Sub-project B), but its `_hashState`
|
|
48
|
+
approach (canonicalize then hash) is worth mirroring conceptually once
|
|
49
|
+
Sub-project B needs a cache key over field-identity state; `field-identity.js`
|
|
50
|
+
ships a `hashState` now specifically so Sub-project B doesn't have to
|
|
51
|
+
retrofit one onto an already-shipped state shape.
|
|
52
|
+
|
|
53
|
+
**Forbidden absolutely:** importing `scanner/src/dataflow/engine.js` or
|
|
54
|
+
touching its live taint `Set<accessPath>` state. Per
|
|
55
|
+
`scanner/src/lineage/CLAUDE.md` and PRD §3.1/§18.1.
|
|
56
|
+
|
|
57
|
+
## 3. Ancestor/descendant semantics (bidirectional — corrected from the original one-directional design)
|
|
58
|
+
|
|
59
|
+
`identitiesAt(state, path)` aggregates in BOTH directions:
|
|
60
|
+
|
|
61
|
+
- **Ancestor coverage** (original): a coarser recorded fact about a
|
|
62
|
+
container implies the same fact about anything under it, when nothing
|
|
63
|
+
more specific overrides it — `identitiesAt(state, 'obj.email')` includes
|
|
64
|
+
any identity recorded at `'obj'` itself. Mirrors `access-paths.js`'s
|
|
65
|
+
`isCoveredBy`.
|
|
66
|
+
- **Descendant coverage** (added by the corrected design below): asking
|
|
67
|
+
about a container AS A WHOLE aggregates everything recorded under it —
|
|
68
|
+
`identitiesAt(state, 'obj')` includes an identity recorded only at
|
|
69
|
+
`'obj.email'`. The object legitimately carries every field's identity
|
|
70
|
+
when read as a whole (`return obj` after `obj = {email: X, ssn: Y}` must
|
|
71
|
+
see both).
|
|
72
|
+
|
|
73
|
+
This does NOT reintroduce cross-field leakage: querying `obj.email` never
|
|
74
|
+
picks up a sibling path like `obj.ssn` — neither is a prefix of the other,
|
|
75
|
+
so descendant coverage only ever aggregates when the query path is an
|
|
76
|
+
actual ancestor of the recorded path, never across siblings.
|
|
77
|
+
|
|
78
|
+
### Corrected design
|
|
79
|
+
|
|
80
|
+
The original design (above, before this note) was one-directional:
|
|
81
|
+
ancestor-visible-from-descendant only, with the reverse deliberately
|
|
82
|
+
excluded ("asking about the whole object doesn't imply you learn a fact
|
|
83
|
+
that was only ever established about one of its fields"). This was wrong,
|
|
84
|
+
and the final whole-branch review (composing pieces no single task review
|
|
85
|
+
had reason to combine) found why: `assign`'s object-literal handling
|
|
86
|
+
combined with the one-directional `identitiesAt` forced a choice between
|
|
87
|
+
two broken options —
|
|
88
|
+
|
|
89
|
+
(a) write the object literal's resolved identities BOTH at each field's
|
|
90
|
+
own sub-path (`rec.email`, `rec.ssn`) AND as a flat union at the
|
|
91
|
+
container's own path (`rec`) — needed so a later whole-object read
|
|
92
|
+
(`return rec`) could see every field, since one-directional
|
|
93
|
+
`identitiesAt` couldn't look downward. But this write is exactly
|
|
94
|
+
FR-301's violation: `identitiesAt(state, 'rec.email')` then
|
|
95
|
+
ancestor-inherits from the coarse `rec` entry and returns
|
|
96
|
+
`{email, ssn}` — both fields, merged, for a query that asked about
|
|
97
|
+
only one. This was the actual bug (see `engine.js`'s pre-fix `assign`
|
|
98
|
+
case), and it was a plan-authoring bug, not an implementer error — the
|
|
99
|
+
flat-union-at-root write was specified verbatim in this project's own
|
|
100
|
+
original brief.
|
|
101
|
+
|
|
102
|
+
(b) drop the flat-union-at-root write and keep only the per-field
|
|
103
|
+
sub-path writes — this fixes (a) but breaks `return rec`: with the
|
|
104
|
+
old one-directional `identitiesAt`, querying `rec` finds nothing,
|
|
105
|
+
since no identity was ever recorded at `rec` itself.
|
|
106
|
+
|
|
107
|
+
The bidirectional design resolves both without a redundant coarse write:
|
|
108
|
+
per-field writes alone are sufficient (`assign`'s object-literal case now
|
|
109
|
+
writes ONLY `rec.email`/`rec.ssn`, never `rec`), `identitiesAt(state,
|
|
110
|
+
'rec.email')` correctly returns only `{email}` (no ancestor entry exists
|
|
111
|
+
to leak from), and `identitiesAt(state, 'rec')` correctly returns
|
|
112
|
+
`{email, ssn}` via descendant aggregation. Sibling paths remain correctly
|
|
113
|
+
isolated from each other throughout, since aggregation only ever follows
|
|
114
|
+
an actual prefix relationship.
|
|
115
|
+
|
|
116
|
+
Unlike boolean taint (where recording a fact at a coarser path makes any
|
|
117
|
+
existing finer-path fact redundant and `access-paths.js`'s `addPath` prunes
|
|
118
|
+
it away), field-identity state does **not** prune a descendant entry when an
|
|
119
|
+
ancestor gains a *different* identity — `obj` carrying data element X and
|
|
120
|
+
`obj.email` carrying data element Y are both real, independently meaningful
|
|
121
|
+
facts that must coexist (this coexistence IS FR-301's core requirement).
|
|
122
|
+
Pruning is deliberately NOT implemented as an optimization in this first
|
|
123
|
+
slice — `field-identity.js`'s state can carry redundant entries (an
|
|
124
|
+
ancestor and descendant both recording the same identity); this affects
|
|
125
|
+
efficiency, never correctness, since `identitiesAt`'s union already handles
|
|
126
|
+
it. Left as a documented, deferred optimization, not built now, per this
|
|
127
|
+
project's "don't design for hypothetical future requirements" convention.
|
|
128
|
+
|
|
129
|
+
### The residual principle (round 2 — closes an aliasing gap round 1 left open)
|
|
130
|
+
|
|
131
|
+
The bidirectional `identitiesAt` fix above (round 1) was necessary but not
|
|
132
|
+
sufficient. A final re-review of round 1 found the exact same coarse-merge
|
|
133
|
+
bug survives through one level of variable aliasing, because round 1 only
|
|
134
|
+
applied the "write per-field, not a coarse root value" rule at the object-
|
|
135
|
+
literal call site — `resolveExprIdentities`'s `ident`/`member` case still
|
|
136
|
+
unconditionally returned an empty `byPath`, even when the state genuinely
|
|
137
|
+
had per-field structure recorded under the resolved path. Concretely:
|
|
138
|
+
|
|
139
|
+
```js
|
|
140
|
+
function combine(user) {
|
|
141
|
+
const copy = user; // copy = user is a plain alias, not an object
|
|
142
|
+
// literal, so round 1's `assign` fix never
|
|
143
|
+
// triggers: resolveExprIdentities(state, user)
|
|
144
|
+
// returned byPath: Map() (always empty for
|
|
145
|
+
// ident/member), so `assign` fell into the
|
|
146
|
+
// byPath-empty branch and wrote the FULL flat
|
|
147
|
+
// union ({email, ssn}) at `copy`'s own path —
|
|
148
|
+
// exactly the coarse-root-write bug round 1
|
|
149
|
+
// fixed for object literals, recreated here.
|
|
150
|
+
const e = copy.email; // identitiesAt('copy.email') then ancestor-
|
|
151
|
+
// inherits from that coarse `copy` entry and
|
|
152
|
+
// returns {email, ssn} — both fields.
|
|
153
|
+
return e; // WRONG: should be {email} only.
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
This was measurably *worse* than the pre-round-1 code for this exact shape:
|
|
158
|
+
before round 1, a one-directional `identitiesAt` silently dropped this flow
|
|
159
|
+
entirely (safe but useless); round 1's bidirectional `identitiesAt` can now
|
|
160
|
+
correctly resolve `user`'s full field set on read, but `assign` had nowhere
|
|
161
|
+
to put that structured information except a coarse merge — going from
|
|
162
|
+
"silently drops the flow" to "silently merges distinct fields", the more
|
|
163
|
+
serious of FR-301's two failure modes.
|
|
164
|
+
|
|
165
|
+
The fix generalizes round 1's rule into one helper, `residualFlat(flat,
|
|
166
|
+
byPath)` (`engine.js`), applied consistently everywhere a `flat`+`byPath`
|
|
167
|
+
pair is written to a target/key — not special-cased at `assign` alone.
|
|
168
|
+
Two concrete applications from round 2:
|
|
169
|
+
|
|
170
|
+
- `resolveExprIdentities`'s `ident`/`member` case now populates `byPath`
|
|
171
|
+
from the state's recorded descendants of the resolved path — an alias
|
|
172
|
+
like `user` (with `user.email`/`user.ssn` recorded) is now structurally
|
|
173
|
+
indistinguishable from a fresh object literal `{email: ..., ssn: ...}`
|
|
174
|
+
for every downstream consumer of `byPath`.
|
|
175
|
+
- `resolveExprIdentities`'s `object` case writes `residualFlat(r.flat,
|
|
176
|
+
r.byPath)` — not the full `r.flat` — coarsely at a property's own key.
|
|
177
|
+
This closes a *deeper, compounding* version of the same bug that the
|
|
178
|
+
point above alone would reintroduce: `{ a: someAliasedObject }`, where
|
|
179
|
+
`someAliasedObject` is itself an alias with `byPath` structure. Without
|
|
180
|
+
this, the coarse write at key `a` would duplicate what the nested
|
|
181
|
+
`a.x`/`a.y` entries already separate, one level deeper than round 1 ever
|
|
182
|
+
checked.
|
|
183
|
+
- `step()`'s `assign` case collapses round 1's explicit
|
|
184
|
+
byPath-empty-vs-nonempty branching into one rule: write every `byPath`
|
|
185
|
+
entry at its own sub-path, and write `residualFlat(resolved.flat,
|
|
186
|
+
resolved.byPath)` — whatever isn't already captured by that
|
|
187
|
+
structure — coarsely at the target's own root. An empty residual is a
|
|
188
|
+
no-op, so this naturally subsumes both of round 1's cases without
|
|
189
|
+
branching on them explicitly.
|
|
190
|
+
|
|
191
|
+
**This was originally written up as "applied consistently at exactly three
|
|
192
|
+
sites."** That was a mistake independent of whether the three were
|
|
193
|
+
correctly identified: a hand-counted enumeration reads as a closed,
|
|
194
|
+
completed list, and a fixed-count claim is exactly what let a fourth,
|
|
195
|
+
fifth, and sixth site (`union`, `logical`, `assign-expr` — see round 3
|
|
196
|
+
below) go unexamined through two further rounds of scrutiny. The durable
|
|
197
|
+
statement is the general invariant in "The structure-preserving vs.
|
|
198
|
+
structure-flattening invariant (round 3)" below, not a count. Treat any
|
|
199
|
+
future "N sites" phrasing in this document as a bug in the document.
|
|
200
|
+
|
|
201
|
+
`identitiesAt`'s read-side logic (the bidirectional aggregation from round
|
|
202
|
+
1) is untouched by round 2 or round 3 — only the *write* side (what gets
|
|
203
|
+
recorded by `assign`, and how `resolveExprIdentities` reports structure to
|
|
204
|
+
it) has changed. A whole-object read through an alias (`const copy = user;
|
|
205
|
+
return copy;`) still correctly aggregates every field, exactly as round 1's
|
|
206
|
+
fix already guaranteed, because that guarantee never depended on `assign`
|
|
207
|
+
writing a coarse value — it depended on `identitiesAt`'s descendant
|
|
208
|
+
aggregation, which neither round 2 nor round 3 touches.
|
|
209
|
+
|
|
210
|
+
### The structure-preserving vs. structure-flattening invariant (round 3 — closes the bug CLASS, not just individual sites)
|
|
211
|
+
|
|
212
|
+
A third re-review found the SAME coarse-merge bug survived, untouched by
|
|
213
|
+
rounds 1-2, in three more `resolveExprIdentities` cases: `union` (ternary),
|
|
214
|
+
`logical` (`||`/`&&`/`??`), and `assign-expr`. All three unconditionally
|
|
215
|
+
returned `byPath: new Map()` regardless of what their operands' `byPath`
|
|
216
|
+
contained — the same class of bug round 2 closed for `ident`/`member` and
|
|
217
|
+
`object`, just not yet applied here.
|
|
218
|
+
|
|
219
|
+
Confirmed via the real parser:
|
|
220
|
+
|
|
221
|
+
```js
|
|
222
|
+
const c = flag ? user : other; return c.email; // → [email, ssn, name] instead of [email]
|
|
223
|
+
const c = user ?? {}; return c.email; // → [email, ssn] instead of [email]
|
|
224
|
+
const c = flag ? user : user; return c.email; // → [email, ssn] instead of [email]
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
The third example is the sharpest proof this is a bug rather than a
|
|
228
|
+
legitimate approximation: both ternary branches ARE the same object, so
|
|
229
|
+
there is no real alternative to justify merging `user`'s own fields
|
|
230
|
+
together — yet the pre-fix code did exactly that, purely because the
|
|
231
|
+
ternary *syntax* was present.
|
|
232
|
+
|
|
233
|
+
**Why this is the SAME bug, not the (legitimately different, already-
|
|
234
|
+
accepted) branch-alternatives approximation**: the CFG's own branch join
|
|
235
|
+
(`joinStates`) already handles the equivalent
|
|
236
|
+
`if (flag) { c = user; } else { c = other; } return c.email;` correctly —
|
|
237
|
+
it unions PER PATH, so `c.email`/`c.ssn`/`c.name` stay separated even
|
|
238
|
+
though the join conservatively doesn't know which branch ran. The
|
|
239
|
+
ternary/logical form of the exact same program gave a strictly WORSE
|
|
240
|
+
(wrong) answer for identical semantics — that asymmetry between two
|
|
241
|
+
syntactic forms of the same semantics is the tell. The correct
|
|
242
|
+
approximation is "union per sub-path across alternatives" (exactly what
|
|
243
|
+
`joinStates` already does at the CFG level) — NOT "flatten everything into
|
|
244
|
+
one coarse blob."
|
|
245
|
+
|
|
246
|
+
Rather than enumerate a fixed site count again (see the note above on why
|
|
247
|
+
that failed twice), the durable rule is: every `resolveExprIdentities` case
|
|
248
|
+
falls into one of two categories, and every case in the switch must be
|
|
249
|
+
checked against this invariant whenever the switch changes.
|
|
250
|
+
|
|
251
|
+
- **Structure-preserving** — its result could genuinely BE an existing
|
|
252
|
+
structured value from `state`, by reference/selection, not by computing
|
|
253
|
+
something new. Must forward `byPath`: `ident` (round 2), `member`
|
|
254
|
+
(round 2 for the case where its base is a pure ident/member chain
|
|
255
|
+
resolvable via `accessPathOf`; round 4 added a second path — see below —
|
|
256
|
+
for when the base is NOT a pure chain, e.g. `(user ?? other).email`),
|
|
257
|
+
`object` (constructs new structure directly; round 1/2), `union`
|
|
258
|
+
(selects one branch verbatim; round 3), `logical` (short-circuit
|
|
259
|
+
evaluation can return an operand verbatim; round 3), `assign-expr`
|
|
260
|
+
(simple pass-through of its resolved source; round 3).
|
|
261
|
+
- **Structure-flattening, correctly and by design** — nothing to forward,
|
|
262
|
+
or forwarding would be actively wrong. Stays flat-only, deliberately:
|
|
263
|
+
`literal` (no state involved), `tpl` (template literals always produce a
|
|
264
|
+
new string, primitivizing whatever's interpolated), `binary` (arithmetic/
|
|
265
|
+
comparison operators always produce a new primitive — this is why
|
|
266
|
+
`binary` and `logical` are split into separate cases as of round 3, not
|
|
267
|
+
shared), `array` (this IR's parser transparently unwraps `SpreadElement`
|
|
268
|
+
— see `scanner/src/ir/parser-js.js`'s `exprOf`'s `SpreadElement` case —
|
|
269
|
+
so `[...xs, user]` and `[xs, user]` are byte-identical in the IR; naive
|
|
270
|
+
per-index attribution would actively MISATTRIBUTE a spread source's
|
|
271
|
+
contents to a literal index, which is worse than staying uniformly flat.
|
|
272
|
+
This is corrected from an earlier, factually false version of this
|
|
273
|
+
rationale — "no index-sensitive access paths" — which round 4's review
|
|
274
|
+
found untrue: `accessPathOf`/the parser's `exprOf`/`lhsPath` DO extract
|
|
275
|
+
literal computed keys and can build paths like `arr.0`. The spread
|
|
276
|
+
ambiguity, not a missing capability, is the real reason to stay flat.
|
|
277
|
+
Fixing this properly would require the parser to distinguish spread
|
|
278
|
+
elements from literal elements first — out of scope for this plan),
|
|
279
|
+
`call` (an unresolved call's return value is genuinely UNKNOWN structure
|
|
280
|
+
— correctly flat + `widened: true`, honestly modeling "we don't know,"
|
|
281
|
+
not laundering an identity), `unknown`/default (nothing to preserve).
|
|
282
|
+
|
|
283
|
+
### The three hop types the invariant covers (round 4 — broadens the invariant beyond `resolveExprIdentities`'s own switch)
|
|
284
|
+
|
|
285
|
+
A fourth re-review found the invariant as stated through round 3 was
|
|
286
|
+
itself scoped too narrowly: it only asked "which `resolveExprIdentities`
|
|
287
|
+
switch cases PRODUCE `byPath`," which is exactly the round-1-through-3
|
|
288
|
+
bug class, but misses two other places structure is lost that are not
|
|
289
|
+
about which case *produces* `byPath` at all.
|
|
290
|
+
|
|
291
|
+
- **Production** — a switch case building a value's structure. This is
|
|
292
|
+
rounds 1-3's fixes, and the category the "structure-preserving vs.
|
|
293
|
+
structure-flattening" list above documents.
|
|
294
|
+
- **Selection** — reading a field off an already-produced structured
|
|
295
|
+
value. Round 3 taught `union`/`logical`/`object`/`assign-expr` to
|
|
296
|
+
correctly report their structure via `byPath` when RESOLVED AS A WHOLE
|
|
297
|
+
(e.g. `const c = user ?? other; return c.email;` correctly resolves to
|
|
298
|
+
`[email]`), but the pre-round-3 `member` case resolved its base only via
|
|
299
|
+
`accessPathOf`, which returns `null` for anything that isn't a pure
|
|
300
|
+
ident/member chain — so `member` fell into `noIdentity()` and silently
|
|
301
|
+
DROPPED the identity the moment the exact same value was read directly,
|
|
302
|
+
with no intermediate variable: `return (user ?? other).email;` returned
|
|
303
|
+
`[]`. Same semantics, two syntactic forms, two different (and one
|
|
304
|
+
wrong) answers — the same tell every prior round used to find its bug.
|
|
305
|
+
Fixed by giving `member` a second resolution path, alongside its
|
|
306
|
+
existing `accessPathOf`-based one, that resolves the base recursively
|
|
307
|
+
and selects `prop` out of the base's `byPath` — the read-side mirror of
|
|
308
|
+
how `object`'s construction attributes a property to its own key.
|
|
309
|
+
- **Write-out** — writing a resolved value to a target path that must
|
|
310
|
+
itself be a valid, non-fabricated path. `step()`'s `assign` case passed
|
|
311
|
+
`node.target` straight through to `removeIdentitiesAt`/`addIdentity`
|
|
312
|
+
with no guard that it was actually a string path. Assignment-expression-
|
|
313
|
+
form destructuring (`({a} = obj)`, lowered by the real parser into one
|
|
314
|
+
`assign` CFG node whose `target` is the raw pattern object, not a
|
|
315
|
+
string) implicitly stringified to the literal string `"[object Object]"`
|
|
316
|
+
— every such destructuring anywhere in a function collided onto this
|
|
317
|
+
one fabricated key, silently merging fields from unrelated statements.
|
|
318
|
+
Fixed by guarding `node.target` to be a string before writing anything
|
|
319
|
+
(skip rather than fabricate a key), matching a precedent already
|
|
320
|
+
established in the sibling taint engine (`scanner/src/dataflow/
|
|
321
|
+
engine.js`'s own `assign` case) that this package had not inherited.
|
|
322
|
+
|
|
323
|
+
**The general rule going forward:** a structured value's `byPath` must
|
|
324
|
+
survive every hop it passes through — anywhere structure is built,
|
|
325
|
+
selected out of, or written to a target — losing it (via an empty
|
|
326
|
+
fallback, a fabricated key, or a coarse merge) at ANY of these three hop
|
|
327
|
+
types is this bug class, regardless of which specific construct triggers
|
|
328
|
+
it. Checking only "does this switch case forward `byPath`" (round 3's own
|
|
329
|
+
framing) is necessary but not sufficient — selection sites outside the
|
|
330
|
+
switch's own recursive calls, and write-out sites in `step()`, must be
|
|
331
|
+
checked too.
|
|
332
|
+
|
|
333
|
+
### Statically-unknown path components (round 5 — the three hop types were right, but assumed every PATH COMPONENT flowing through them was a real property name)
|
|
334
|
+
|
|
335
|
+
A fifth re-review found that round 4's three-hop-types invariant, while
|
|
336
|
+
correctly identifying *where* structure can be lost, never questioned
|
|
337
|
+
whether the path components flowing through those hops (`node.target`,
|
|
338
|
+
`expr.prop`, `prop.key`) are always real, distinct property names. They
|
|
339
|
+
are not: `scanner/src/dataflow/access-paths.js`'s `accessPathOf` — reused
|
|
340
|
+
directly by this package per §2's reuse boundary — already maps a
|
|
341
|
+
computed member access with a statically-unknown key (`user[k]`, where `k`
|
|
342
|
+
is a variable, not a literal) to a path ending in the literal segment `*`
|
|
343
|
+
(e.g. `"user.*"`). This is an existing, already-established convention in
|
|
344
|
+
the shared IR/access-path layer (`scanner/src/ir/parser-js.js`'s
|
|
345
|
+
`MemberExpression` case and `lhsPath`'s computed-write handling both
|
|
346
|
+
already emit it), not something round 5 invented — but this package had
|
|
347
|
+
never interpreted it specially, and it broke all three hop types at once:
|
|
348
|
+
|
|
349
|
+
- **Selection**: `member`'s path-succeeds branch queried
|
|
350
|
+
`identitiesAt(state, 'user.*')` for `user[k]`, which never matches real
|
|
351
|
+
entries like `'user.email'` — silently returning `[]` instead of
|
|
352
|
+
propagating a widened flow. This directly contradicted this document's
|
|
353
|
+
own §4 "Unresolved function calls" entry, which already named a dynamic
|
|
354
|
+
property key as an example that must propagate as a widened flow, never
|
|
355
|
+
silently vanish (FR-306's "never launder identity into a clean value"
|
|
356
|
+
principle).
|
|
357
|
+
- **Write-out**: every computed-key write on the SAME container lowers to
|
|
358
|
+
the SAME literal target string (`'bag.*'`), since the actual runtime key
|
|
359
|
+
is statically unknown — `bag[k1] = user.email; bag[k2] = user.ssn;`
|
|
360
|
+
produces two `assign` nodes with the identical target `'bag.*'`.
|
|
361
|
+
`step()`'s `assign` case's STRONG update (`removeIdentitiesAt` before
|
|
362
|
+
writing) treated these as the same location a later write legitimately
|
|
363
|
+
overwrites, silently deleting the first write's identity —
|
|
364
|
+
`scanner/src/dataflow/engine.js`'s `_addPathAliasAware` had already
|
|
365
|
+
solved exactly this for the sibling taint engine (a trailing `.*` write
|
|
366
|
+
must be a WEAK update — add to the container, never clear it first) but
|
|
367
|
+
this package had not inherited that precedent, the same pattern as round
|
|
368
|
+
4's Finding 5 (write-out sites need their own scrutiny, not just
|
|
369
|
+
production/selection).
|
|
370
|
+
- **Production**: `scanner/src/ir/parser-js.js`'s `ObjectExpression`
|
|
371
|
+
handling built each property's `key` as `p.key.name || ...` with no
|
|
372
|
+
check of Babel's own `p.computed` flag — so a computed property with a
|
|
373
|
+
non-literal key expression (`{[k]: v}`) resolved to the key
|
|
374
|
+
EXPRESSION's own variable name (`'k'`), colliding with an explicit,
|
|
375
|
+
non-computed property literally named `k` on the same object:
|
|
376
|
+
`{ k: user.ssn, [k]: user.email }` produced two properties both keyed
|
|
377
|
+
`'k'`, which `resolveExprIdentities`'s `object` case then merged into
|
|
378
|
+
ONE byPath entry — FR-301's core violation, via a more innocuous-looking
|
|
379
|
+
fabricated key than any prior round's finding.
|
|
380
|
+
|
|
381
|
+
**The fix**, entirely at the engine layer for selection/write-out
|
|
382
|
+
(wildcard handling is a policy decision about how to interpret a path
|
|
383
|
+
component, not something baked into `field-identity.js`'s pure state
|
|
384
|
+
primitives) plus a minimal, convention-following change to the shared
|
|
385
|
+
parser for production: a `'*'`-ending (or bare `'*'`) path component is
|
|
386
|
+
now recognized wherever it can appear —
|
|
387
|
+
|
|
388
|
+
- `member`'s path-succeeds branch and its non-path fallback both check for
|
|
389
|
+
a `'*'` key/trailing-`.*` path and resolve the CONTAINER's full
|
|
390
|
+
aggregate (via `identitiesAt`'s existing descendant aggregation, or the
|
|
391
|
+
base's own `flat`), flagged `widened: true`.
|
|
392
|
+
- `step()`'s `assign` case checks for a `'*'`-ending target and performs a
|
|
393
|
+
WEAK update (`addIdentity` onto whatever the container already carries,
|
|
394
|
+
never `removeIdentitiesAt` first), flagged as a `dynamic-property-key`
|
|
395
|
+
widening event.
|
|
396
|
+
- `parser-js.js`'s `ObjectExpression` handling now checks `p.computed`: a
|
|
397
|
+
computed key that is itself a resolvable literal (`{[42]: v}`,
|
|
398
|
+
`{['literal']: v}` — Babel still marks these `computed: true`) still
|
|
399
|
+
resolves to that literal's string form, exactly as before; a computed
|
|
400
|
+
key that is NOT a literal resolves to the literal string `'*'` —
|
|
401
|
+
mirroring the EXISTING computed-member-access convention this same file
|
|
402
|
+
already uses for `obj[k]` reads and writes, not a new one.
|
|
403
|
+
`resolveExprIdentities`'s `object` case folds a `key === '*'` property
|
|
404
|
+
into the object's coarse residual (never a byPath entry keyed `'*'`,
|
|
405
|
+
which would just be a differently-shaped version of the same collision
|
|
406
|
+
bug).
|
|
407
|
+
|
|
408
|
+
**The durable framing, restated once more per this round's own finding
|
|
409
|
+
(subsumes round 4's "three hop types" note and this round's finding
|
|
410
|
+
without needing to re-enumerate constructs again):** every path component
|
|
411
|
+
the IR supplies must be either a real property name or an explicitly
|
|
412
|
+
modeled unknown (`'*'`), and every kill (`removeIdentitiesAt`) must be
|
|
413
|
+
justified as a strong update on a definite, uniquely-identified location —
|
|
414
|
+
a write to an unknown/aliased location must be a weak update instead.
|
|
415
|
+
|
|
416
|
+
### Two under-enforcements of the round-5 invariant (round 6 — the invariant itself was correct, its application wasn't complete)
|
|
417
|
+
|
|
418
|
+
A sixth re-review found the round-5 invariant CORRECT but under-enforced in
|
|
419
|
+
two places, both fixed together.
|
|
420
|
+
|
|
421
|
+
**Finding 1 — destructuring keys had the same computed-key bug round 5
|
|
422
|
+
fixed for object literals.** Round 5 fixed `parser-js.js`'s
|
|
423
|
+
`ObjectExpression` handling (production, for `{[k]: v}`) but never touched
|
|
424
|
+
`lhsPath`'s `ObjectPattern` branch — the DESTRUCTURING-pattern lowering, a
|
|
425
|
+
different code path in the same file, used for `const {[field]: value} =
|
|
426
|
+
user`. It had the identical bug: a non-literal computed key resolved to the
|
|
427
|
+
key EXPRESSION's own variable name instead of `'*'`, so `const { [field]:
|
|
428
|
+
value } = user; return value;` silently dropped `value`'s identity
|
|
429
|
+
entirely (`returnFacts: []`) — the equivalent `return user[field]` already
|
|
430
|
+
widened correctly per round 5's fix.
|
|
431
|
+
|
|
432
|
+
Fixed by extracting the shared `resolveObjectKey(p)` helper from
|
|
433
|
+
`ObjectExpression`'s already-fixed logic and calling it from both sites, so
|
|
434
|
+
a third instance of this exact bug class can't appear in some future
|
|
435
|
+
object-key-reading code path in this file.
|
|
436
|
+
|
|
437
|
+
**Finding 2 — the round-5 wildcard guards only handled a TRAILING `'*'`,
|
|
438
|
+
not an INTERIOR one.** Round 5's guards in `engine.js` (`path === '*' ||
|
|
439
|
+
path.endsWith('.*')` on the selection side; `node.target === '*' ||
|
|
440
|
+
node.target.endsWith('.*')` on the write-out side) only recognized a
|
|
441
|
+
wildcard segment at the very end of a path. A wildcard can appear in the
|
|
442
|
+
MIDDLE too: `store[k1].name`/`store[k2].name` both lower to the identical
|
|
443
|
+
access path `'store.*.name'` (the `'*'` is interior, not trailing) —
|
|
444
|
+
`endsWith('.*')` is false for this string, so it fell through to the OLD,
|
|
445
|
+
unfixed strong-update/silent-drop behavior. This is round 5's own bug (b)
|
|
446
|
+
recurring one path segment deeper: the second assign's
|
|
447
|
+
`removeIdentitiesAt(state, 'store.*.name')` deleted the first write's
|
|
448
|
+
identity, and the read-side equivalent (`store[k].name = user.ssn; return
|
|
449
|
+
store.a.name;`) silently returned `[]` instead of conservatively resolving
|
|
450
|
+
via the definite prefix.
|
|
451
|
+
|
|
452
|
+
Fixed by replacing both position-dependent checks with two new shared
|
|
453
|
+
helpers in `engine.js`, `definitePrefixBeforeWildcard(path)` and
|
|
454
|
+
`pathHasWildcard(path)` — the former finds the longest wildcard-free prefix
|
|
455
|
+
of a path before its FIRST `'*'` segment, at any position, subsuming round
|
|
456
|
+
5's trailing-only handling as a special case (`'bag.*'` still resolves to
|
|
457
|
+
`'bag'`, exactly as before) while also correctly handling an interior one
|
|
458
|
+
(`'store.*.name'` now resolves to `'store'`, not falling through
|
|
459
|
+
unguarded).
|
|
460
|
+
|
|
461
|
+
`member`'s non-path fallback branch (round 4, for when `accessPathOf`
|
|
462
|
+
returns `null` because the base isn't a pure ident/member chain) needed no
|
|
463
|
+
change: it never constructs or parses a dotted path string in the first
|
|
464
|
+
place — it resolves the base recursively via the expression tree, and each
|
|
465
|
+
recursive call independently checks its own single-segment `expr.prop ===
|
|
466
|
+
'*'`. An "interior" wildcard in this branch's terms is just an outer
|
|
467
|
+
`member` node whose base (a nested `member`) itself has `prop === '*'` —
|
|
468
|
+
already handled by that same check firing one recursion level in, with no
|
|
469
|
+
path-string parsing involved at any level.
|
|
470
|
+
|
|
471
|
+
**Deferred, not fixed this round (Finding 3 from the same re-review):** a
|
|
472
|
+
widening event recorded by `assign`/`return`'s widening-push always uses
|
|
473
|
+
the hardcoded reason string `'unresolved-call'`, even when the actual
|
|
474
|
+
cause was a dynamic property key (`'dynamic-property-key'` is used
|
|
475
|
+
correctly at the `assign` case's own `'*'`-target weak-update branch, but
|
|
476
|
+
NOT at the general `resolved.widened` push a few lines below it, which
|
|
477
|
+
covers e.g. a `return`-side dynamic-key read). This is Minor and
|
|
478
|
+
soundness-unaffected — the identity SETS themselves are correct either
|
|
479
|
+
way, only the stated cause in the widening ledger can be wrong — and is
|
|
480
|
+
left as a documented follow-up: a real fix would require threading a
|
|
481
|
+
reason string through `resolveExprIdentities`'s return shape more broadly
|
|
482
|
+
than this round's scope covers.
|
|
483
|
+
|
|
484
|
+
## 4. Per-construct handling (JS/TS, this plan's scope)
|
|
485
|
+
|
|
486
|
+
- **Assignment** (`target: string`, `source: exprDesc`): resolve `source`'s
|
|
487
|
+
identities, clear stale facts at `target` and its descendants
|
|
488
|
+
(reassignment invalidates whatever was there), write the resolved
|
|
489
|
+
identities at `target`.
|
|
490
|
+
- **Object literals**: each property's value is resolved recursively and
|
|
491
|
+
attributed to `target.<key>` (dotted, nested objects produce dotted
|
|
492
|
+
sub-paths like `target.a.b`) — this is the concrete mechanism that proves
|
|
493
|
+
FR-301: two properties on one object literal, from two different data
|
|
494
|
+
elements, land at two different access paths in the state, never merged.
|
|
495
|
+
`assign`'s handling of this case writes ONLY these per-field `byPath`
|
|
496
|
+
entries — never a coarse value at the container's own path (`target`
|
|
497
|
+
itself) — because for a plain object literal the residual is always
|
|
498
|
+
empty (see §3's "Corrected design" and "The residual principle"
|
|
499
|
+
notes for why a coarse root write was tried and rejected, and for the
|
|
500
|
+
general rule this is a special case of). This is the documented,
|
|
501
|
+
correct behavior, not an oversight: `identitiesAt`'s descendant
|
|
502
|
+
aggregation (§3) already answers a whole-object read like `return
|
|
503
|
+
target` correctly from the per-field entries alone. The same residual
|
|
504
|
+
rule applies identically to a plain variable/property alias
|
|
505
|
+
(`target = someOtherRef`) — see §3's "The residual principle" note —
|
|
506
|
+
since round 2 made `resolveExprIdentities` report `byPath` structure
|
|
507
|
+
for those references too, not just for object literals.
|
|
508
|
+
- **Array literals**: flattened, no index sensitivity. **Not** because this
|
|
509
|
+
IR lacks index-sensitive access paths — round 4's review found that
|
|
510
|
+
claim false: `accessPathOf`/the parser's `exprOf`/`lhsPath` DO extract
|
|
511
|
+
literal computed keys for both reads and writes, and paths like `arr.0`
|
|
512
|
+
are buildable. The real reason: the parser transparently unwraps
|
|
513
|
+
`SpreadElement` (`scanner/src/ir/parser-js.js`'s `exprOf`'s
|
|
514
|
+
`SpreadElement` case), so `[...xs, user]` and `[xs, user]` are
|
|
515
|
+
byte-identical in the IR — naive per-index attribution would actively
|
|
516
|
+
MISATTRIBUTE a spread source's contents to a literal index, which is
|
|
517
|
+
worse than staying uniformly flat. An array carrying two different
|
|
518
|
+
fields at two different indices is not distinguishable in this slice;
|
|
519
|
+
this is an accepted, documented limitation, not silently wrong (every
|
|
520
|
+
element's identities are still recorded, just flattened into one bag
|
|
521
|
+
rather than per-index). Fixing this properly would require the parser
|
|
522
|
+
to distinguish spread elements from literal elements first — out of
|
|
523
|
+
scope for this plan.
|
|
524
|
+
- **Object spread/rest — NOW FIXED.** `{...user}` (object spread in a
|
|
525
|
+
literal) and `const {...rest} = user` (object rest in a destructuring
|
|
526
|
+
pattern) were previously silently dropped entirely by the parser —
|
|
527
|
+
confirmed pre-existing since `scanner/src/ir/parser-js.js`'s first
|
|
528
|
+
commit. Both are now fixed — a parser change for each, plus one small
|
|
529
|
+
new branch in this package's own `engine.js` for the spread case (the
|
|
530
|
+
rest fix genuinely needed zero engine changes, reusing an already-built
|
|
531
|
+
wildcard-selection mechanism unchanged; the spread fix did not — see the
|
|
532
|
+
first sub-bullet below):
|
|
533
|
+
- Object literal spread (`{...user}`) merges the resolved source's
|
|
534
|
+
`byPath` structure as top-level siblings, preserving field-level
|
|
535
|
+
distinctness. A spread property contributes to the object's
|
|
536
|
+
structured `byPath`, not to its coarse residual — two spread
|
|
537
|
+
sources (`{...user, ...other}`) each contribute their own fields
|
|
538
|
+
without merging, the same way two explicit properties do. Confirmed
|
|
539
|
+
tested by real before/after `runScan` comparison.
|
|
540
|
+
- Destructuring rest (`const {...rest} = user`) reuses the existing
|
|
541
|
+
wildcard-selection machinery: rest bindings use the container's
|
|
542
|
+
full aggregate (`identitiesAt` of the source over its `path.*`),
|
|
543
|
+
**over-approximating by including sibling-destructured keys rather
|
|
544
|
+
than precisely excluding them** — when `const {a, ...rest} = user`,
|
|
545
|
+
`rest` conservatively carries `user`'s full set, not just
|
|
546
|
+
`{b, c, ...}`. This is a real limitation, explicitly called out so
|
|
547
|
+
it isn't silently assumed to be precise. The tradeoff is worth the
|
|
548
|
+
simplicity, and it's still sound for taint/lineage purposes.
|
|
549
|
+
- Both fixes were confirmed to also resolve the identical blindness
|
|
550
|
+
in `scanner/src/dataflow/`'s shared taint engine, which consumes
|
|
551
|
+
the same parser (no engine changes needed there either).
|
|
552
|
+
- **Array rest's separate, lower-severity, still-unfixed imprecision:**
|
|
553
|
+
Unlike object rest, array rest (`const [first, ...tail] = arr`) is
|
|
554
|
+
NOT a `SpreadElement` that was being filtered out — it's a legitimate
|
|
555
|
+
IR `RestElement` node that already exists in the parsed tree. The
|
|
556
|
+
imprecision here is architectural (index-insensitive flattening, same
|
|
557
|
+
as array literals' own by-design limitation documented above) —
|
|
558
|
+
out of scope for this plan's parser-level fixes, left as a documented
|
|
559
|
+
future refinement if per-index attribution ever becomes feasible.
|
|
560
|
+
- **Member access** (property read): when the whole `object.prop...` chain
|
|
561
|
+
is a pure ident/member chain, resolves via `accessPathOf` + `identitiesAt`
|
|
562
|
+
— no new logic needed, this is what those functions exist for. When the
|
|
563
|
+
base is NOT a pure chain (a ternary, a logical expression, an object
|
|
564
|
+
literal, an assign-expr, a call — anything `accessPathOf` returns `null`
|
|
565
|
+
for), round 4 added a fallback: resolve the base recursively and SELECT
|
|
566
|
+
`prop` out of the base's `byPath` (plus its residual), so reading a field
|
|
567
|
+
directly off one of these constructs gives the same answer as going
|
|
568
|
+
through an intermediate variable first. See the "three hop types" note
|
|
569
|
+
above (the "selection" hop) for the full story.
|
|
570
|
+
- **Template literals and string concatenation (`binary`/`logical` with
|
|
571
|
+
string operators, `tpl` interpolation)**: DO propagate identity normally
|
|
572
|
+
(not flagged as widened/implicit). Rationale: the underlying value is
|
|
573
|
+
genuinely and traceably present in the result — this is different in kind
|
|
574
|
+
from FR-306's "implicit and widened" category, which is about flows whose
|
|
575
|
+
connection to the original field is inferred/approximate (an unresolved
|
|
576
|
+
call, a dynamic property key), not flows where the field's actual value is
|
|
577
|
+
verbatim embedded in a larger string. This is a real design call, stated
|
|
578
|
+
explicitly here so it isn't silently assumed by whichever task implements
|
|
579
|
+
the resolver.
|
|
580
|
+
- **Unresolved function calls** (every call in this plan's scope, since no
|
|
581
|
+
registry/summary integration exists yet): the call's result is treated as
|
|
582
|
+
conservatively carrying the union of its arguments' resolved identities,
|
|
583
|
+
but flagged as a **widening event** (`{atPath, dataElementIds, reason,
|
|
584
|
+
line}`) — a side list the engine returns alongside its main state, never
|
|
585
|
+
silently dropped. This satisfies the project's "never launder identity
|
|
586
|
+
into a clean value" principle (mirrors PRD §18.4's "never translate 'path
|
|
587
|
+
budget exhausted' into 'no path'" spirit) without requiring this plan to
|
|
588
|
+
build the registry integration that would let some calls resolve
|
|
589
|
+
precisely (that's Sub-project D's job).
|
|
590
|
+
- **Destructuring**: resolved (Task 5 read the real IR lowering directly,
|
|
591
|
+
closing the open question this ADR originally left unanswered).
|
|
592
|
+
`const {email, ssn} = user;` lowers to one plain `assign` CFG node per
|
|
593
|
+
bound name — a string `target` (e.g. `'email'`) and a `member`-kind
|
|
594
|
+
`source` (e.g. `{kind: 'member', object: {kind: 'ident', name: 'user'},
|
|
595
|
+
prop: 'email'}`) — see `scanner/src/ir/parser-js.js`'s
|
|
596
|
+
`VariableDeclarator` visitor, the `id.kind === 'object-pattern'` branch,
|
|
597
|
+
around lines 453-463. This is byte-identical to the shape `step()`'s
|
|
598
|
+
`'assign'` case already handles for a plain `member`-sourced assignment,
|
|
599
|
+
so destructuring required zero special-case code in `engine.js` —
|
|
600
|
+
confirmed end to end against the real parser in
|
|
601
|
+
`test/lineage/engine-integration.test.js`.
|
|
602
|
+
- **Ternary/conditional expressions** (`union` kind, both branches kept by
|
|
603
|
+
the parser — never resolved to one) and **logical expressions**
|
|
604
|
+
(`logical` kind, `||`/`&&`/`??`): both/all alternatives' identities are
|
|
605
|
+
unioned PER SUB-PATH — the exact same per-path union `joinStates`
|
|
606
|
+
already performs at the CFG branch-join level, not a coarse flat merge.
|
|
607
|
+
(A previous revision of this bullet claimed this parity while the code
|
|
608
|
+
underneath still flattened `union`/`logical` into one flat blob, entirely
|
|
609
|
+
dropping `byPath` — see round 3's "The structure-preserving vs.
|
|
610
|
+
structure-flattening invariant" note below for the fix and why the false
|
|
611
|
+
claim is believed to be why the gap survived two rounds of review: it read
|
|
612
|
+
as an already-accepted approximation rather than a bug.) `binary`
|
|
613
|
+
(arithmetic/comparison operators) is deliberately NOT part of this
|
|
614
|
+
group — see the invariant below for why.
|
|
615
|
+
- **`unknown`-kind expressions** (JSX, anything the parser doesn't handle):
|
|
616
|
+
resolve to "carries no identity" — fails open, matching the general IR
|
|
617
|
+
contract's own `unknown` CFG-node-kind treatment. Not flagged as a
|
|
618
|
+
coverage gap by this plan (that accounting is AC-11/coverage-ledger
|
|
619
|
+
territory — Sub-project E's job, not this one's).
|
|
620
|
+
|
|
621
|
+
## 5. What this plan explicitly does NOT build (so later sub-projects don't assume it's here)
|
|
622
|
+
|
|
623
|
+
Interprocedural call resolution and summaries (Sub-project B); the path DAG
|
|
624
|
+
(Sub-project C); any connection to the source/sink registry or
|
|
625
|
+
transformation-kind catalog (Sub-project D); any `DataFlowGraph v1` output
|
|
626
|
+
(Sub-project E); any wiring into `runScan`/the CLI. This plan's output is
|
|
627
|
+
consumed directly by tests with hand-supplied entry facts, nothing else,
|
|
628
|
+
by design.
|