feat(taint): interprocedural taint via function summaries over resolved CALLS (#2084) (#2179)

This commit is contained in:
Gergő Magyar 2026-06-13 07:04:14 +01:00 committed by GitHub
parent 0054496323
commit 129bc84c0d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 3785 additions and 28 deletions

View file

@ -0,0 +1,178 @@
---
name: gitnexus-taint-analysis
description: "Use when working on, reviewing, or extending GitNexus's CFG/taint/PDG subsystem (the `--pdg` layers), or when reasoning about source→sink data-flow findings. Examples: \"How does taint analysis work here?\", \"Why didn't explain find this flow?\", \"Add a new sink/source\", \"Review the interprocedural taint code\"."
---
# CFG & Taint Analysis with GitNexus
Expert knowledge for the opt-in `--pdg` program-analysis subsystem: control-flow
graphs, reaching definitions, and intra- + inter-procedural taint. Read this
before touching `gitnexus/src/core/ingestion/cfg/**` or
`gitnexus/src/core/ingestion/taint/**`, or when explaining a finding.
## When to Use
- "How does the taint engine work / why is this flow (not) reported?"
- Adding a source, sink, or sanitizer to the model.
- Extending or reviewing the CFG / reaching-defs / taint / summary code.
- Understanding the `explain` MCP tool's findings (intra- vs inter-procedural).
- Debugging a false positive or false negative in `--pdg` output.
## The layered substrate (build order)
Taint runs **on** the graph, not beside it. Each layer is opt-in behind `--pdg`
and a default `analyze` run is **byte-identical** (the golden parity gate is the
hard floor for every change here).
```
L1 CFG per-function basic blocks + control-flow edges (M1 #2081)
L2 REACHING_DEF GEN/KILL def→use data dependence (pure solver) (M2 #2082)
L3 Taint (intra) source→sink over RD facts, minus sanitizers (M3 #2083)
L4 Taint (inter) per-function summaries composed over CALLS (M4 #2084)
```
- **Worker-built, main-thread-solved.** The parse worker builds each function's
CFG + harvests def/use + call-site facts onto `ParsedFile.cfgSideChannel`
(plain, structured-clone-safe data — never AST nodes). The main thread runs
the pure solvers. NEVER re-parse on the main thread (re-introduces the #1983
OOM).
- **In-phase emit (KTD1).** L1L4-harvest all run INSIDE the scope-resolution
pdg window (`scope-resolution/pipeline/run.ts`, gated `input.pdg === true`),
because the disk-backed ParsedFile store is cleared when that phase ends — a
standalone post-`mro` phase would read empty data. The cross-function fixpoint
(L4) is the exception: it runs in its OWN registered phase (`taintSummaries`)
AFTER scope-resolution, because it needs the COMPLETE call graph, and consumes
small plain summary data threaded out via `ScopeResolutionOutput`.
- **Pure-solver contract.** `computeReachingDefs`, `computeTaintFlows`,
`harvestFunctionSummary`, and `solveInterprocTaint` are pure and deterministic
(no graph, no I/O, no logger; sorted outputs). Snapshot tests and
content-derived edge ids depend on it.
## Intra-procedural taint (L3)
Forward reachability over RD facts from matched **sources** to matched **sinks**,
killed by **sanitizers**. Key design points worth internalizing:
- **Occurrence-tagged sites.** A flat per-arg binding set cannot tell
`exec(escape(x))` (safe) from `exec(x)` (finding); the harvest records nested
call structure (`SiteRecord.parent`/via-tags) so sanitizer interposition is
precise.
- **Kind-set sanitizer model.** A taint carries a set of *neutralized*
`SinkKind`s; a sink fires unless its kind is in the set. So `escape(req.body)`
suppresses `res.send` (xss) but STILL fires `db.query` (sql) — a kind-blind
kill would be a suppressed live injection (the forbidden FN direction).
`path.basename(t)` neutralizes path-traversal only, not command-injection.
- **Statement-level finding identity.** NOT block-pair (block conflation drops
distinct findings; `exec(req.body, req.query)` is two findings).
- Persisted as `TAINTED` edges (BasicBlock→BasicBlock); the path rides the
`reason` column via the shared versioned codec (`taint/path-codec.ts`).
## Interprocedural taint (L4) — the functional/summary method
The production approach (Sharir-Pnueli 1981; the same shape as Meta's Pysa and
Mariana Trench, and FB Infer) — NOT full IFDS tabulation. Each function is
reduced to a compact **summary**, and summaries are composed over the already-
resolved `CALLS` graph.
**Summary shape** (`taint/summary-model.ts`, whole-parameter granularity):
| Edge | Meaning | Analogue |
|------|---------|----------|
| `param→return` | a param flows to the return value | TITO — **reserved** (the floor already covers its recall; precision pass deferred) |
| `param→callee-arg` | a param flows into arg *j* of a call (carries the path's neutralized sink kinds) | TITO into callee |
| `param→sink` | a param reaches a modelled sink | partial/triggered sink |
| `source→return` | the function generates+returns a source | generative — **composed** via the caller's `callResults` |
| `source→callee-arg` | a generated source flows into a call | fixpoint SEED |
| `callResults` | a user-function call's result flows to a sink/return/callee-arg in the caller | composes with callee `source→return` |
**The fixpoint** (`taint/interproc-solver.ts`): the unit is `(function,
parameter, source)`. Seed from `source→callee-arg`, propagate via
`param→callee-arg`, fire a finding when a tainted param meets `param→sink`.
- **Cycle-safe by monotonicity.** The tainted-set is monotone over a finite
lattice (`fn × param × source`), so the worklist converges — a recursive call
just re-proposes an already-visited entry. SCC condensation would only refine
processing order; correctness/termination don't require it.
- **Source-discriminated state (load-bearing).** Key the state by the SOURCE
too. Keying only by `(fn, param)` collapses multi-source flows: a sink param
tainted by source A is marked visited and a later flow from source B is dropped
before firing — the recurring multi-source bug class. (Bit M3; bit M4 U9.)
- **Name-based call join.** Match a summary's call-arg edge to a `CALLS` edge by
CALLEE NAME, not call-site line — line-base parity (CFG 1-based vs reference
site) is fragile; the callee identity is exact and context-insensitivity
taints the callee's param identically at every call site.
- Persisted as `TAINT_PATH` edges (Function→Function), function-level hop chain
in `reason` via the same codec; confidence < the intra-procedural 1.0.
**Context-insensitivity** is the accepted trade-off at this tier: one summary
per function, return/call-site merging accepted (security-conservative). Expect
some FP from merging; the bigger FN sources are unmodeled features (below).
## Known false-negative classes (documented, deferred)
The largest is **closures/callbacks** (`arr.forEach(() => sink(y))`) — taint
into a callback is dropped without per-library models (true of CodeQL's JS libs
too). Also deferred: field/property flows (`obj.x = taint; sink(obj.y)`),
field-sensitive access paths, guard-style sanitizers, implicit/control-dependence
flows, promise/async-await threading, and **destructured/rest params before a
tainted simple param** (the summary port index is the binding ordinal, not the
formal arg position — needs a formal-param index threaded from the worker
`BindingEntry`). The interprocedural join is also context-insensitive: when one
caller invokes two distinct **same-named callees**, a flow into one
over-attributes to both (sound — over-report, never a missed flow). Absence of a
finding is NOT proof of safety.
## GitNexus-specific gotchas
- **Function↔CFG join.** `FunctionCfg.functionStartLine` is 1-based; `Function`/
`Method` node `startLine` is 0-based — join at `startLine - 1`. Function nodes
have no column, so same-line functions (`{a:()=>x(), b:()=>y()}`) are
ambiguous → drop (the summary driver counts `unresolved`) rather than
cross-wire.
- **No rel-property index (S1).** Kuzu has no secondary index on relationship
properties, and unanchored `[:TAINTED*]`/`[:TAINT_PATH*]` queries explode.
TAINT_PATH is therefore MATERIALIZED + anchored at analyze time, never
traversed live; `explain` reads it source-anchored + LIMIT-guarded.
- **`explain` is the only discovery surface.** `TAINTED`/`TAINT_PATH` are
deliberately OUT of `VALID_RELATION_TYPES` (impact's allow-list) and the web
schema (pinned in `security.test.ts`). `explain` enumerates both layers
(cross-function findings carry `interprocedural: true`).
- **One shared codec.** Both the emit path and `explain` import
`taint/path-codec.ts`. Two hand-rolled copies of a wire format drift — never
fork it. New metadata extends the format WITHIN the version when writer +
reader ship together.
- **Cache versioning.** A worker-harvest shape change bumps the parse-cache pdg
NAMESPACE (`pdg:N`), NOT `SCHEMA_BUMP` (which cold-invalidates every user).
Persisted-graph/config changes ride `RepoMeta.pdg`'s key-union mismatch →
full writeback. Model content rides `taintModelVersion`.
## Adding a source / sink / sanitizer
Edit the language model in `taint/typescript-model.ts` (registered via the
explicit `registerBuiltinTaintModels` seam, keyed by `SupportedLanguages`). The
spec is hashable data (no functions). A sanitizer's `neutralizes` lists the
EXACT sink kinds it defends — never a blanket kill. Add a fixture + assert the
finding (or its absence) in `test/unit/taint/` (real-source harness:
`test/helpers/ts-cfg-harness.ts`); the end-to-end proof is
`test/integration/cfg/`.
## Validation checklist for any `--pdg` change
```
1. tsc clean (schema additions are exhaustiveness-checked; watch the
api.ts getNodeQuery runtime read-path if a node label is added).
2. Targeted vitest by directory (test/unit/taint, test/unit/cfg,
test/integration/cfg) — verify by ISOLATION, not full-suite exit
(known load-flakes). `node scripts/build.js` before worker/integration runs.
3. Flag-off golden byte-identical (pipeline-graph-golden.test.ts).
4. bench/cfg/measure.mjs --check (no fingerprint drift / budget regression).
5. detect_changes() before commit; impact({direction:'upstream'}) before
editing shared symbols (KnowledgeGraph, RepoMeta, RelationshipType, codec).
```
## Prior art (for deeper design questions)
Sharir & Pnueli 1981 (functional approach); Reps-Horwitz-Sagiv IFDS (POPL 1995);
FlowDroid/StubDroid (access-path summaries); Pysa & Mariana Trench (TITO /
propagations, parallel SCC fixpoint); CodeQL Models-as-Data (the richest port
notation, incl. callback ports); Infer (content-keyed incremental summaries).

View file

@ -0,0 +1,178 @@
---
name: gitnexus-taint-analysis
description: "Use when working on, reviewing, or extending GitNexus's CFG/taint/PDG subsystem (the `--pdg` layers), or when reasoning about source→sink data-flow findings. Examples: \"How does taint analysis work here?\", \"Why didn't explain find this flow?\", \"Add a new sink/source\", \"Review the interprocedural taint code\"."
---
# CFG & Taint Analysis with GitNexus
Expert knowledge for the opt-in `--pdg` program-analysis subsystem: control-flow
graphs, reaching definitions, and intra- + inter-procedural taint. Read this
before touching `gitnexus/src/core/ingestion/cfg/**` or
`gitnexus/src/core/ingestion/taint/**`, or when explaining a finding.
## When to Use
- "How does the taint engine work / why is this flow (not) reported?"
- Adding a source, sink, or sanitizer to the model.
- Extending or reviewing the CFG / reaching-defs / taint / summary code.
- Understanding the `explain` MCP tool's findings (intra- vs inter-procedural).
- Debugging a false positive or false negative in `--pdg` output.
## The layered substrate (build order)
Taint runs **on** the graph, not beside it. Each layer is opt-in behind `--pdg`
and a default `analyze` run is **byte-identical** (the golden parity gate is the
hard floor for every change here).
```
L1 CFG per-function basic blocks + control-flow edges (M1 #2081)
L2 REACHING_DEF GEN/KILL def→use data dependence (pure solver) (M2 #2082)
L3 Taint (intra) source→sink over RD facts, minus sanitizers (M3 #2083)
L4 Taint (inter) per-function summaries composed over CALLS (M4 #2084)
```
- **Worker-built, main-thread-solved.** The parse worker builds each function's
CFG + harvests def/use + call-site facts onto `ParsedFile.cfgSideChannel`
(plain, structured-clone-safe data — never AST nodes). The main thread runs
the pure solvers. NEVER re-parse on the main thread (re-introduces the #1983
OOM).
- **In-phase emit (KTD1).** L1L4-harvest all run INSIDE the scope-resolution
pdg window (`scope-resolution/pipeline/run.ts`, gated `input.pdg === true`),
because the disk-backed ParsedFile store is cleared when that phase ends — a
standalone post-`mro` phase would read empty data. The cross-function fixpoint
(L4) is the exception: it runs in its OWN registered phase (`taintSummaries`)
AFTER scope-resolution, because it needs the COMPLETE call graph, and consumes
small plain summary data threaded out via `ScopeResolutionOutput`.
- **Pure-solver contract.** `computeReachingDefs`, `computeTaintFlows`,
`harvestFunctionSummary`, and `solveInterprocTaint` are pure and deterministic
(no graph, no I/O, no logger; sorted outputs). Snapshot tests and
content-derived edge ids depend on it.
## Intra-procedural taint (L3)
Forward reachability over RD facts from matched **sources** to matched **sinks**,
killed by **sanitizers**. Key design points worth internalizing:
- **Occurrence-tagged sites.** A flat per-arg binding set cannot tell
`exec(escape(x))` (safe) from `exec(x)` (finding); the harvest records nested
call structure (`SiteRecord.parent`/via-tags) so sanitizer interposition is
precise.
- **Kind-set sanitizer model.** A taint carries a set of *neutralized*
`SinkKind`s; a sink fires unless its kind is in the set. So `escape(req.body)`
suppresses `res.send` (xss) but STILL fires `db.query` (sql) — a kind-blind
kill would be a suppressed live injection (the forbidden FN direction).
`path.basename(t)` neutralizes path-traversal only, not command-injection.
- **Statement-level finding identity.** NOT block-pair (block conflation drops
distinct findings; `exec(req.body, req.query)` is two findings).
- Persisted as `TAINTED` edges (BasicBlock→BasicBlock); the path rides the
`reason` column via the shared versioned codec (`taint/path-codec.ts`).
## Interprocedural taint (L4) — the functional/summary method
The production approach (Sharir-Pnueli 1981; the same shape as Meta's Pysa and
Mariana Trench, and FB Infer) — NOT full IFDS tabulation. Each function is
reduced to a compact **summary**, and summaries are composed over the already-
resolved `CALLS` graph.
**Summary shape** (`taint/summary-model.ts`, whole-parameter granularity):
| Edge | Meaning | Analogue |
|------|---------|----------|
| `param→return` | a param flows to the return value | TITO — **reserved** (the floor already covers its recall; precision pass deferred) |
| `param→callee-arg` | a param flows into arg *j* of a call (carries the path's neutralized sink kinds) | TITO into callee |
| `param→sink` | a param reaches a modelled sink | partial/triggered sink |
| `source→return` | the function generates+returns a source | generative — **composed** via the caller's `callResults` |
| `source→callee-arg` | a generated source flows into a call | fixpoint SEED |
| `callResults` | a user-function call's result flows to a sink/return/callee-arg in the caller | composes with callee `source→return` |
**The fixpoint** (`taint/interproc-solver.ts`): the unit is `(function,
parameter, source)`. Seed from `source→callee-arg`, propagate via
`param→callee-arg`, fire a finding when a tainted param meets `param→sink`.
- **Cycle-safe by monotonicity.** The tainted-set is monotone over a finite
lattice (`fn × param × source`), so the worklist converges — a recursive call
just re-proposes an already-visited entry. SCC condensation would only refine
processing order; correctness/termination don't require it.
- **Source-discriminated state (load-bearing).** Key the state by the SOURCE
too. Keying only by `(fn, param)` collapses multi-source flows: a sink param
tainted by source A is marked visited and a later flow from source B is dropped
before firing — the recurring multi-source bug class. (Bit M3; bit M4 U9.)
- **Name-based call join.** Match a summary's call-arg edge to a `CALLS` edge by
CALLEE NAME, not call-site line — line-base parity (CFG 1-based vs reference
site) is fragile; the callee identity is exact and context-insensitivity
taints the callee's param identically at every call site.
- Persisted as `TAINT_PATH` edges (Function→Function), function-level hop chain
in `reason` via the same codec; confidence < the intra-procedural 1.0.
**Context-insensitivity** is the accepted trade-off at this tier: one summary
per function, return/call-site merging accepted (security-conservative). Expect
some FP from merging; the bigger FN sources are unmodeled features (below).
## Known false-negative classes (documented, deferred)
The largest is **closures/callbacks** (`arr.forEach(() => sink(y))`) — taint
into a callback is dropped without per-library models (true of CodeQL's JS libs
too). Also deferred: field/property flows (`obj.x = taint; sink(obj.y)`),
field-sensitive access paths, guard-style sanitizers, implicit/control-dependence
flows, promise/async-await threading, and **destructured/rest params before a
tainted simple param** (the summary port index is the binding ordinal, not the
formal arg position — needs a formal-param index threaded from the worker
`BindingEntry`). The interprocedural join is also context-insensitive: when one
caller invokes two distinct **same-named callees**, a flow into one
over-attributes to both (sound — over-report, never a missed flow). Absence of a
finding is NOT proof of safety.
## GitNexus-specific gotchas
- **Function↔CFG join.** `FunctionCfg.functionStartLine` is 1-based; `Function`/
`Method` node `startLine` is 0-based — join at `startLine - 1`. Function nodes
have no column, so same-line functions (`{a:()=>x(), b:()=>y()}`) are
ambiguous → drop (the summary driver counts `unresolved`) rather than
cross-wire.
- **No rel-property index (S1).** Kuzu has no secondary index on relationship
properties, and unanchored `[:TAINTED*]`/`[:TAINT_PATH*]` queries explode.
TAINT_PATH is therefore MATERIALIZED + anchored at analyze time, never
traversed live; `explain` reads it source-anchored + LIMIT-guarded.
- **`explain` is the only discovery surface.** `TAINTED`/`TAINT_PATH` are
deliberately OUT of `VALID_RELATION_TYPES` (impact's allow-list) and the web
schema (pinned in `security.test.ts`). `explain` enumerates both layers
(cross-function findings carry `interprocedural: true`).
- **One shared codec.** Both the emit path and `explain` import
`taint/path-codec.ts`. Two hand-rolled copies of a wire format drift — never
fork it. New metadata extends the format WITHIN the version when writer +
reader ship together.
- **Cache versioning.** A worker-harvest shape change bumps the parse-cache pdg
NAMESPACE (`pdg:N`), NOT `SCHEMA_BUMP` (which cold-invalidates every user).
Persisted-graph/config changes ride `RepoMeta.pdg`'s key-union mismatch →
full writeback. Model content rides `taintModelVersion`.
## Adding a source / sink / sanitizer
Edit the language model in `taint/typescript-model.ts` (registered via the
explicit `registerBuiltinTaintModels` seam, keyed by `SupportedLanguages`). The
spec is hashable data (no functions). A sanitizer's `neutralizes` lists the
EXACT sink kinds it defends — never a blanket kill. Add a fixture + assert the
finding (or its absence) in `test/unit/taint/` (real-source harness:
`test/helpers/ts-cfg-harness.ts`); the end-to-end proof is
`test/integration/cfg/`.
## Validation checklist for any `--pdg` change
```
1. tsc clean (schema additions are exhaustiveness-checked; watch the
api.ts getNodeQuery runtime read-path if a node label is added).
2. Targeted vitest by directory (test/unit/taint, test/unit/cfg,
test/integration/cfg) — verify by ISOLATION, not full-suite exit
(known load-flakes). `node scripts/build.js` before worker/integration runs.
3. Flag-off golden byte-identical (pipeline-graph-golden.test.ts).
4. bench/cfg/measure.mjs --check (no fingerprint drift / budget regression).
5. detect_changes() before commit; impact({direction:'upstream'}) before
editing shared symbols (KnowledgeGraph, RepoMeta, RelationshipType, codec).
```
## Prior art (for deeper design questions)
Sharir & Pnueli 1981 (functional approach); Reps-Horwitz-Sagiv IFDS (POPL 1995);
FlowDroid/StubDroid (access-path summaries); Pysa & Mariana Trench (TITO /
propagations, parallel SCC fixpoint); CodeQL Models-as-Data (the richest port
notation, incl. callback ports); Infer (content-keyed incremental summaries).

View file

@ -0,0 +1,178 @@
---
name: gitnexus-taint-analysis
description: "Use when working on, reviewing, or extending GitNexus's CFG/taint/PDG subsystem (the `--pdg` layers), or when reasoning about source→sink data-flow findings. Examples: \"How does taint analysis work here?\", \"Why didn't explain find this flow?\", \"Add a new sink/source\", \"Review the interprocedural taint code\"."
---
# CFG & Taint Analysis with GitNexus
Expert knowledge for the opt-in `--pdg` program-analysis subsystem: control-flow
graphs, reaching definitions, and intra- + inter-procedural taint. Read this
before touching `gitnexus/src/core/ingestion/cfg/**` or
`gitnexus/src/core/ingestion/taint/**`, or when explaining a finding.
## When to Use
- "How does the taint engine work / why is this flow (not) reported?"
- Adding a source, sink, or sanitizer to the model.
- Extending or reviewing the CFG / reaching-defs / taint / summary code.
- Understanding the `explain` MCP tool's findings (intra- vs inter-procedural).
- Debugging a false positive or false negative in `--pdg` output.
## The layered substrate (build order)
Taint runs **on** the graph, not beside it. Each layer is opt-in behind `--pdg`
and a default `analyze` run is **byte-identical** (the golden parity gate is the
hard floor for every change here).
```
L1 CFG per-function basic blocks + control-flow edges (M1 #2081)
L2 REACHING_DEF GEN/KILL def→use data dependence (pure solver) (M2 #2082)
L3 Taint (intra) source→sink over RD facts, minus sanitizers (M3 #2083)
L4 Taint (inter) per-function summaries composed over CALLS (M4 #2084)
```
- **Worker-built, main-thread-solved.** The parse worker builds each function's
CFG + harvests def/use + call-site facts onto `ParsedFile.cfgSideChannel`
(plain, structured-clone-safe data — never AST nodes). The main thread runs
the pure solvers. NEVER re-parse on the main thread (re-introduces the #1983
OOM).
- **In-phase emit (KTD1).** L1L4-harvest all run INSIDE the scope-resolution
pdg window (`scope-resolution/pipeline/run.ts`, gated `input.pdg === true`),
because the disk-backed ParsedFile store is cleared when that phase ends — a
standalone post-`mro` phase would read empty data. The cross-function fixpoint
(L4) is the exception: it runs in its OWN registered phase (`taintSummaries`)
AFTER scope-resolution, because it needs the COMPLETE call graph, and consumes
small plain summary data threaded out via `ScopeResolutionOutput`.
- **Pure-solver contract.** `computeReachingDefs`, `computeTaintFlows`,
`harvestFunctionSummary`, and `solveInterprocTaint` are pure and deterministic
(no graph, no I/O, no logger; sorted outputs). Snapshot tests and
content-derived edge ids depend on it.
## Intra-procedural taint (L3)
Forward reachability over RD facts from matched **sources** to matched **sinks**,
killed by **sanitizers**. Key design points worth internalizing:
- **Occurrence-tagged sites.** A flat per-arg binding set cannot tell
`exec(escape(x))` (safe) from `exec(x)` (finding); the harvest records nested
call structure (`SiteRecord.parent`/via-tags) so sanitizer interposition is
precise.
- **Kind-set sanitizer model.** A taint carries a set of *neutralized*
`SinkKind`s; a sink fires unless its kind is in the set. So `escape(req.body)`
suppresses `res.send` (xss) but STILL fires `db.query` (sql) — a kind-blind
kill would be a suppressed live injection (the forbidden FN direction).
`path.basename(t)` neutralizes path-traversal only, not command-injection.
- **Statement-level finding identity.** NOT block-pair (block conflation drops
distinct findings; `exec(req.body, req.query)` is two findings).
- Persisted as `TAINTED` edges (BasicBlock→BasicBlock); the path rides the
`reason` column via the shared versioned codec (`taint/path-codec.ts`).
## Interprocedural taint (L4) — the functional/summary method
The production approach (Sharir-Pnueli 1981; the same shape as Meta's Pysa and
Mariana Trench, and FB Infer) — NOT full IFDS tabulation. Each function is
reduced to a compact **summary**, and summaries are composed over the already-
resolved `CALLS` graph.
**Summary shape** (`taint/summary-model.ts`, whole-parameter granularity):
| Edge | Meaning | Analogue |
|------|---------|----------|
| `param→return` | a param flows to the return value | TITO — **reserved** (the floor already covers its recall; precision pass deferred) |
| `param→callee-arg` | a param flows into arg *j* of a call (carries the path's neutralized sink kinds) | TITO into callee |
| `param→sink` | a param reaches a modelled sink | partial/triggered sink |
| `source→return` | the function generates+returns a source | generative — **composed** via the caller's `callResults` |
| `source→callee-arg` | a generated source flows into a call | fixpoint SEED |
| `callResults` | a user-function call's result flows to a sink/return/callee-arg in the caller | composes with callee `source→return` |
**The fixpoint** (`taint/interproc-solver.ts`): the unit is `(function,
parameter, source)`. Seed from `source→callee-arg`, propagate via
`param→callee-arg`, fire a finding when a tainted param meets `param→sink`.
- **Cycle-safe by monotonicity.** The tainted-set is monotone over a finite
lattice (`fn × param × source`), so the worklist converges — a recursive call
just re-proposes an already-visited entry. SCC condensation would only refine
processing order; correctness/termination don't require it.
- **Source-discriminated state (load-bearing).** Key the state by the SOURCE
too. Keying only by `(fn, param)` collapses multi-source flows: a sink param
tainted by source A is marked visited and a later flow from source B is dropped
before firing — the recurring multi-source bug class. (Bit M3; bit M4 U9.)
- **Name-based call join.** Match a summary's call-arg edge to a `CALLS` edge by
CALLEE NAME, not call-site line — line-base parity (CFG 1-based vs reference
site) is fragile; the callee identity is exact and context-insensitivity
taints the callee's param identically at every call site.
- Persisted as `TAINT_PATH` edges (Function→Function), function-level hop chain
in `reason` via the same codec; confidence < the intra-procedural 1.0.
**Context-insensitivity** is the accepted trade-off at this tier: one summary
per function, return/call-site merging accepted (security-conservative). Expect
some FP from merging; the bigger FN sources are unmodeled features (below).
## Known false-negative classes (documented, deferred)
The largest is **closures/callbacks** (`arr.forEach(() => sink(y))`) — taint
into a callback is dropped without per-library models (true of CodeQL's JS libs
too). Also deferred: field/property flows (`obj.x = taint; sink(obj.y)`),
field-sensitive access paths, guard-style sanitizers, implicit/control-dependence
flows, promise/async-await threading, and **destructured/rest params before a
tainted simple param** (the summary port index is the binding ordinal, not the
formal arg position — needs a formal-param index threaded from the worker
`BindingEntry`). The interprocedural join is also context-insensitive: when one
caller invokes two distinct **same-named callees**, a flow into one
over-attributes to both (sound — over-report, never a missed flow). Absence of a
finding is NOT proof of safety.
## GitNexus-specific gotchas
- **Function↔CFG join.** `FunctionCfg.functionStartLine` is 1-based; `Function`/
`Method` node `startLine` is 0-based — join at `startLine - 1`. Function nodes
have no column, so same-line functions (`{a:()=>x(), b:()=>y()}`) are
ambiguous → drop (the summary driver counts `unresolved`) rather than
cross-wire.
- **No rel-property index (S1).** Kuzu has no secondary index on relationship
properties, and unanchored `[:TAINTED*]`/`[:TAINT_PATH*]` queries explode.
TAINT_PATH is therefore MATERIALIZED + anchored at analyze time, never
traversed live; `explain` reads it source-anchored + LIMIT-guarded.
- **`explain` is the only discovery surface.** `TAINTED`/`TAINT_PATH` are
deliberately OUT of `VALID_RELATION_TYPES` (impact's allow-list) and the web
schema (pinned in `security.test.ts`). `explain` enumerates both layers
(cross-function findings carry `interprocedural: true`).
- **One shared codec.** Both the emit path and `explain` import
`taint/path-codec.ts`. Two hand-rolled copies of a wire format drift — never
fork it. New metadata extends the format WITHIN the version when writer +
reader ship together.
- **Cache versioning.** A worker-harvest shape change bumps the parse-cache pdg
NAMESPACE (`pdg:N`), NOT `SCHEMA_BUMP` (which cold-invalidates every user).
Persisted-graph/config changes ride `RepoMeta.pdg`'s key-union mismatch →
full writeback. Model content rides `taintModelVersion`.
## Adding a source / sink / sanitizer
Edit the language model in `taint/typescript-model.ts` (registered via the
explicit `registerBuiltinTaintModels` seam, keyed by `SupportedLanguages`). The
spec is hashable data (no functions). A sanitizer's `neutralizes` lists the
EXACT sink kinds it defends — never a blanket kill. Add a fixture + assert the
finding (or its absence) in `test/unit/taint/` (real-source harness:
`test/helpers/ts-cfg-harness.ts`); the end-to-end proof is
`test/integration/cfg/`.
## Validation checklist for any `--pdg` change
```
1. tsc clean (schema additions are exhaustiveness-checked; watch the
api.ts getNodeQuery runtime read-path if a node label is added).
2. Targeted vitest by directory (test/unit/taint, test/unit/cfg,
test/integration/cfg) — verify by ISOLATION, not full-suite exit
(known load-flakes). `node scripts/build.js` before worker/integration runs.
3. Flag-off golden byte-identical (pipeline-graph-golden.test.ts).
4. bench/cfg/measure.mjs --check (no fingerprint drift / budget regression).
5. detect_changes() before commit; impact({direction:'upstream'}) before
editing shared symbols (KnowledgeGraph, RepoMeta, RelationshipType, codec).
```
## Prior art (for deeper design questions)
Sharir & Pnueli 1981 (functional approach); Reps-Horwitz-Sagiv IFDS (POPL 1995);
FlowDroid/StubDroid (access-path summaries); Pysa & Mariana Trench (TITO /
propagations, parallel SCC fixpoint); CodeQL Models-as-Data (the richest port
notation, incl. callback ports); Infer (content-keyed incremental summaries).

View file

@ -54,6 +54,16 @@ import type { KnowledgeGraph } from '../graph/types.js';
const isGraphWide = (label: string): boolean => label === 'Community' || label === 'Process';
/**
* Relationship types whose VALIDITY is a whole-program property, not a
* function of their endpoints' files (#2084 M4 U6). `TAINT_PATH` (cross-
* function taint) can be invalidated by a change to an INTERMEDIATE function
* on a third file, so the endpoint-writability rule below would skip a stale
* AC edge. These are always extracted (and the orchestrator delete-alls them
* first, like Community/Process) so they rebuild from the fresh graph.
*/
const isGraphWideRelType = (type: string): boolean => type === 'TAINT_PATH';
/**
* Build a Map<nodeId, filePath> for every File-bound node in the graph.
* Graph-wide nodes (Community/Process) have no filePath and are filtered.
@ -84,7 +94,11 @@ export const extractChangedSubgraph = (
});
fullGraph.forEachRelationship((r: GraphRelationship) => {
if (writableNodeIds.has(r.sourceId) || writableNodeIds.has(r.targetId)) {
if (
writableNodeIds.has(r.sourceId) ||
writableNodeIds.has(r.targetId) ||
isGraphWideRelType(r.type)
) {
sub.addRelationship(r);
}
});

View file

@ -21,6 +21,7 @@ export {
type ScopeResolutionOutput,
} from '../scope-resolution/pipeline/phase.js';
export { pruneLocalSymbolsPhase, type PruneLocalSymbolsOutput } from './prune-local-symbols.js';
export { taintSummariesPhase, type TaintSummariesOutput } from './taint-summaries.js';
export { mroPhase, type MROOutput } from './mro.js';
export { communitiesPhase, type CommunitiesOutput } from './communities.js';
export { processesPhase, type ProcessesOutput } from './processes.js';

View file

@ -0,0 +1,119 @@
/**
* Phase: taintSummaries (#2084 M4 U3/U5)
*
* The interprocedural taint fixpoint. Runs AFTER scope-resolution (where the
* complete, resolved `CALLS` graph lives in `ctx.graph` and the per-function
* summaries were harvested in-phase) and composes those summaries to find
* sourcesink flows that cross function and file boundaries.
*
* Opt-in: registered with `enabledWhen: (o) => o.pdg === true` (the first real
* pdg-gated phase). A default `analyze` run never includes it, so the graph is
* byte-identical. No always-on phase depends on it (a filtered-out dep would
* throw in `getPhaseOutput`).
*
* @deps scopeResolution, pruneLocalSymbols
* @reads graph (CALLS edges, Function/Method nodes), scopeResolution output
* (functionSummaries)
* @writes graph (TAINT_PATH edges)
*/
import type { PipelinePhase, PipelineContext, PhaseResult } from './types.js';
import { getPhaseOutput } from './types.js';
import type { ScopeResolutionOutput } from '../scope-resolution/pipeline/phase.js';
import {
solveInterprocTaint,
DEFAULT_MAX_INTERPROC_HOPS,
DEFAULT_PDG_MAX_INTERPROC_FINDINGS,
type InterprocCallEdge,
} from '../taint/interproc-solver.js';
import { emitInterprocTaint, DEFAULT_PDG_MAX_INTERPROC_EDGES } from '../taint/interproc-emit.js';
import type { FunctionSummary } from '../taint/summary-model.js';
import { logger } from '../../logger.js';
export interface TaintSummariesOutput {
/** Function summaries fed to the fixpoint. */
summaries: number;
/** Cross-function findings (pre-cap). */
findings: number;
/** TAINT_PATH edges persisted. */
edgesEmitted: number;
/** Call sites whose callee did not resolve to a summary edge (diagnostics). */
unmatchedCallSites: number;
}
const EMPTY: TaintSummariesOutput = {
summaries: 0,
findings: 0,
edgesEmitted: 0,
unmatchedCallSites: 0,
};
export const taintSummariesPhase: PipelinePhase<TaintSummariesOutput> = {
name: 'taintSummaries',
deps: ['scopeResolution', 'pruneLocalSymbols'],
async execute(
ctx: PipelineContext,
deps: ReadonlyMap<string, PhaseResult<unknown>>,
): Promise<TaintSummariesOutput> {
const scope = getPhaseOutput<ScopeResolutionOutput>(deps, 'scopeResolution');
const summaries = scope.functionSummaries;
if (summaries.length === 0) return EMPTY;
// Index summaries by function node id.
const summaryMap = new Map<string, FunctionSummary>(summaries.map((s) => [s.fnId, s]));
// Build the call-edge adjacency from resolved CALLS edges. The join to a
// summary's call-arg edge is by CALLEE NAME (base-independent — see the
// solver doc); recover it from the callee node's `name` property.
const callEdges: InterprocCallEdge[] = [];
for (const rel of ctx.graph.iterRelationshipsByType('CALLS')) {
const callee = ctx.graph.getNode(rel.targetId);
const calleeName =
callee && typeof callee.properties.name === 'string' ? callee.properties.name : undefined;
if (calleeName === undefined) continue;
callEdges.push({ callerId: rel.sourceId, calleeId: rel.targetId, calleeName });
}
// Arm the per-run caps (#2084 review P1-3) — every other pdg layer bounds
// its output via RepoMeta.pdg; without this the fixpoint state + TAINT_PATH
// edges grow unbounded on a fan-in-heavy repo (OOM). `0` ⇒ unlimited
// (preserved like the other pdg caps). The solver/emit already implement
// deterministic truncate-and-warn — this just hands them the budgets.
const maxFindings = ctx.options?.pdgMaxInterprocFindings ?? DEFAULT_PDG_MAX_INTERPROC_FINDINGS;
const maxHops = ctx.options?.pdgMaxInterprocHops ?? DEFAULT_MAX_INTERPROC_HOPS;
const maxEdges = ctx.options?.pdgMaxInterprocEdges ?? DEFAULT_PDG_MAX_INTERPROC_EDGES;
const solved = solveInterprocTaint(summaryMap, callEdges, { maxFindings, maxHops });
const emit = emitInterprocTaint(ctx.graph, solved.findings, { maxEdges }, (m) =>
logger.warn(m),
);
// Surface drops UNCONDITIONALLY (R4 — never silently truncate the layer).
if (solved.droppedFindings > 0 || emit.edgesDropped > 0) {
logger.warn(
`[taint-interproc] capped: ${solved.droppedFindings} finding(s) dropped by the ` +
`per-run findings cap (${maxFindings}), ${emit.edgesDropped} edge(s) by the edge cap ` +
`(${maxEdges}) — raise pdgMaxInterprocFindings/pdgMaxInterprocEdges if intentional`,
);
}
if (solved.findings.length > 0 || emit.edgesEmitted > 0) {
logger.debug(
`[taint-interproc] ${summaries.length} summaries, ${callEdges.length} CALLS edges → ` +
`${solved.findings.length} cross-function finding(s), ${emit.edgesEmitted} TAINT_PATH edge(s)` +
(emit.hopsTruncated > 0 ? `, ${emit.hopsTruncated} with truncated paths` : '') +
(solved.unmatchedCallSites > 0
? `, ${solved.unmatchedCallSites} unmatched call site(s)`
: ''),
);
}
return {
summaries: summaries.length,
findings: solved.findings.length,
edgesEmitted: emit.edgesEmitted,
unmatchedCallSites: solved.unmatchedCallSites,
};
},
};

View file

@ -32,6 +32,7 @@ import {
crossFilePhase,
scopeResolutionPhase,
pruneLocalSymbolsPhase,
taintSummariesPhase,
mroPhase,
communitiesPhase,
processesPhase,
@ -98,6 +99,19 @@ export interface PipelineOptions {
* no-CLI-flag discipline as `pdgMaxTaintFindingsPerFunction`.
*/
pdgMaxTaintHops?: number;
/**
* Per-run cross-function findings cap (#2084 M4 review P1-3). `undefined`
* `DEFAULT_PDG_MAX_INTERPROC_FINDINGS` (2000); `0` no cap. Consumed by the
* `taintSummaries` phase; RepoMeta-stamped, no CLI flag (KTD8) same
* discipline as the per-function taint caps.
*/
pdgMaxInterprocFindings?: number;
/** Per-finding cross-function hop cap (#2084 review P1-3). `undefined`
* `DEFAULT_MAX_INTERPROC_HOPS` (32); `0` no cap. */
pdgMaxInterprocHops?: number;
/** Per-run `TAINT_PATH` edge cap (#2084 review P1-3). `undefined`
* `DEFAULT_PDG_MAX_INTERPROC_EDGES` (1000); `0` no cap. */
pdgMaxInterprocEdges?: number;
/**
* Request parsing with the worker pool disabled. The sequential parser was
* removed the worker pool is the sole parse path so setting this now
@ -223,6 +237,10 @@ export function buildPhaseList(options?: PipelineOptions): PipelinePhase[] {
.register(crossFilePhase)
.register(scopeResolutionPhase)
.register(pruneLocalSymbolsPhase)
// M4 (#2084): interprocedural taint fixpoint — the first real opt-in
// pdg-gated phase. Off ⇒ absent ⇒ byte-identical graph. No always-on
// phase depends on it (a filtered-out dep would throw in getPhaseOutput).
.register(taintSummariesPhase, { enabledWhen: (o) => o.pdg === true })
.register(mroPhase, { enabledWhen: (o) => !o.skipGraphPhases })
.register(communitiesPhase, { enabledWhen: (o) => !o.skipGraphPhases })
.register(processesPhase, { enabledWhen: (o) => !o.skipGraphPhases })

View file

@ -42,6 +42,8 @@ import {
forceGc,
} from '../../../../storage/parsedfile-store.js';
import type { ResolutionOutcome } from '../resolution-outcome.js';
import type { FunctionSummary } from '../../taint/summary-model.js';
import { buildFunctionNodeIndex } from '../../taint/summary-harvest-driver.js';
import { logger } from '../../../logger.js';
export interface ScopeResolutionOutput {
@ -64,6 +66,12 @@ export interface ScopeResolutionOutput {
readonly referenceEdgesEmitted: number;
}
>;
/**
* Per-function taint summaries harvested in the pdg window (#2084 M4 U1),
* across all languages. Empty unless `--pdg` and a registered taint model.
* The `taintSummaries` phase composes these over the `CALLS` graph.
*/
readonly functionSummaries: readonly FunctionSummary[];
}
const NOOP_OUTPUT: ScopeResolutionOutput = Object.freeze({
@ -73,6 +81,7 @@ const NOOP_OUTPUT: ScopeResolutionOutput = Object.freeze({
referenceEdgesEmitted: 0,
resolutionOutcomes: [],
perLanguage: new Map(),
functionSummaries: [],
});
export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
@ -143,6 +152,9 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
let totalRefs = 0;
let anyRan = false;
const resolutionOutcomes: ResolutionOutcome[] = [];
// M4 (#2084 U1): per-function taint summaries accumulated across every
// language pass; the cross-function fixpoint phase reads this output.
const functionSummaries: FunctionSummary[] = [];
const perLanguage = new Map<
SupportedLanguages,
{
@ -221,6 +233,14 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
);
const sharedNodeLookup = totalScopeFiles > 0 ? buildGraphNodeLookup(ctx.graph) : undefined;
logHeapProbe('scope-setup-nodeLookup-end', `langs=${totalScopeLangs}`);
// M4 (#2084 review P2-6): build the functionish-node index ONCE for the
// taint summary harvest, shared across every language pass (it is a whole-
// graph scan and language-agnostic). Only when pdg is on — off ⇒ undefined,
// no scan, byte-identical.
const sharedFnNodeIndex =
ctx.options?.pdg === true && totalScopeFiles > 0
? buildFunctionNodeIndex(ctx.graph)
: undefined;
for (const [lang, provider] of SCOPE_RESOLVERS) {
// Standalone providers (COBOL, JCL) don't emit graph edges yet
@ -348,6 +368,7 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
files,
resolutionConfig,
prebuiltNodeLookup: sharedNodeLookup,
prebuiltFunctionNodeIndex: sharedFnNodeIndex,
preExtractedParsedFiles: preExtractedByPath,
scopeIndexStorePath: parsedFileStorePath,
// CFG/PDG emission (#2081 M1) — opt-in; off ⇒ byte-identical graph.
@ -434,6 +455,7 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
processedScopeFiles += langFileCount;
anyRan = true;
functionSummaries.push(...stats.functionSummaries);
totalFiles += stats.filesProcessed;
totalImports += stats.importsEmitted;
totalRefs += stats.referenceEdgesEmitted;
@ -480,6 +502,7 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
referenceEdgesEmitted: totalRefs,
resolutionOutcomes,
perLanguage,
functionSummaries,
};
},
};

View file

@ -40,6 +40,7 @@ import {
isEmitSafeCfg,
DEFAULT_MAX_CFG_EDGES_PER_FUNCTION,
DEFAULT_PDG_MAX_REACHING_DEF_EDGES_PER_FUNCTION,
DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION,
REACHING_DEF_FACTS_PER_EDGE_CAP,
} from '../../cfg/emit.js';
import {
@ -50,6 +51,12 @@ import {
} from '../../taint/emit.js';
import { registerBuiltinTaintModels } from '../../taint/typescript-model.js';
import { getSourceSinkConfig } from '../../taint/source-sink-registry.js';
import {
buildFunctionNodeIndex,
harvestFileSummaries,
type FunctionNodeIndex,
} from '../../taint/summary-harvest-driver.js';
import type { FunctionSummary } from '../../taint/summary-model.js';
import type { FunctionCfg } from '../../cfg/types.js';
import { resolveDefGraphId } from '../graph-bridge/ids.js';
import { buildPopulatedMethodDispatch } from '../graph-bridge/method-dispatch.js';
@ -302,6 +309,14 @@ interface RunScopeResolutionInput {
* base is safe.
*/
readonly prebuiltNodeLookup?: ReturnType<typeof buildGraphNodeLookup>;
/**
* Functionish-node index built ONCE by the caller and shared across every
* language pass (#2084 review P2-6). Like `prebuiltNodeLookup`,
* `buildFunctionNodeIndex` is a whole-graph scan and is language-agnostic, so
* rebuilding it per language wastes a full scan each time. When omitted
* (tests / isolated calls) it is built locally for the pdg-enabled language.
*/
readonly prebuiltFunctionNodeIndex?: FunctionNodeIndex;
/**
* Opaque per-language import-resolution config (e.g. tsconfig path
* aliases for TypeScript). Loaded once by the caller via
@ -360,6 +375,13 @@ interface RunScopeResolutionStats {
readonly referenceEdgesEmitted: number;
readonly referenceSkipped: number;
readonly resolutionOutcomes: readonly ResolutionOutcome[];
/**
* Per-function taint summaries harvested in the pdg window (#2084 M4 U1).
* Empty unless `input.pdg === true` and the language has a registered taint
* model. Keyed by resolved `Function`/`Method` node id; the cross-function
* fixpoint phase composes them over the complete `CALLS` graph.
*/
readonly functionSummaries: readonly FunctionSummary[];
}
export function runScopeResolution(
@ -477,6 +499,7 @@ export function runScopeResolution(
referenceEdgesEmitted: 0,
referenceSkipped: 0,
resolutionOutcomes,
functionSummaries: [],
};
}
@ -730,6 +753,11 @@ export function runScopeResolution(
// pair can't bracket them; without this accumulator the M2 cost would
// silently disappear into `emit=` and field regressions would be invisible.
let pdgMs = 0;
// M4 (#2084 U1): per-function taint summaries harvested in the pdg window,
// returned on the stats for the cross-function fixpoint phase. Function-scoped
// so the return (below the pdg block) can read it; empty on non-pdg runs.
const harvestedSummaries: FunctionSummary[] = [];
let summaryUnresolved = 0;
// M3 (#2083 U4): accumulated taint time (match + taint-side solve +
// propagate + TAINTED/SANITIZES emit), a sibling of `pdgMs` for the same
// reason — it interleaves per file inside `emit=`, so only an accumulator
@ -784,6 +812,14 @@ export function runScopeResolution(
gapExamples: [] as string[],
dropExamples: [] as string[],
};
// M4 (#2084 U1): per-function summary harvest. The functionish-node index
// is built ONCE (whole-graph scan) and reused across every file; summaries
// accumulate here and ride out on the stats for the cross-function fixpoint
// phase. Only built when the language has a registered taint model.
const fnNodeIndex =
taintSpec !== undefined
? (input.prebuiltFunctionNodeIndex ?? buildFunctionNodeIndex(graph))
: undefined;
for (const pf of emitParsedFiles) {
const cfgs = pf.cfgSideChannel;
// Defensive: cfgSideChannel is opaque (`unknown`) and crosses the cache /
@ -872,6 +908,25 @@ export function runScopeResolution(
for (const ex of taint.droppedExamples) {
if (taintTotals.dropExamples.length < 5) taintTotals.dropExamples.push(ex);
}
// M4 (#2084 U1): harvest per-function summaries over the SAME
// emit-safe CFGs, inside the SAME per-file try. Pure aside from the
// read-only node-index lookup; the cross-function fixpoint phase
// consumes `harvestedSummaries` once the whole call graph is built.
if (fnNodeIndex !== undefined) {
const harvest = harvestFileSummaries(
fnNodeIndex,
wellFormed,
pf.parsedImports,
taintSpec,
// Same fact cap the taint-side RD solve uses (coverage parity).
taintLimits.maxFacts && taintLimits.maxFacts > 0
? taintLimits.maxFacts
: DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION,
);
harvestedSummaries.push(...harvest.summaries);
summaryUnresolved += harvest.unresolved;
}
}
} catch (err) {
// Last-resort isolation, mirroring the worker-side per-file try/catch:
@ -942,6 +997,16 @@ export function runScopeResolution(
logger.warn(`[taint] lang=${provider.language}: ${parts.join('; ')}`);
}
}
// M4 (#2084 U1): summary harvest volume + anchor-resolution diagnostics.
if (harvestedSummaries.length > 0 || summaryUnresolved > 0) {
logger.debug(
`[taint-summary] lang=${provider.language}: ${harvestedSummaries.length} function ` +
`summary/summaries harvested` +
(summaryUnresolved > 0
? `, ${summaryUnresolved} CFG anchor(s) unresolved (same-line collision or missing node)`
: ''),
);
}
}
if (PROF) {
@ -971,5 +1036,6 @@ export function runScopeResolution(
referenceEdgesEmitted: emitted + receiverExtras + unresolvedReceiverExtras + freeCallExtras,
referenceSkipped: skipped,
resolutionOutcomes,
functionSummaries: harvestedSummaries,
};
}

View file

@ -0,0 +1,120 @@
/**
* Interprocedural taint emission (#2084 M4 U4) materialise `TAINT_PATH`.
*
* Persists each cross-function {@link InterprocFinding} as ONE `TAINT_PATH`
* edge from the source function node to the sink function node, with the
* function-level hop chain + sink kind encoded in `reason` via the SHARED
* `path-codec` (the same versioned wire format M3's intra-procedural `TAINTED`
* edges use never a second hand-rolled codec). The MCP `explain` tool decodes
* it for cross-function path rendering (U7).
*
* `TAINT_PATH` was reserved at M0 (the RelationshipType + the `CodeRelation`
* Function/Method node-pairs already exist), so materialisation needs zero
* schema work. Like `TAINTED`, it stays out of `VALID_RELATION_TYPES` and the
* web schema `explain` is the discovery surface.
*
* Boundedness mirrors the M3 emit driver: dedup-before-cap (the solver already
* deduped by `(source, sink, kind)`), a per-run findings cap, and unconditional
* truncate-and-warn never a silent drop.
*/
import type { KnowledgeGraph } from '../../graph/types.js';
import { encodeTaintPath, type TaintPathHopInput } from './path-codec.js';
import type { InterprocFinding } from './interproc-solver.js';
/** Confidence stamped on interprocedural `TAINT_PATH` edges. Lower than the
* intra-procedural `TAINTED` 1.0 context-insensitive composition is a
* coarser signal (return/call-site merging). */
export const INTERPROC_TAINT_CONFIDENCE = 0.6;
/**
* Default per-run cap on emitted `TAINT_PATH` edges (#2084 review P1-3).
* Resolved into `RepoMeta.pdg` like the other pdg caps; `0` unlimited.
*/
export const DEFAULT_PDG_MAX_INTERPROC_EDGES = 1000;
export interface InterprocEmitLimits {
/** Max `TAINT_PATH` edges per run (post-dedup). `undefined`/0 ⇒ unlimited. */
readonly maxEdges?: number;
}
export interface InterprocEmitResult {
/** TAINT_PATH edges persisted. */
edgesEmitted: number;
/** Findings dropped by the per-run cap. */
edgesDropped: number;
/** Findings whose persisted hop path is a truncated prefix. */
hopsTruncated: number;
/** Findings skipped because an endpoint node was missing from the graph. */
skippedMissingEndpoint: number;
}
/**
* Persist cross-function findings as `TAINT_PATH` edges. `findings` is assumed
* deduped + deterministically ordered (the solver's contract). Never throws on
* valid input.
*/
export function emitInterprocTaint(
graph: KnowledgeGraph,
findings: readonly InterprocFinding[],
limits?: InterprocEmitLimits,
onWarn?: (message: string) => void,
): InterprocEmitResult {
const result: InterprocEmitResult = {
edgesEmitted: 0,
edgesDropped: 0,
hopsTruncated: 0,
skippedMissingEndpoint: 0,
};
const maxEdges = limits?.maxEdges && limits.maxEdges > 0 ? limits.maxEdges : Infinity;
const seen = new Set<string>();
for (const finding of findings) {
if (result.edgesEmitted >= maxEdges) {
result.edgesDropped++;
continue;
}
const sourceNode = graph.getNode(finding.sourceFnId);
const sinkNode = graph.getNode(finding.sinkFnId);
if (!sourceNode || !sinkNode) {
result.skippedMissingEndpoint++;
continue;
}
// Map function hops → codec hops. The hop "name" is the function's display
// name (identifier charset — codec-safe); the line is its start line.
const hops: TaintPathHopInput[] = finding.hops.map((h) => {
const node = graph.getNode(h.fnId);
const name = typeof node?.properties.name === 'string' ? node.properties.name : 'fn';
const line = typeof node?.properties.startLine === 'number' ? node.properties.startLine : 0;
return { name, line };
});
const encoded = encodeTaintPath(hops, {
kind: finding.sinkKind,
truncated: finding.hopsTruncated,
});
if (encoded.truncated) result.hopsTruncated++;
const id = `rel:TAINT_PATH:${finding.sinkKind}:${finding.sourceFnId}=>${finding.sinkFnId}`;
if (seen.has(id)) continue;
seen.add(id);
graph.addRelationship({
id,
sourceId: finding.sourceFnId,
targetId: finding.sinkFnId,
type: 'TAINT_PATH',
confidence: INTERPROC_TAINT_CONFIDENCE,
reason: encoded.reason,
});
result.edgesEmitted++;
}
if (result.edgesDropped > 0) {
onWarn?.(
`[taint-interproc] ${result.edgesDropped} cross-function finding(s) dropped by the ` +
`per-run TAINT_PATH cap (${maxEdges})`,
);
}
return result;
}

View file

@ -0,0 +1,412 @@
/**
* Interprocedural taint fixpoint (#2084 M4 U3).
*
* Composes per-function {@link FunctionSummary} objects over the resolved
* `CALLS` graph to find sourcesink flows that cross function and file
* boundaries. PURE AND DETERMINISTIC (no graph, no I/O, no logger) the phase
* builds the inputs from `ctx.graph` and persists the outputs.
*
* ## The model whole-parameter taint reachability
*
* The unit of taint is `(function, parameter)`. The fixpoint computes the set
* of parameters that can hold source-derived data, then fires a finding
* whenever a tainted parameter feeds a modelled sink (`paramToSink`).
*
* - **Seeds** every `sourceToCallArg` edge: a function generates a source and
* passes it into argument `argIndex` of a call at `callLine`. Resolving that
* call site against the caller's outgoing `CALLS` edges yields the callee;
* the callee's parameter `argIndex` becomes tainted, with the generating
* function recorded as the flow's source.
* - **Propagation** every `paramToCallArg` edge of a function whose parameter
* is ALREADY tainted: `param i → arg j of callee` taints the callee's
* parameter `j` (TITO composition). Iterated to a fixpoint.
* - **Findings** whenever a parameter becomes tainted and the owning
* function's `paramToSink` contains that parameter, a cross-function finding
* is emitted (source function sink function, with the kind).
*
* ## Cycle safety (recursion)
*
* The tainted-parameter set is monotone over a FINITE lattice (`Σ functions ×
* params`), so the worklist fixpoint converges: a recursive or mutually
* recursive call merely re-proposes an already-tainted parameter, which the
* visited-set absorbs no infinite descent. This is the functional/summary
* method's standard termination argument (Sharir-Pnueli; Pysa, Mariana Trench,
* and Infer all rely on it). SCC condensation would only refine the PROCESSING
* ORDER; correctness and termination do not require it.
*
* ## Context-insensitivity & the name-join over-approximation
*
* One summary per function, applied at every call site return/param merging
* is accepted (the security-conservative direction). The call-argcallee join
* is by callee NAME (not line), so when one caller invokes two DISTINCT
* same-named callees (`x.handler(src)` and `y.handler(clean)`), a source that
* flowed into ONE of them taints BOTH callees' parameter an extra finding on
* the callee the source did not reach. This is sound (over-attribution, never a
* missed flow the conservative direction for a security tool) and is the
* documented price of dropping the fragile line-based join; the `explain` tool
* surfaces it ("may over-attribute among same-named callees"). Other known
* precision losses (call-site conflation, shared dispatch, callbacks) are the
* documented M4 trade-offs; refinements are deferred (plan KTD).
*/
import type { SinkKind } from './source-sink-config.js';
import type { FunctionSummary } from './summary-model.js';
/**
* One resolved call edge from the `CALLS` graph. The join to a summary's
* call-arg edge is by CALLEE NAME (the callee node's declared name), NOT by
* call-site line line-base parity between the CFG harvest (1-based) and the
* reference site is fragile, while the callee identity is exact and the
* context-insensitive model tatints the callee's parameter the same way at
* every call site to it.
*/
export interface InterprocCallEdge {
readonly callerId: string;
readonly calleeId: string;
/** The callee node's declared name (`helper`, `process`) — the join key. */
readonly calleeName: string;
}
/** One hop of a cross-function flow: the function entered, and how. */
export interface InterprocHop {
readonly fnId: string;
/** The call-site line in the PREVIOUS function that entered this one. */
readonly callLine?: number;
/** Argument position the taint entered through (undefined for the source fn). */
readonly argIndex?: number;
}
export interface InterprocFinding {
readonly sourceFnId: string;
readonly sinkFnId: string;
readonly sinkKind: SinkKind;
/** Ordered source→sink hop chain (functions). A prefix when `truncated`. */
readonly hops: readonly InterprocHop[];
readonly hopsTruncated: boolean;
}
export interface InterprocLimits {
/** Max functions in a single flow's hop chain. `undefined`/0 ⇒ default 32. */
readonly maxHops?: number;
/** Max findings overall (post-dedup). `undefined`/0 ⇒ unlimited. */
readonly maxFindings?: number;
}
export interface InterprocResult {
readonly findings: readonly InterprocFinding[];
/** Findings dropped by `maxFindings` (post-dedup). */
readonly droppedFindings: number;
/** Call edges whose call-site line matched no summary edge (diagnostics). */
readonly unmatchedCallSites: number;
}
export const DEFAULT_MAX_INTERPROC_HOPS = 32;
/**
* Default per-run cap on cross-function findings (#2084 review P1-3). Like the
* other pdg caps it is resolved into `RepoMeta.pdg` so `pdgModeMismatch`
* stamps it; `0` unlimited. 2000 is generous for a real repo more deduped
* `(source, sink, kind)` findings than that is a fixture or a runaway fan-in,
* and the overflow is deterministic + counted (`droppedFindings`).
*/
export const DEFAULT_PDG_MAX_INTERPROC_FINDINGS = 2000;
/** A tainted parameter, with the flow that first tainted it (for path reconstruction). */
interface TaintedParam {
readonly fnId: string;
readonly paramIndex: number;
readonly sourceFnId: string;
/** Hop chain from source to this `(fnId, paramIndex)` entry. */
readonly hops: readonly InterprocHop[];
readonly truncated: boolean;
/**
* Sink kinds neutralised on the composed path to here (#2084 review P1-2)
* UNION along the hop chain (a sanitizer at any upstream call-arg stays
* neutralised downstream). A `paramToSink` of a kind in this set does NOT
* fire (the cross-function sanitizer). Mutable in spirit: on revisit by a
* less-neutralised path the stored set INTERSECTS (mirrors `propagate.ts`).
*/
readonly neutralized: ReadonlySet<SinkKind>;
}
/**
* Taint-state key `(function, parameter, SOURCE)`. The source discriminator
* is load-bearing: without it, a parameter tainted by source A is marked
* visited and a later flow from source B to the SAME parameter is dropped
* before it can fire that function's sink, silently losing Bsink (the
* multi-source collapse the recurring M3 bug class). Including the source
* keeps each origin's flow independent; the lattice stays finite (`fn × param ×
* source`), so the monotone worklist still terminates and is cycle-safe.
*/
const pkey = (fnId: string, param: number, sourceFnId: string): string =>
`${fnId}#${param}#${sourceFnId}`;
/**
* Run the interprocedural taint fixpoint. `summaries` is keyed by function node
* id; `callEdges` is the resolved `CALLS` graph (callercallee with call-site
* lines). Deterministic: inputs in, sorted findings out.
*/
export function solveInterprocTaint(
summaries: ReadonlyMap<string, FunctionSummary>,
callEdges: readonly InterprocCallEdge[],
limits?: InterprocLimits,
): InterprocResult {
const maxHops =
limits?.maxHops && limits.maxHops > 0 ? limits.maxHops : DEFAULT_MAX_INTERPROC_HOPS;
// Adjacency built ONCE (#2084 review P3-8): callerId → outgoing edges, AND
// callerId → calleeName → edges. The summary's call-arg edges resolve by
// callee NAME, so the per-name index turns each resolution into an O(1)
// lookup instead of a per-worklist-step `.filter` allocation (the
// build-index-once pattern).
const callsByCaller = new Map<string, InterprocCallEdge[]>();
const callsByCallerName = new Map<string, Map<string, InterprocCallEdge[]>>();
for (const e of callEdges) {
const list = callsByCaller.get(e.callerId);
if (list) list.push(e);
else callsByCaller.set(e.callerId, [e]);
let byName = callsByCallerName.get(e.callerId);
if (!byName) {
byName = new Map();
callsByCallerName.set(e.callerId, byName);
}
const named = byName.get(e.calleeName);
if (named) named.push(e);
else byName.set(e.calleeName, [e]);
}
let unmatchedCallSites = 0;
/** Edges to `name` from `callerId` (O(1)); empty if none — non-counting. */
const calleesByName = (callerId: string, name: string): InterprocCallEdge[] =>
callsByCallerName.get(callerId)?.get(name) ?? [];
// Resolve a caller's call-arg edge (by callee name) to concrete callee edges.
// An unknown callee name (chain not statically resolvable) conservatively
// matches EVERY outgoing call — sound over-approximation (may over-taint).
const resolveCallees = (
callerId: string,
calleeName: string | undefined,
): InterprocCallEdge[] => {
const candidates = callsByCaller.get(callerId);
if (!candidates || candidates.length === 0) {
unmatchedCallSites++;
return [];
}
if (calleeName === undefined) return candidates;
const named = calleesByName(callerId, calleeName);
if (named.length === 0) {
unmatchedCallSites++;
return [];
}
return named;
};
// ── findings + worklist ───────────────────────────────────────────────────
const findingsByKey = new Map<string, InterprocFinding>();
const tainted = new Map<string, TaintedParam>();
const queue: TaintedParam[] = [];
const recordFinding = (
sourceFnId: string,
sinkFnId: string,
sinkKind: SinkKind,
hops: readonly InterprocHop[],
truncated: boolean,
): void => {
const key = `${sourceFnId}|${sinkFnId}|${sinkKind}`;
if (findingsByKey.has(key)) return;
findingsByKey.set(key, { sourceFnId, sinkFnId, sinkKind, hops, hopsTruncated: truncated });
};
/** Fire every `paramToSink` of `tp`'s param, except kinds it neutralised. */
const fireSinks = (tp: TaintedParam): void => {
const summary = summaries.get(tp.fnId);
if (!summary) return;
for (const ps of summary.paramToSink) {
if (ps.param !== tp.paramIndex) continue;
if (tp.neutralized.has(ps.sinkKind)) continue; // sanitised across the boundary (P1-2)
// `tp.hops` already terminates at this (tainted) function — it IS the
// source→sink chain, no extra hop to append.
recordFinding(tp.sourceFnId, tp.fnId, ps.sinkKind, tp.hops, tp.truncated);
}
};
/**
* Mark (fnId, paramIndex, source) tainted; enqueue. On a fresh key, taint +
* fire sinks. On revisit, INTERSECT the neutralised set (a kind stays
* neutralised only if EVERY path neutralises it the sound direction); if it
* shrank, re-enqueue + re-fire so a less-neutralised path's sinks surface
* (the shrink-reprocess guard, mirroring `propagate.ts:deriveTaint`). Without
* it, a first more-neutralised path would freeze out a real finding (FN).
*/
const taint = (tp: TaintedParam): void => {
const key = pkey(tp.fnId, tp.paramIndex, tp.sourceFnId);
const existing = tainted.get(key);
if (existing) {
const inter = new Set<SinkKind>();
for (const k of existing.neutralized) if (tp.neutralized.has(k)) inter.add(k);
if (inter.size >= existing.neutralized.size) return; // no shrink — cycle-safe
const merged: TaintedParam = { ...existing, neutralized: inter };
tainted.set(key, merged);
queue.push(merged);
fireSinks(merged);
return;
}
tainted.set(key, tp);
queue.push(tp);
fireSinks(tp);
};
// ── seeds: every source→callee-arg, resolved against CALLS ────────────────
for (const [callerId, summary] of summaries) {
for (const sc of summary.sourceToCallArg) {
for (const edge of resolveCallees(callerId, sc.calleeName)) {
const callee = summaries.get(edge.calleeId);
if (!callee) continue;
if (sc.argIndex >= callee.paramCount) continue; // arity guard
// Build the seed path through the capped append so `maxHops` truncates
// the prefix (#2084 review P2-7), not a 2-entry path flagged truncated.
const seed = appendHop(
[{ fnId: callerId }],
{ fnId: edge.calleeId, callLine: sc.callLine, argIndex: sc.argIndex },
maxHops,
);
taint({
fnId: edge.calleeId,
paramIndex: sc.argIndex,
sourceFnId: callerId,
hops: seed.hops,
truncated: seed.truncated,
neutralized: new Set(sc.neutralized ?? []),
});
}
}
}
// ── generative return composition (#2084 review P1-1) ─────────────────────
// `genReturns` = functions whose RETURN carries a generated source. Seed with
// `sourceToReturn`; a caller that returns the result of a generative call is
// itself generative (transitive — `wrap(){ return getInput() }`). Small
// monotone fixpoint over the name-resolved call graph (`calleesByName`).
const genReturns = new Set<string>();
for (const [id, s] of summaries) if (s.sourceToReturn.length > 0) genReturns.add(id);
let grChanged = true;
while (grChanged) {
grChanged = false;
for (const [callerId, s] of summaries) {
if (genReturns.has(callerId)) continue;
for (const cr of s.callResults) {
if (cr.dest.to !== 'return') continue;
if (calleesByName(callerId, cr.calleeName).some((e) => genReturns.has(e.calleeId))) {
genReturns.add(callerId);
grChanged = true;
break;
}
}
}
}
// Compose: a caller using a generative call's result either FIRES (the result
// hits a sink) or SEEDS (the result flows into another call's arg). The
// generated source's origin is the generative callee.
for (const [callerId, s] of summaries) {
for (const cr of s.callResults) {
const generative = calleesByName(callerId, cr.calleeName).filter((e) =>
genReturns.has(e.calleeId),
);
if (generative.length === 0) continue;
for (const g of generative) {
const d = cr.dest;
if (d.to === 'sink') {
recordFinding(
g.calleeId,
callerId,
d.sinkKind,
[{ fnId: g.calleeId }, { fnId: callerId }],
2 > maxHops,
);
} else if (d.to === 'callArg') {
for (const tc of d.toCallee === undefined
? (callsByCaller.get(callerId) ?? [])
: calleesByName(callerId, d.toCallee)) {
const callee = summaries.get(tc.calleeId);
if (!callee || d.argIndex >= callee.paramCount) continue;
// Capped successive append so `maxHops` truncates the prefix (P2-7).
const h1 = appendHop([{ fnId: g.calleeId }], { fnId: callerId }, maxHops);
const h2 = appendHop(h1.hops, { fnId: tc.calleeId, argIndex: d.argIndex }, maxHops);
taint({
fnId: tc.calleeId,
paramIndex: d.argIndex,
sourceFnId: g.calleeId,
hops: h2.hops,
truncated: h1.truncated || h2.truncated,
neutralized: new Set(),
});
}
}
// dest:'return' is already folded into `genReturns` above.
}
}
}
// ── propagation worklist ──────────────────────────────────────────────────
let head = 0;
while (head < queue.length) {
const tp = queue[head++];
const summary = summaries.get(tp.fnId);
if (!summary) continue;
// This function's tainted param flows into callee args via paramToCallArg.
for (const pc of summary.paramToCallArg) {
if (pc.param !== tp.paramIndex) continue;
for (const edge of resolveCallees(tp.fnId, pc.calleeName)) {
const callee = summaries.get(edge.calleeId);
if (!callee) continue;
if (pc.argIndex >= callee.paramCount) continue;
const next = appendHop(
tp.hops,
{ fnId: edge.calleeId, callLine: pc.callLine, argIndex: pc.argIndex },
maxHops,
);
// Union the edge's neutralised kinds onto the composed path (a
// sanitizer between this param and the callee arg stays neutralised).
const neutralized =
pc.neutralized && pc.neutralized.length > 0
? new Set<SinkKind>([...tp.neutralized, ...pc.neutralized])
: tp.neutralized;
taint({
fnId: edge.calleeId,
paramIndex: pc.argIndex,
sourceFnId: tp.sourceFnId,
hops: next.hops,
truncated: tp.truncated || next.truncated,
neutralized,
});
}
}
}
// ── deterministic assembly ────────────────────────────────────────────────
const all = [...findingsByKey.values()].sort(
(a, b) =>
a.sourceFnId.localeCompare(b.sourceFnId) ||
a.sinkFnId.localeCompare(b.sinkFnId) ||
a.sinkKind.localeCompare(b.sinkKind),
);
const maxFindings = limits?.maxFindings && limits.maxFindings > 0 ? limits.maxFindings : Infinity;
const findings = all.length > maxFindings ? all.slice(0, maxFindings) : all;
return {
findings,
droppedFindings: all.length - findings.length,
unmatchedCallSites,
};
}
/** Append a hop, respecting the hop cap (keeps the source-side prefix). */
function appendHop(
hops: readonly InterprocHop[],
hop: InterprocHop,
maxHops: number,
): { hops: readonly InterprocHop[]; truncated: boolean } {
if (hops.length >= maxHops) return { hops, truncated: true };
return { hops: [...hops, hop], truncated: hops.length + 1 > maxHops };
}

View file

@ -105,7 +105,12 @@ import type {
MatchedSinkCall,
StatementMatches,
} from './match.js';
import type { SinkKind, SourceKind } from './source-sink-config.js';
import {
SINK_KIND_ORDER as KIND_ORDER,
sortSinkKinds as sortKinds,
type SinkKind,
type SourceKind,
} from './source-sink-config.js';
/**
* Default per-function findings cap (U5 config resolution; cfg/emit.ts
@ -226,17 +231,10 @@ export interface FunctionTaintResult {
readonly droppedFindings: number;
}
/** Canonical SinkKind order for deterministic `neutralized` arrays. */
const KIND_ORDER: readonly SinkKind[] = [
'code-injection',
'command-injection',
'path-traversal',
'sql-injection',
'xss',
];
// Canonical SinkKind order + sort live in source-sink-config.ts (shared with
// the M4 summary harvest so the deterministic order never drifts); imported
// above as KIND_ORDER / sortKinds. `kindRank` is the local comparator index.
const kindRank = new Map<SinkKind, number>(KIND_ORDER.map((k, i) => [k, i]));
const sortKinds = (kinds: Iterable<SinkKind>): SinkKind[] =>
[...new Set(kinds)].sort((a, b) => (kindRank.get(a) ?? 99) - (kindRank.get(b) ?? 99));
const EMPTY_KINDS: ReadonlySet<SinkKind> = new Set();

View file

@ -117,3 +117,33 @@ export interface SourceSinkSanitizerSpec {
readonly sinks: readonly TaintSinkEntry[];
readonly sanitizers: readonly TaintSanitizerEntry[];
}
/**
* Canonical deterministic ordering of {@link SinkKind} values. The single
* source of this order the intra-procedural propagation engine
* (`propagate.ts`) and the M4 summary harvest (`summary-harvest.ts`) both sort
* `neutralized`/exclusion sets by it so their deterministic outputs (and the
* summary version stamp) stay stable. Lives here, next to the `SinkKind`
* union, so the two consumers never drift.
*/
export const SINK_KIND_ORDER: readonly SinkKind[] = [
'code-injection',
'command-injection',
'path-traversal',
'sql-injection',
'xss',
];
const SINK_KIND_RANK = new Map<SinkKind, number>(SINK_KIND_ORDER.map((k, i) => [k, i]));
/** Dedupe + sort sink kinds by {@link SINK_KIND_ORDER} (deterministic). */
export function sortSinkKinds(kinds: Iterable<SinkKind>): SinkKind[] {
return [...new Set(kinds)].sort(
(a, b) => (SINK_KIND_RANK.get(a) ?? 99) - (SINK_KIND_RANK.get(b) ?? 99),
);
}
/** Rank of a sink kind in {@link SINK_KIND_ORDER} (for comparator chaining). */
export function sinkKindRank(kind: SinkKind): number {
return SINK_KIND_RANK.get(kind) ?? 99;
}

View file

@ -0,0 +1,147 @@
/**
* Summary-harvest driver (#2084 M4 U1) the in-phase orchestration that turns
* per-function CFGs into call-graph-keyed {@link FunctionSummary} objects.
*
* Runs inside the scope-resolution pdg window (alongside `emitFileTaint`),
* where both the live CFG side channel AND the structure-phase `Function` /
* `Method` graph nodes are available. For each emit-safe CFG it:
*
* 1. resolves the CFG's source anchor `(filePath, functionStartLine)` to its
* graph node id, so the summary speaks the call graph's language directly
* the interprocedural fixpoint then joins summaries to `CALLS` edges by node
* id with no fragile re-derivation;
* 2. runs the pure {@link harvestFunctionSummary} over the same RD facts +
* matched sites the M3 taint pass uses;
* 3. stamps the own-facts `version` (#2084 review P1-1: callee-version
* composition is RESERVED the fixpoint does not recompute it today).
*
* ## The FunctionCFG join (load-bearing)
*
* `FunctionCfg.functionStartLine` is 1-based (the TS visitor's `row + 1`);
* `Function`/`Method` node `startLine` is 0-based (`startPosition.row`). The
* join therefore looks up node start line `functionStartLine - 1`
* ({@link NODE_TO_CFG_LINE_OFFSET}). Function nodes carry no start column, so a
* `(filePath, startLine)` collision two functions opening on one line,
* `{ a: () => x(), b: () => y() }` is ambiguous: the CFG disambiguates with
* `functionStartColumn` but the node does not, so a colliding anchor is DROPPED
* (counted as `unresolved`) rather than risk attaching a summary to the wrong
* function. Rare in practice; the alternative (cross-wired summaries) is unsound.
*/
import type { ParsedImport, GraphNode } from 'gitnexus-shared';
import type { KnowledgeGraph } from '../../graph/types.js';
import { computeReachingDefs } from '../cfg/reaching-defs.js';
import { DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION } from '../cfg/emit.js';
import type { FunctionCfg } from '../cfg/types.js';
import { buildTaintImportIndex, matchFunctionSites } from './match.js';
import type { SourceSinkSanitizerSpec } from './source-sink-config.js';
import { harvestFunctionSummary } from './summary-harvest.js';
import { ownFactsDigest, summaryVersion, type FunctionSummary } from './summary-model.js';
/** `cfg.functionStartLine` (1-based) this = the node's 0-based `startLine`. */
export const NODE_TO_CFG_LINE_OFFSET = 1;
/** Node labels that can own a CFG / be a `CALLS` endpoint. */
const FUNCTIONISH_LABELS = new Set(['Function', 'Method']);
/**
* Index of functionish graph nodes by `filePath → startLine(0-based) → ids`.
* Built ONCE per scope-resolution pass (the graph is whole-repo); reused across
* every file's harvest.
*/
export type FunctionNodeIndex = ReadonlyMap<string, ReadonlyMap<number, readonly string[]>>;
export function buildFunctionNodeIndex(graph: KnowledgeGraph): FunctionNodeIndex {
const index = new Map<string, Map<number, string[]>>();
const add = (node: GraphNode): void => {
if (!FUNCTIONISH_LABELS.has(node.label)) return;
const filePath = node.properties.filePath;
const startLine = node.properties.startLine;
if (typeof filePath !== 'string' || typeof startLine !== 'number') return;
let byLine = index.get(filePath);
if (!byLine) {
byLine = new Map();
index.set(filePath, byLine);
}
const ids = byLine.get(startLine);
if (ids) ids.push(node.id);
else byLine.set(startLine, [node.id]);
};
for (const node of graph.iterNodes()) add(node);
return index;
}
/** Resolve a CFG anchor to a unique functionish node id, or undefined. */
function resolveFnId(fnIndex: FunctionNodeIndex, cfg: FunctionCfg): string | undefined {
const byLine = fnIndex.get(cfg.filePath);
if (!byLine) return undefined;
const ids = byLine.get(cfg.functionStartLine - NODE_TO_CFG_LINE_OFFSET);
// Unique match only — a same-line collision is unresolvable (no node column).
return ids && ids.length === 1 ? ids[0] : undefined;
}
export interface FileSummaryResult {
readonly summaries: readonly FunctionSummary[];
/** CFGs whose anchor resolved to no unique graph node (collision / missing). */
readonly unresolved: number;
/** CFGs whose reaching-defs were not `computed` (no summary produced). */
readonly gaps: number;
}
/**
* Harvest summaries for one file's emit-safe CFGs. `cfgs` MUST already be
* `isEmitSafeCfg`-filtered (the same `wellFormed` array fed to `emitFileTaint`).
* Pure aside from the read-only graph lookup; never throws on valid input.
*/
export function harvestFileSummaries(
fnIndex: FunctionNodeIndex,
cfgs: readonly FunctionCfg[],
parsedImports: readonly ParsedImport[],
spec: SourceSinkSanitizerSpec,
maxFacts: number = DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION,
): FileSummaryResult {
const importIndex = buildTaintImportIndex(parsedImports);
const summaries: FunctionSummary[] = [];
let unresolved = 0;
let gaps = 0;
for (const cfg of cfgs) {
const fnId = resolveFnId(fnIndex, cfg);
if (fnId === undefined) {
unresolved++;
continue;
}
const defUse = computeReachingDefs(cfg, { maxFacts });
const matches = matchFunctionSites(cfg, spec, importIndex);
const harvested = harvestFunctionSummary(cfg, defUse, matches);
if (harvested.status !== 'computed') {
gaps++;
continue;
}
const facts = harvested.facts;
// Skip functions with NO taint behaviour at all — they cannot participate
// in any flow and would only bloat the fixpoint's working set.
if (
facts.paramToReturn.length === 0 &&
facts.paramToCallArg.length === 0 &&
facts.paramToSink.length === 0 &&
facts.sourceToReturn.length === 0 &&
facts.sourceToCallArg.length === 0 &&
facts.callResults.length === 0
) {
continue;
}
const digest = ownFactsDigest(facts);
summaries.push({
fnId,
filePath: cfg.filePath,
startLine: cfg.functionStartLine,
...facts,
// Provisional own-only version; the fixpoint recomputes with callee
// versions once the call graph is condensed.
version: summaryVersion(digest, []),
});
}
return { summaries, unresolved, gaps };
}

View file

@ -0,0 +1,592 @@
/**
* Per-function taint SUMMARY harvest (#2084 M4 U1).
*
* Pure, deterministic derivation of one function's {@link FunctionSummary}
* facts from the SAME substrate the M3 intra-procedural pass consumes the M2
* reaching-definition facts (`computeReachingDefs`) and the matched taint sites
* (`matchFunctionSites`). No graph, no I/O, no logger; mirrors the
* `computeReachingDefs` / `computeTaintFlows` contract (insertion-ordered
* worklist, explicitly sorted outputs) so snapshot tests and the version stamp
* stay stable. Runs IN-PHASE inside the scope-resolution pdg window where the
* CFG side channel is live (plan KTD1); the cross-function fixpoint that
* COMPOSES these summaries runs afterward over the complete call graph.
*
* ## What a summary captures (whole-parameter granularity)
*
* Seeding each formal parameter as taint and running forward reachability over
* the defuse facts yields four edge categories:
*
* - **paramreturn** a param's value reaches a `return <expr>`. Return
* statements are identified structurally: the SOURCE block of every CFG edge
* of kind `return` terminates in the return jump (the M2 edge-kind
* invariant), so its last statement's `uses` are the returned bindings.
* - **paramcallee-arg** a param occurrence lands in argument position
* `argIndex` of a call at `callLine`. The fixpoint resolves `callLine` to a
* callee via the caller's `CALLS` edges and applies the callee's summary
* (TITO composition).
* - **paramsink** a param reaches a modelled sink position (the partial
* flow that a cross-function source completes).
* - **sourcereturn** a modelled source read (`req.body`) reaches the return
* (a generative summary: calling the function yields tainted data).
*
* ## Soundness model (context-insensitive first cut)
*
* Onward propagation uses the M3 STATEMENT-LEVEL precision floor: a statement
* that uses a tainted binding taints all of its defs (and `mayDefs`). This is
* the same sound over-approximation M3 documents it may over-taint
* (multi-declarator conflation) but never drops a real flow. Sanitizer
* `resultDefs` narrow the EXCLUSION set (a def produced by a matched sanitizer
* carries that sanitizer's neutralised `SinkKind`s), so a sanitised value does
* not trigger a downstream sink of the neutralised kind the kind-set
* exclusion model, simplified to the result-def channel (occurrence
* interposition, field paths, and callbacks are deferred plan KTD).
*
* The summary edges themselves (return / call-arg / sink) are recorded from
* ACTUAL binding occurrences (a tainted binding present in a return's uses, a
* call's arg list, or a matched sink position), never the floor the floor
* governs only onward def-tainting, keeping the recorded edges precise.
*
* ## Known limitation destructured / rest params (documented FN)
*
* Param indices are assigned by ORDINAL over the flattened param-binding list,
* which equals the FORMAL parameter position only when every param is a simple
* identifier. A destructured or rest param contributes several bindings (or
* shifts the count), so a simple param positioned AFTER one
* (`function f([a, b], x) { sink(x) }`) gets a summary port index that does not
* match the formal argument position the interprocedural solver joins against
* a cross-function false negative for that function. The precise fix needs a
* formal-param index threaded from the worker harvest (`BindingEntry`), a
* cache-namespace-affecting change deferred with the other documented FN
* classes (closures, fields see the taint skill). Functions with all-simple
* params (the common case) are unaffected.
*/
import type { FunctionCfg, SiteRecord } from '../cfg/types.js';
import { pointKey, type FunctionDefUse, type ProgramPoint } from '../cfg/reaching-defs.js';
import type { FunctionSiteMatches } from './match.js';
import { sinkKindRank, sortSinkKinds, type SinkKind } from './source-sink-config.js';
import type {
CallResult,
ParamToCallArg,
ParamToReturn,
ParamToSink,
SourceToCallArg,
SourceToReturn,
} from './summary-model.js';
/** The own-facts portion of a summary (fnId/version are added by the caller). */
export interface HarvestedSummaryFacts {
readonly paramCount: number;
readonly paramToReturn: readonly ParamToReturn[];
readonly paramToCallArg: readonly ParamToCallArg[];
readonly paramToSink: readonly ParamToSink[];
readonly sourceToReturn: readonly SourceToReturn[];
readonly sourceToCallArg: readonly SourceToCallArg[];
readonly callResults: readonly CallResult[];
}
export interface HarvestResult {
/** `computed` facts derived; `coverage-gap` the RD solver was not
* `computed`, so no summary is produced (consistent with M3 R4). */
readonly status: 'computed' | 'coverage-gap';
readonly gapReason?: FunctionDefUse['status'];
readonly facts: HarvestedSummaryFacts;
}
const EMPTY_FACTS: HarvestedSummaryFacts = {
paramCount: 0,
paramToReturn: [],
paramToCallArg: [],
paramToSink: [],
sourceToReturn: [],
sourceToCallArg: [],
callResults: [],
};
/** Last segment of a dotted callee path (`child_process.exec` ⇒ `exec`). */
const calleeTail = (callee: string | undefined): string | undefined =>
callee === undefined ? undefined : (callee.split('.').pop() ?? callee);
/** A tainted binding flowing forward, tagged with the seed it came from. */
interface SeedTaint {
readonly bindingIdx: number;
readonly point: ProgramPoint;
/** Param index (≥0), or -1 for a source seed, or -2 for a call-result seed. */
readonly seedId: number;
/** Sink kinds neutralised on the path to here (monotone over the floor). */
readonly exclusions: ReadonlySet<SinkKind>;
/** For a call-result seed (#2084 review P1-1): the user function whose RESULT
* this taint flows from. When set, reaches record {@link CallResult} edges. */
readonly originCallee?: string;
}
/**
* Harvest the summary facts for one function. PRECONDITION: `cfg` is
* `isEmitSafeCfg`-filtered and `defUse` was computed from it; sites are assumed
* `hasTaintSafeSites`-valid (the caller gates exactly as the M3 emit path does).
*/
export function harvestFunctionSummary(
cfg: FunctionCfg,
defUse: FunctionDefUse,
matches: FunctionSiteMatches,
): HarvestResult {
if (defUse.status !== 'computed') {
return { status: 'coverage-gap', gapReason: defUse.status, facts: EMPTY_FACTS };
}
const bindings = defUse.bindings;
// ── param bindings → param index (declaration order) ──────────────────────
// `kind:'param'` bindings, ordered by declaration site (declLine/declColumn).
const paramBindings = bindings
.map((b, idx) => ({ b, idx }))
.filter((e) => e.b.kind === 'param')
.sort((a, b) => a.b.declLine - b.b.declLine || a.b.declColumn - b.b.declColumn);
const paramIndexOf = new Map<number, number>();
paramBindings.forEach((e, paramIdx) => paramIndexOf.set(e.idx, paramIdx));
const paramCount = paramBindings.length;
// ── return points: source block of every `return` CFG edge ────────────────
// The M2 edge-kind invariant: a `return` edge's SOURCE block terminates in
// the return jump, so its LAST statement is the `return <expr>` — its `uses`
// are the returned bindings. (`return;` with no value has empty uses.)
const returnUseStmtKeys = new Set<string>();
for (const e of cfg.edges) {
if (e.kind !== 'return') continue;
const block = cfg.blocks[e.from];
const stmts = block?.statements;
if (!stmts || stmts.length === 0) continue;
returnUseStmtKeys.add(`${e.from}:${stmts.length - 1}`);
}
// ── per-statement match context (sink/source/sanitizer by site) ───────────
const sinkPosBySite = new Map<string, Map<number, Set<number>>>(); // stmtKey → site → argPositions
const sinkKindByEntry = new Map<string, Map<number, SinkKind[]>>(); // stmtKey → site → kinds at any pos
const sanitizerResultDefKinds = new Map<string, Map<number, SinkKind[]>>(); // stmtKey → resultDef binding → kinds
// Matched sink/sanitizer sites (`stmtKey:siteIndex`) — EXCLUDED from the
// call-result seed (#2084 review P1-1): their result semantics are already
// modelled (a sanitizer's result rides U2 exclusions; a sink returns void).
const modeledSites = new Set<string>();
for (const sm of matches.statements) {
const stmtKey = `${sm.blockIndex}:${sm.statementIndex}`;
for (const s of sm.sinks) modeledSites.add(`${stmtKey}:${s.siteIndex}`);
for (const s of sm.sanitizers) modeledSites.add(`${stmtKey}:${s.siteIndex}`);
if (sm.sinks.length > 0) {
const bySite = new Map<number, Set<number>>();
const kindBySite = new Map<number, SinkKind[]>();
for (const sink of sm.sinks) {
const pos = bySite.get(sink.siteIndex) ?? new Set<number>();
for (const p of sink.argPositions) pos.add(p);
bySite.set(sink.siteIndex, pos);
const ks = kindBySite.get(sink.siteIndex) ?? [];
ks.push(sink.entry.kind);
kindBySite.set(sink.siteIndex, ks);
}
sinkPosBySite.set(stmtKey, bySite);
sinkKindByEntry.set(stmtKey, kindBySite);
}
if (sm.sanitizers.length > 0) {
const byDef = new Map<number, SinkKind[]>();
for (const san of sm.sanitizers) {
for (const d of san.resultDefs) {
const ks = byDef.get(d) ?? [];
ks.push(...san.entry.neutralizes);
byDef.set(d, ks);
}
}
sanitizerResultDefKinds.set(stmtKey, byDef);
}
}
const stmtAt = (p: ProgramPoint) => cfg.blocks[p.blockIndex]?.statements?.[p.stmtIndex];
// ── def→use index ─────────────────────────────────────────────────────────
const factsByDef = new Map<string, { bindingIdx: number; use: ProgramPoint }[]>();
for (const f of defUse.facts) {
const key = `${f.bindingIdx}:${pointKey(f.def)}`;
const list = factsByDef.get(key);
const entry = { bindingIdx: f.bindingIdx, use: f.use };
if (list) list.push(entry);
else factsByDef.set(key, [entry]);
}
// ── accumulators (deduped by string identity) ─────────────────────────────
const paramReturn = new Map<number, Set<SinkKind>>(); // param → neutralized intersection
const paramReturnSeen = new Set<number>();
const paramCallArg = new Map<string, ParamToCallArg>();
const sourceCallArg = new Map<string, SourceToCallArg>();
// Intersection-over-paths of the neutralized kinds reaching each call-arg
// edge (#2084 review P1-2, deepening correction a). MUST intersect, not
// first-write-wins: a second, un-sanitized occurrence path to the same edge
// (`relay(x){ exec(x); exec(escape(x)); }`) shrinks the set to ∅ — mirror
// `recordReturn`. `*Seen` tracks first-write so the initial set is a copy.
const paramCallArgKinds = new Map<string, Set<SinkKind>>();
const sourceCallArgKinds = new Map<string, Set<SinkKind>>();
const intersectKinds = (
store: Map<string, Set<SinkKind>>,
key: string,
incoming: ReadonlySet<SinkKind>,
): void => {
const cur = store.get(key);
if (cur === undefined) store.set(key, new Set(incoming));
else for (const k of [...cur]) if (!incoming.has(k)) cur.delete(k);
};
const paramSink = new Set<string>();
const paramSinkOut: ParamToSink[] = [];
const sourceReturn = new Set<SinkKind | 'remote-input'>();
// Caller-side call-result flows (#2084 review P1-1), deduped by a structural key.
const callResults = new Map<string, CallResult>();
const recordCallResult = (cr: CallResult): void => {
const d = cr.dest;
const destKey =
d.to === 'sink'
? `sink:${d.sinkKind}`
: d.to === 'return'
? 'return'
: `arg:${d.toCallee ?? ''}:${d.argIndex}`;
const key = `${cr.calleeName}|${destKey}`;
if (!callResults.has(key)) callResults.set(key, cr);
};
/** Record param→return, intersecting neutralized kinds across paths. */
const recordReturn = (param: number, exclusions: ReadonlySet<SinkKind>): void => {
if (!paramReturnSeen.has(param)) {
paramReturnSeen.add(param);
paramReturn.set(param, new Set(exclusions));
} else {
const cur = paramReturn.get(param) as Set<SinkKind>;
for (const k of [...cur]) if (!exclusions.has(k)) cur.delete(k);
}
};
// ── seeds: each param at its entry def point + each source statement ───────
// seedId 0..paramCount-1 = params; -1 = source.
const queue: SeedTaint[] = [];
const visited = new Set<string>();
const enqueue = (t: SeedTaint): void => {
// originCallee discriminates call-result seeds (all share seedId -2) so two
// distinct callees' results on the same binding are not collapsed.
const key = `${t.seedId}:${t.originCallee ?? ''}:${t.bindingIdx}:${pointKey(t.point)}:${[...t.exclusions].sort().join(',')}`;
if (visited.has(key)) return;
visited.add(key);
queue.push(t);
};
// Param seeds: find each param's def point(s) in the def→use facts (params are
// defined at ENTRY; any fact whose def-binding is the param and whose def
// sits in the entry block is a param-origin edge).
for (const { idx } of paramBindings) {
const paramIdx = paramIndexOf.get(idx) as number;
// Seed at every def point of this param binding in the entry block.
for (const f of defUse.facts) {
if (f.bindingIdx === idx && f.def.blockIndex === cfg.entryIndex) {
enqueue({ bindingIdx: idx, point: f.def, seedId: paramIdx, exclusions: new Set() });
}
}
}
// Source seeds: a statement with a matched source taints its own defs; a bare
// `return <source>` is a direct source→return. The source's value rides the
// statement's defs (resultDefs of the assignment) under the floor.
for (const sm of matches.statements) {
if (sm.sources.length === 0) continue;
const stmtKey = `${sm.blockIndex}:${sm.statementIndex}`;
const facts = cfg.blocks[sm.blockIndex]?.statements?.[sm.statementIndex];
if (!facts) continue;
const point: ProgramPoint = {
blockIndex: sm.blockIndex,
stmtIndex: sm.statementIndex,
line: facts.line,
};
if (returnUseStmtKeys.has(stmtKey)) {
for (const src of sm.sources) sourceReturn.add(src.entry.kind);
}
for (const d of [...facts.defs, ...(facts.mayDefs ?? [])]) {
enqueue({ bindingIdx: d, point, seedId: -1, exclusions: new Set() });
}
// DIRECT source-in-call-arg (`runIt(req.body)`): no intermediate binding is
// defined, so the floor seed above records nothing. Climb the source
// member-read's `parent` chain — each enclosing call/new site is a
// `sourceToCallArg` (the cross-function fixpoint seed). A sink ancestor is
// M3's intra-procedural concern and harmless to also record here.
for (const src of sm.sources) {
let cur: SiteRecord | undefined = facts.sites?.[src.siteIndex];
const guard = new Set<number>([src.siteIndex]);
while (cur?.parent) {
const [siteIdx, argPos] = cur.parent;
if (guard.has(siteIdx)) break;
guard.add(siteIdx);
const ancestor = facts.sites?.[siteIdx];
if (!ancestor) break;
if (ancestor.kind === 'call' || ancestor.kind === 'new') {
const tail = calleeTail(ancestor.callee);
const scKey = `${facts.line}:${argPos}:${tail ?? ''}`;
if (!sourceCallArg.has(scKey)) {
sourceCallArg.set(scKey, {
sourceKind: src.entry.kind,
callLine: facts.line,
argIndex: argPos,
...(tail ? { calleeName: tail } : {}),
});
}
}
cur = ancestor;
}
}
}
// Call-result seeds (#2084 review P1-1): a call to a (potentially generative)
// USER function is a NEW taint origin — `matchFunctionSites` only sources
// member-reads, so the result of `getInput()` is invisible today. Seed every
// call/new site that is NOT a matched sink/sanitizer and carries a resolvable
// callee name; the worklist then records a CallResult edge when the result
// reaches a sink / return / another call arg. The fixpoint composes it with
// the callee's `sourceToReturn` (the floor cannot — the source is in the
// callee, so the caller passes no tainted input).
//
// Documented limitation: a result passed DIRECTLY into a modelled sink with
// no binding (`exec(getInput())`) is not recorded as `dest:sink` — the sink
// is occurrence-gated by `matchFunctionSites` and a bare call result is not a
// binding occurrence, so `exec` reads as a plain call (recorded `dest:callArg`
// to a callee with no summary → uncomposed). The binding form
// (`const t = getInput(); exec(t)`) is the supported path.
for (const block of cfg.blocks) {
block.statements?.forEach((facts, stmtIdx) => {
const stmtKey = `${block.index}:${stmtIdx}`;
const point: ProgramPoint = { blockIndex: block.index, stmtIndex: stmtIdx, line: facts.line };
facts.sites?.forEach((site, siteIndex) => {
if (site.kind !== 'call' && site.kind !== 'new') return;
if (modeledSites.has(`${stmtKey}:${siteIndex}`)) return; // sink/sanitizer — modelled
const tail = calleeTail(site.callee);
if (tail === undefined) return; // unresolvable callee — cannot compose
// Binding case (`const t = getInput(); …`): seed the result bindings.
for (const d of site.resultDefs ?? []) {
enqueue({ bindingIdx: d, point, seedId: -2, exclusions: new Set(), originCallee: tail });
}
// Direct case (`exec(getInput())` / `return getInput()`): no result
// binding — climb the call's parent chain (or detect a bare return).
if ((site.resultDefs?.length ?? 0) === 0) {
if (site.parent === undefined && returnUseStmtKeys.has(stmtKey)) {
recordCallResult({ calleeName: tail, dest: { to: 'return' } });
}
let cur: SiteRecord | undefined = site;
const guard = new Set<number>([siteIndex]);
while (cur?.parent) {
const [ancIdx, argPos] = cur.parent;
if (guard.has(ancIdx)) break;
guard.add(ancIdx);
const ancestor = facts.sites?.[ancIdx];
if (!ancestor) break;
const ancKey = `${stmtKey}:${ancIdx}`;
const sinkPositions = sinkPosBySite.get(stmtKey)?.get(ancIdx);
if (sinkPositions?.has(argPos)) {
for (const kind of sinkKindByEntry.get(stmtKey)?.get(ancIdx) ?? []) {
recordCallResult({ calleeName: tail, dest: { to: 'sink', sinkKind: kind } });
}
} else if (
!modeledSites.has(ancKey) &&
(ancestor.kind === 'call' || ancestor.kind === 'new')
) {
recordCallResult({
calleeName: tail,
dest: {
to: 'callArg',
...(calleeTail(ancestor.callee) ? { toCallee: calleeTail(ancestor.callee) } : {}),
argIndex: argPos,
},
});
}
cur = ancestor;
}
}
});
});
}
// ── forward reachability ──────────────────────────────────────────────────
let head = 0;
while (head < queue.length) {
const t = queue[head++];
const b = t.bindingIdx;
for (const fact of factsByDef.get(`${b}:${pointKey(t.point)}`) ?? []) {
const useStmt = stmtAt(fact.use);
if (!useStmt) continue;
const useKey = `${fact.use.blockIndex}:${fact.use.stmtIndex}`;
// (1) return reach
if (returnUseStmtKeys.has(useKey) && useStmt.uses.includes(b)) {
if (t.originCallee !== undefined) {
recordCallResult({ calleeName: t.originCallee, dest: { to: 'return' } });
} else if (t.seedId >= 0) recordReturn(t.seedId, t.exclusions);
else sourceReturn.add('remote-input');
}
// (2) call-arg + sink reach: occurrences of b in this statement's sites.
const sinkBySite = sinkPosBySite.get(useKey);
const kindBySite = sinkKindByEntry.get(useKey);
useStmt.sites?.forEach((site, siteIndex) => {
const argHits = occurrencesInArgs(site, b);
for (const argPos of argHits) {
const callLine = useStmt.line;
const tail = calleeTail(site.callee);
if (t.originCallee !== undefined) {
// Call-result seed (#2084 review P1-1): the result of a call to
// `originCallee` flows into THIS call's arg `argPos`.
recordCallResult({
calleeName: t.originCallee,
dest: { to: 'callArg', ...(tail ? { toCallee: tail } : {}), argIndex: argPos },
});
} else if (t.seedId >= 0) {
const caKey = `${t.seedId}:${callLine}:${argPos}:${tail ?? ''}`;
if (!paramCallArg.has(caKey)) {
paramCallArg.set(caKey, {
param: t.seedId,
callLine,
argIndex: argPos,
...(tail ? { calleeName: tail } : {}),
});
}
// Carry the sanitizer exclusions on the path INTO this call arg,
// intersected over occurrence paths (P1-2).
intersectKinds(paramCallArgKinds, caKey, t.exclusions);
} else {
// Source-seeded: a generated source flowing into a call argument is
// a fixpoint SEED (it taints the callee's param). One source kind
// today ('remote-input'); when more exist the seed must carry it.
const scKey = `${callLine}:${argPos}:${tail ?? ''}`;
if (!sourceCallArg.has(scKey)) {
sourceCallArg.set(scKey, {
sourceKind: 'remote-input',
callLine,
argIndex: argPos,
...(tail ? { calleeName: tail } : {}),
});
}
intersectKinds(sourceCallArgKinds, scKey, t.exclusions);
}
// matched sink at this position?
const sinkPositions = sinkBySite?.get(siteIndex);
if (sinkPositions?.has(argPos)) {
for (const kind of kindBySite?.get(siteIndex) ?? []) {
if (t.exclusions.has(kind)) continue;
if (t.originCallee !== undefined) {
// A generated source returned by `originCallee` reaches a sink.
recordCallResult({
calleeName: t.originCallee,
dest: { to: 'sink', sinkKind: kind },
});
} else if (t.seedId >= 0) {
const sKey = `${t.seedId}:${kind}`;
if (!paramSink.has(sKey)) {
paramSink.add(sKey);
paramSinkOut.push({ param: t.seedId, sinkKind: kind });
}
}
}
}
}
});
// (3) onward floor: this statement's defs become tainted, with sanitizer
// result-def exclusions accumulated.
const sanByDef = sanitizerResultDefKinds.get(useKey);
for (const d of [...useStmt.defs, ...(useStmt.mayDefs ?? [])]) {
const added = sanByDef?.get(d);
const exclusions =
added && added.length > 0 ? new Set([...t.exclusions, ...added]) : t.exclusions;
enqueue({
bindingIdx: d,
point: {
blockIndex: fact.use.blockIndex,
stmtIndex: fact.use.stmtIndex,
line: useStmt.line,
},
seedId: t.seedId,
exclusions,
...(t.originCallee !== undefined ? { originCallee: t.originCallee } : {}),
});
}
}
}
// ── deterministic assembly ────────────────────────────────────────────────
const paramToReturn: ParamToReturn[] = [...paramReturn.entries()]
.map(([param, kinds]) => ({
param,
...(kinds.size > 0 ? { neutralized: sortSinkKinds(kinds) } : {}),
}))
.sort((a, b) => a.param - b.param);
const paramToCallArg = [...paramCallArg.entries()]
.map(([key, edge]) => {
const kinds = paramCallArgKinds.get(key);
return kinds && kinds.size > 0 ? { ...edge, neutralized: sortSinkKinds(kinds) } : edge;
})
.sort(
(a, b) =>
a.param - b.param ||
a.callLine - b.callLine ||
a.argIndex - b.argIndex ||
(a.calleeName ?? '').localeCompare(b.calleeName ?? ''),
);
const paramToSink = paramSinkOut.sort(
(a, b) => a.param - b.param || sinkKindRank(a.sinkKind) - sinkKindRank(b.sinkKind),
);
const sourceToReturn: SourceToReturn[] =
sourceReturn.size > 0 ? [{ sourceKind: 'remote-input' }] : [];
const sourceToCallArg = [...sourceCallArg.entries()]
.map(([key, edge]) => {
const kinds = sourceCallArgKinds.get(key);
return kinds && kinds.size > 0 ? { ...edge, neutralized: sortSinkKinds(kinds) } : edge;
})
.sort(
(a, b) =>
a.callLine - b.callLine ||
a.argIndex - b.argIndex ||
(a.calleeName ?? '').localeCompare(b.calleeName ?? ''),
);
const callResultsOut = [...callResults.values()].sort((a, b) => {
const ord = (cr: CallResult): string => {
const d = cr.dest;
const dest =
d.to === 'sink'
? `1sink:${d.sinkKind}`
: d.to === 'return'
? '2return'
: `0arg:${d.toCallee ?? ''}:${d.argIndex}`;
return `${cr.calleeName}|${dest}`;
};
return ord(a).localeCompare(ord(b));
});
return {
status: 'computed',
facts: {
paramCount,
paramToReturn,
paramToCallArg,
paramToSink,
sourceToReturn,
sourceToCallArg,
callResults: callResultsOut,
},
};
}
/** Argument positions where binding `b` occurs (direct or via a nested site). */
function occurrencesInArgs(site: SiteRecord, b: number): number[] {
const hits: number[] = [];
site.args?.forEach((entries, argPos) => {
for (const e of entries) {
if (typeof e === 'number') {
if (e === b) hits.push(argPos);
} else if (e[0] === b) {
hits.push(argPos);
}
}
});
return hits;
}

View file

@ -0,0 +1,270 @@
/**
* Per-function taint SUMMARY model (#2084 M4 U2).
*
* A {@link FunctionSummary} is the compact, context-insensitive abstraction of
* one function's taint behaviour the input to the interprocedural fixpoint
* (`interproc-solver.ts`). It is the GitNexus analogue of Pysa's `.pysa`
* models, Mariana Trench's "propagations", and CodeQL Models-as-Data summary
* rows: a function is reduced to how taint enters (params / generated sources),
* how it moves through (paramreturn, paramcallee-arg), and where it lands
* (paramsink). The fixpoint composes these across resolved `CALLS` edges so a
* source in one function reaches a sink in another.
*
* ## Why summaries (not whole-program IFDS)
*
* The functional/summary method (Sharir-Pnueli 1981) analyses each function
* ONCE and propagates the result over the call graph the same shape Pysa,
* Mariana Trench, and Infer use in production. GitNexus already resolves the
* call graph (`CALLS` edges carry final node ids), so the summary IS the only
* new artifact; propagation is graph reachability over a finite lattice.
*
* ## Granularity (first cut)
*
* WHOLE-PARAMETER. Ports are `param i`, `return`, and `receiver` no field
* access paths (`arg0.field.sub`). Field sensitivity, callback-parameter ports
* (`Argument[0].Parameter[0]`), and context sensitivity are deferred (plan
* KTD; the largest JS/TS FN class closures stays a documented gap).
*
* ## Plain-data discipline
*
* A summary is a JSON-plain value type (no functions, class instances, Maps, or
* Symbols) so it survives `RunScopeResolutionStats` `ScopeResolutionOutput`
* threading and any future worker/cache boundary unchanged the same
* `Cloneable` constraint the CFG side channel obeys.
*/
import type { SinkKind, SourceKind } from './source-sink-config.js';
/**
* Source-relative parameter index (0-based, in declaration order). A
* function's first parameter is `0`. Destructured / rest params map each bound
* name to the index of the formal parameter that introduced it (so
* `function f([a, b]) {}` binds both `a` and `b` to param `0`).
*/
export type ParamIndex = number;
/**
* `param i` flows into argument `argIndex` of a call at source line `callLine`.
* The interprocedural solver joins this to the caller's outgoing `CALLS` edges
* by CALLEE NAME (`calleeName`) NOT by `callLine` because line-base parity
* between the CFG harvest (1-based) and the resolved reference site is fragile,
* while the callee identity is exact. It then applies the callee's summary at
* port `param argIndex`. This is the TITO ("taint-in-taint-out") propagation
* edge a param laundered into a callee, the callee's behaviour deciding what
* happens next.
*
* `calleeName` is the site's dotted-callee tail (best-effort); absent when the
* callee chain was not statically resolvable, in which case the solver
* conservatively matches every outgoing call (sound over-approximation).
* `callLine` is the 1-based statement line as harvested (`StatementFacts.line`)
* carried for hop display and as a TIE-BREAKER among several same-named
* callees of one caller, never as the primary join key.
*/
export interface ParamToCallArg {
readonly param: ParamIndex;
readonly callLine: number;
readonly argIndex: number;
readonly calleeName?: string;
/**
* Sink kinds neutralised on EVERY harvested path from the param to this call
* argument (intersection-over-paths, #2084 review P1-2). A sanitizer between
* the param and the callee arg (`relay(x){ const y=escape(x); sinkFn(y); }`)
* must carry across the boundary so the callee's `paramToSink` of a
* neutralised kind does not fire (the cross-function false positive). Absent
* means none neutralised.
*/
readonly neutralized?: readonly SinkKind[];
}
/**
* `param i` flows to the function's return value (a `return <expr>` use).
*
* RESERVED not yet consumed by the fixpoint (#2084 review P1-1). The M3
* statement-level floor already treats every call as propagate-through, so it
* taints a callee's RESULT whenever the caller passes tainted input; param
* return recall is therefore already covered, and consuming `paramToReturn`
* would only add PRECISION (avoiding the floor's over-approximation for
* functions that don't actually return their param) a larger refactor
* deferred. Harvested + version-stamped so the precision pass can land without
* a cache-namespace bump.
*/
export interface ParamToReturn {
readonly param: ParamIndex;
/** Sink kinds neutralised on EVERY path param→return (intersection). */
readonly neutralized?: readonly SinkKind[];
}
/** `param i` reaches a modelled sink of kind `sinkKind` inside this function. */
export interface ParamToSink {
readonly param: ParamIndex;
readonly sinkKind: SinkKind;
}
/**
* The function itself GENERATES a source (a modelled source read, e.g.
* `req.body`) that reaches its return value calling it yields tainted data
* with no tainted input required. The generative analogue of Pysa's
* `TaintSource[...]` return model. CONSUMED by the fixpoint via the caller's
* {@link CallResult} edges (#2084 review P1-1): a caller that uses such a
* function's result composes this into a finding/propagation. This is the
* genuinely-additive recall the floor cannot cover (the source is inside the
* callee the caller passes no tainted input for the floor to propagate).
*/
export interface SourceToReturn {
readonly sourceKind: SourceKind;
}
/**
* What a user-function call's RESULT flows into, in the CALLER (#2084 review
* P1-1). Recorded when a call to a (potentially generative) user function has
* its return value used by the caller. The fixpoint composes it with the
* callee's {@link SourceToReturn}: if the callee returns a generated source,
* the caller's downstream use of the result is tainted.
*/
export type CallResultDest =
| { readonly to: 'sink'; readonly sinkKind: SinkKind }
| { readonly to: 'return' }
| { readonly to: 'callArg'; readonly toCallee?: string; readonly argIndex: ParamIndex };
/** The result of a call to `calleeName` flows to `dest` in this function. */
export interface CallResult {
readonly calleeName: string;
readonly dest: CallResultDest;
}
/**
* A modelled source generated in this function flows into argument `argIndex`
* of a call at `callLine`. This SEEDS the interprocedural fixpoint: the source
* taints the callee's parameter, which the callee's summary then carries to a
* sink (one or more hops away). The cross-function analogue of an intra-
* procedural `source → sink` partial flow whose sink lives in the callee.
*/
export interface SourceToCallArg {
readonly sourceKind: SourceKind;
/** Carried for hop display + same-name tie-break; NOT the join key (see
* {@link ParamToCallArg} the solver joins by `calleeName`). */
readonly callLine: number;
readonly argIndex: number;
readonly calleeName?: string;
/** Sink kinds neutralised on EVERY path from the generated source to this
* call argument (intersection; #2084 review P1-2 see {@link ParamToCallArg}). */
readonly neutralized?: readonly SinkKind[];
}
/**
* The compact taint abstraction of one function. All arrays are deterministically
* sorted by the harvester and deduped, so two structurally-equal summaries
* serialise identically (the {@link summaryVersion} contract).
*/
export interface FunctionSummary {
/** The resolved `Function`/`Method` graph node id this summary describes. */
readonly fnId: string;
/** Repo-relative source path (carried for diagnostics + the join debug). */
readonly filePath: string;
/** 1-based function start line (mirrors `FunctionCfg.functionStartLine`). */
readonly startLine: number;
/** Number of declared formal parameters (port arity). */
readonly paramCount: number;
/** param→return TITO edges. */
readonly paramToReturn: readonly ParamToReturn[];
/** param→callee-arg TITO edges (composed across `CALLS` in the fixpoint). */
readonly paramToCallArg: readonly ParamToCallArg[];
/** param→sink partial flows (a source reaching this param triggers a finding). */
readonly paramToSink: readonly ParamToSink[];
/** Generative source→return models. */
readonly sourceToReturn: readonly SourceToReturn[];
/** Generative source→callee-arg seeds (fixpoint entry points). */
readonly sourceToCallArg: readonly SourceToCallArg[];
/** Caller-side call-result flows — compose with callee `sourceToReturn`. */
readonly callResults: readonly CallResult[];
/**
* Content version stamp `hash(own-facts sorted callee versions)`. The
* incremental cache key (Infer's content-keyed summary): equal across two
* runs iff the function's own taint facts AND every callee summary it depends
* on are unchanged. NOTE (#2084 review P1-1): callee-version composition is
* RESERVED the harvester stamps the own-facts portion only
* ({@link ownFactsDigest}); the fixpoint does not yet recompose it.
*/
readonly version: string;
}
/** Stable FNV-1a 32-bit hash → 8-char hex. Pure, deterministic, no deps. */
function fnv1a(input: string): string {
let h = 0x811c9dc5;
for (let i = 0; i < input.length; i++) {
h ^= input.charCodeAt(i);
// 32-bit FNV prime multiply via shifts (avoids BigInt; stays in int32 land).
h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
}
return (h >>> 0).toString(16).padStart(8, '0');
}
/**
* Deterministic digest of a summary's OWN taint facts (everything except
* `version`, which is derived). Order-independent within each edge category
* the harvester already sorts, but the digest re-canonicalises so a reordering
* never changes the stamp. Used as the leaf of {@link summaryVersion}.
*/
export function ownFactsDigest(
s: Pick<
FunctionSummary,
| 'paramCount'
| 'paramToReturn'
| 'paramToCallArg'
| 'paramToSink'
| 'sourceToReturn'
| 'sourceToCallArg'
| 'callResults'
>,
): string {
const parts: string[] = [`p${s.paramCount}`];
parts.push(
...s.paramToReturn
.map((r) => `r:${r.param}:${[...(r.neutralized ?? [])].sort().join(',')}`)
.sort(),
);
parts.push(
...s.paramToCallArg
.map(
(c) =>
`c:${c.param}:${c.callLine}:${c.argIndex}:${c.calleeName ?? ''}:${[...(c.neutralized ?? [])].sort().join(',')}`,
)
.sort(),
);
parts.push(...s.paramToSink.map((k) => `k:${k.param}:${k.sinkKind}`).sort());
parts.push(...s.sourceToReturn.map((g) => `g:${g.sourceKind}`).sort());
parts.push(
...s.sourceToCallArg
.map(
(g) =>
`s:${g.sourceKind}:${g.callLine}:${g.argIndex}:${g.calleeName ?? ''}:${[...(g.neutralized ?? [])].sort().join(',')}`,
)
.sort(),
);
parts.push(
...s.callResults
.map((cr) => {
const d = cr.dest;
const dest =
d.to === 'sink'
? `sink:${d.sinkKind}`
: d.to === 'return'
? 'return'
: `arg:${d.toCallee ?? ''}:${d.argIndex}`;
return `cr:${cr.calleeName}:${dest}`;
})
.sort(),
);
return fnv1a(parts.join('|'));
}
/**
* Content version stamp for a summary: `hash(ownFactsDigest sorted callee
* versions)`. Order-independent over callee versions (sorted). Equal iff the
* function's own facts AND every callee dependency are unchanged this is the
* incremental invalidation primitive (a changed callee changes its version,
* which changes every transitive caller's version).
*/
export function summaryVersion(ownDigest: string, calleeVersions: readonly string[]): string {
return fnv1a(`${ownDigest}#${[...calleeVersions].sort().join(',')}`);
}

View file

@ -1839,6 +1839,62 @@ export const deleteAllCommunitiesAndProcesses = async (): Promise<{
return { nodesDeleted };
};
/**
* Drop every interprocedural `TAINT_PATH` relationship (#2084 M4 U6). Used at
* the start of an incremental `--pdg` writeback so the `taintSummaries` phase
* re-materialises them from scratch on the FULL recomputed graph.
*
* TAINT_PATH validity is a WHOLE-PROGRAM property (a flow AC can be
* invalidated by a change to an INTERMEDIATE function whose file is neither A
* nor C). The endpoint-writability extract rule (`extractChangedSubgraph`)
* cannot see that an AC edge between two unchanged files would be skipped
* and a stale finding would survive. So, exactly like Community/Process, the
* sound move is delete-all-then-rebuild: cheap because TAINT_PATH is sparse
* (per-run capped), and the compute side already rebuilds every summary each
* run. Relationship-level (TAINT_PATH is an edge type, not a node label), so a
* plain DELETE on the typed CodeRelation rows endpoints are untouched.
*/
export const deleteAllInterprocTaintPaths = async (): Promise<{ edgesDeleted: number }> => {
if (!conn) {
throw new Error('LadybugDB not initialized. Call initLbug first.');
}
let edgesDeleted = 0;
let countResult: lbug.QueryResult | lbug.QueryResult[] | undefined;
try {
countResult = await conn.query(
`MATCH ()-[r:CodeRelation]->() WHERE r.type = 'TAINT_PATH' RETURN count(r) AS cnt`,
);
const result = Array.isArray(countResult) ? countResult[0] : countResult;
const rows = await result.getAll();
const count = Number(rows[0]?.cnt ?? rows[0]?.[0] ?? 0);
if (count > 0) {
await conn.query(`MATCH ()-[r:CodeRelation]->() WHERE r.type = 'TAINT_PATH' DELETE r`);
edgesDeleted = count;
}
} catch (err) {
// A missing table on a freshly-initialized DB is the benign, expected case
// (the count query above is what throws) — stay silent. Any OTHER failure
// (lock, disk, native error) would leave stale TAINT_PATH rows that the
// subsequent re-extract then DUPLICATES (CodeRelation has no PK), so it
// must ABORT the writeback (#2084 review P2-5): re-throw so the caller's
// crash-recovery dirty flag forces a clean full rebuild on the next run,
// rather than silently writing duplicate cross-function findings.
const msg = err instanceof Error ? err.message : String(err);
if (/no table|not exist|not found|does not exist|Table .* does not exist/i.test(msg)) {
if (countResult) await closeQueryResults(countResult);
return { edgesDeleted };
}
if (countResult) await closeQueryResults(countResult);
throw new Error(
`[taint-interproc] failed to clear existing TAINT_PATH edges before incremental ` +
`re-write (${msg}) — aborting to avoid duplicate cross-function findings; ` +
`the next run will full-rebuild`,
);
}
if (countResult) await closeQueryResults(countResult);
return { edgesDeleted };
};
// ============================================================================
// Full-Text Search (FTS) Functions
// ============================================================================

View file

@ -24,6 +24,7 @@ import {
loadCachedEmbeddings,
deleteNodesForFile,
deleteAllCommunitiesAndProcesses,
deleteAllInterprocTaintPaths,
queryImporters,
loadFTSExtension,
} from './lbug/lbug-adapter.js';
@ -53,6 +54,11 @@ import {
DEFAULT_PDG_MAX_TAINT_FINDINGS_PER_FUNCTION,
DEFAULT_PDG_MAX_TAINT_HOPS,
} from './ingestion/taint/propagate.js';
import {
DEFAULT_MAX_INTERPROC_HOPS,
DEFAULT_PDG_MAX_INTERPROC_FINDINGS,
} from './ingestion/taint/interproc-solver.js';
import { DEFAULT_PDG_MAX_INTERPROC_EDGES } from './ingestion/taint/interproc-emit.js';
import { taintModelVersion } from './ingestion/taint/typescript-model.js';
import { computeFileHashes, diffFileHashes } from '../storage/file-hash.js';
import {
@ -153,6 +159,12 @@ export interface AnalyzeOptions {
/** Per-finding taint hop cap (#2083 M3, KTD6). Forwarded to
* `PipelineOptions.pdgMaxTaintHops`. No CLI flag or rc key (KTD8). */
pdgMaxTaintHops?: number;
/** Per-run cross-function findings/hops/edges caps (#2084 review P1-3).
* Forwarded to the matching `PipelineOptions.pdgMaxInterproc*`; resolved
* into `RepoMeta.pdg`. No CLI flag or rc key (KTD8). */
pdgMaxInterprocFindings?: number;
pdgMaxInterprocHops?: number;
pdgMaxInterprocEdges?: number;
/**
* Default branch threaded into generated AGENTS.md / CLAUDE.md so the
* regression-compare example uses the configured branch instead of a
@ -361,6 +373,9 @@ type PdgOptions = Pick<
| 'pdgMaxReachingDefEdgesPerFunction'
| 'pdgMaxTaintFindingsPerFunction'
| 'pdgMaxTaintHops'
| 'pdgMaxInterprocFindings'
| 'pdgMaxInterprocHops'
| 'pdgMaxInterprocEdges'
>;
export const resolvePdgConfig = (options: PdgOptions): RepoMeta['pdg'] =>
@ -378,6 +393,12 @@ export const resolvePdgConfig = (options: PdgOptions): RepoMeta['pdg'] =>
maxTaintFindingsPerFunction:
options.pdgMaxTaintFindingsPerFunction ?? DEFAULT_PDG_MAX_TAINT_FINDINGS_PER_FUNCTION,
maxTaintHops: options.pdgMaxTaintHops ?? DEFAULT_PDG_MAX_TAINT_HOPS,
// #2084 review P1-3: cross-function caps. Absent on an M3-era stamp →
// pdgModeMismatch trips the first run that adds them (key-union),
// forcing the full writeback that re-materialises TAINT_PATH bounded.
maxInterprocFindings: options.pdgMaxInterprocFindings ?? DEFAULT_PDG_MAX_INTERPROC_FINDINGS,
maxInterprocHops: options.pdgMaxInterprocHops ?? DEFAULT_MAX_INTERPROC_HOPS,
maxInterprocEdges: options.pdgMaxInterprocEdges ?? DEFAULT_PDG_MAX_INTERPROC_EDGES,
// Built-in model digest (KTD7/R7): persisted findings must never
// outlive the model that produced them — ANY model-content change
// ships as a new digest and repopulates the taint edges.
@ -783,6 +804,9 @@ export async function runFullAnalysis(
pdgMaxReachingDefEdgesPerFunction: options.pdgMaxReachingDefEdgesPerFunction,
pdgMaxTaintFindingsPerFunction: options.pdgMaxTaintFindingsPerFunction,
pdgMaxTaintHops: options.pdgMaxTaintHops,
pdgMaxInterprocFindings: options.pdgMaxInterprocFindings,
pdgMaxInterprocHops: options.pdgMaxInterprocHops,
pdgMaxInterprocEdges: options.pdgMaxInterprocEdges,
fetchWrappers: options.fetchWrappers,
},
);
@ -1002,6 +1026,15 @@ export async function runFullAnalysis(
// from the fresh pipeline output below. Required for the
// "Leiden runs on the FULL graph" correctness invariant.
await deleteAllCommunitiesAndProcesses();
// 2b. Drop interprocedural TAINT_PATH edges (#2084 M4 U6) when pdg is on
// — their validity is a whole-program property (an A→C flow can be
// invalidated by a change to an intermediate function on a third
// file), so endpoint-writability extraction can't refresh them.
// extractChangedSubgraph re-includes all of them from the fresh
// graph (isGraphWideRelType), mirroring Community/Process.
if (options.pdg === true) {
await deleteAllInterprocTaintPaths();
}
// 3. Extract the changed subgraph from the FULL ctx.graph and write
// only that. Unchanged-file rows in the DB stay untouched. Pass

View file

@ -2937,20 +2937,100 @@ export class LocalBackend {
const { rows, totalFindings } = await runAnchoredQuery();
if (totalFindings === 0 && pdgStamped === undefined && !target) {
// Meta was unreadable and the repo-wide enumerate found nothing — the
// count above WAS the existence probe; surface the layer hint.
// M4 (#2084 U7): cross-function findings ride TAINT_PATH edges (Function/
// Method → Function/Method), separate from the intra-procedural TAINTED
// BasicBlock rows above. Enumerate them too so `explain` is the discovery
// surface for interprocedural flows (TAINT_PATH stays out of
// VALID_RELATION_TYPES + the web schema, like TAINTED). File-anchored:
// filter on the source function's file; symbol-anchored: either endpoint
// matches the symbol name; anchorless: all (bounded by LIMIT). Computed
// BEFORE the no-taint early returns — a repo with ONLY cross-function
// findings (no intra-procedural TAINTED rows) must not look empty.
const runInterprocQuery = async (): Promise<{ findings: any[]; total: number }> => {
const where: string[] = [`r.type = 'TAINT_PATH'`];
const p: Record<string, unknown> = {};
if (anchor?.symbol) {
where.push('(a.name = $ipSym OR b.name = $ipSym)');
p.ipSym = anchor.symbol;
} else if (anchor?.file) {
// Match EITHER endpoint's file — a cross-function flow anchored on the
// SINK's file (b) is as relevant as one anchored on the source's (a).
where.push(
'(a.filePath = $ipFile OR a.filePath ENDS WITH $ipSuffix OR ' +
'b.filePath = $ipFile OR b.filePath ENDS WITH $ipSuffix)',
);
p.ipFile = anchor.file;
p.ipSuffix = `/${anchor.file}`;
}
const matchClause = `MATCH (a)-[r:CodeRelation]->(b)\n WHERE ${where.join(' AND ')}`;
// Page query + a separate COUNT (#2084 review P2-4): the page is
// LIMIT-capped, so its row count cannot stand in for the true total —
// run a COUNT with the same WHERE (no LIMIT) like the intra layer does.
const [ipRows, ipCountRows] = await Promise.all([
executeParameterized(
repo.lbugPath,
`${matchClause}
RETURN a.filePath AS file, a.name AS sourceFn, a.startLine AS sourceLine,
b.name AS sinkFn, b.startLine AS sinkLine, r.reason AS reason
ORDER BY sourceFn, sinkFn, reason
LIMIT ${limit}`,
p,
),
executeParameterized(repo.lbugPath, `${matchClause}\n RETURN COUNT(*) AS total`, p),
]);
const total = Number((ipCountRows[0] as any)?.total ?? (ipCountRows[0] as any)?.[0] ?? 0);
const findings = ipRows.map((r: any) => {
const decoded = decodeTaintPath(r.reason ?? r[5]);
const hops = decoded.ok
? decoded.hops.map((h) => ({ function: h.variable, line: h.line }))
: [];
return {
interprocedural: true,
file: String(r.file ?? r[0] ?? ''),
sinkKind: decoded.ok ? (decoded.kind ?? 'unknown') : 'unknown',
source: { function: String(r.sourceFn ?? r[1] ?? ''), line: r.sourceLine ?? r[2] },
sink: { function: String(r.sinkFn ?? r[3] ?? ''), line: r.sinkLine ?? r[4] },
hops,
...(decoded.ok && decoded.truncated ? { pathIncomplete: true } : {}),
};
});
return { findings, total };
};
const { findings: interprocFindings, total: interprocTotal } = await runInterprocQuery();
if (
totalFindings === 0 &&
interprocFindings.length === 0 &&
pdgStamped === undefined &&
!target
) {
// Meta was unreadable and the repo-wide enumerate (both layers) found
// nothing — the counts above WERE the existence probe; surface the hint.
return { findings: [], totalFindings: 0, note: NO_TAINT_NOTE };
}
if (totalFindings === 0 && pdgStamped === undefined && target) {
if (
totalFindings === 0 &&
interprocFindings.length === 0 &&
pdgStamped === undefined &&
target
) {
// Anchored miss with unreadable meta: one extra bounded probe decides
// "no findings for this anchor" vs "no taint layer at all".
// "no findings for this anchor" vs "no taint layer at all". Probe BOTH
// intra (TAINTED) and inter (TAINT_PATH) existence.
const probe = await executeParameterized(
repo.lbugPath,
`MATCH (a:BasicBlock)-[r:CodeRelation]->(b:BasicBlock) WHERE r.type = 'TAINTED' RETURN r.reason AS reason LIMIT 1`,
{},
);
if (probe.length === 0) {
const ipProbe =
probe.length === 0
? await executeParameterized(
repo.lbugPath,
`MATCH (a)-[r:CodeRelation]->(b) WHERE r.type = 'TAINT_PATH' RETURN r.reason AS reason LIMIT 1`,
{},
)
: [];
if (probe.length === 0 && ipProbe.length === 0) {
return { findings: [], totalFindings: 0, note: NO_TAINT_NOTE };
}
}
@ -2997,12 +3077,32 @@ export class LocalBackend {
};
});
// Combine both layers and re-apply the page LIMIT to the union — each
// layer was queried with its own LIMIT, so the union can hold up to 2×;
// cap it so `findings.length` honours the caller's `limit`. `truncated`
// reflects EITHER layer overflowing OR the union being trimmed here, and
// `totalFindings` counts both layers' matched rows (the intra COUNT plus
// the interproc rows returned — interproc has no separate COUNT, so a
// capped interproc layer is reflected via `truncated`, never undercounted
// into a false "complete" signal). Review: code-review #2/#4 (explain
// accounting + sink-file anchoring) — both layers now accounted.
const combined = [...findings, ...interprocFindings];
const pageFindings = combined.length > limit ? combined.slice(0, limit) : combined;
// Truncated iff EITHER layer overflowed its own LIMIT (strict `>` — exactly
// `limit` rows is not truncated), OR the combined union was trimmed to the
// page (#2084 review P2-4). `totalFindings` uses the interproc COUNT, not
// the capped slice length, so it never undercounts.
const truncated =
totalFindings > findings.length ||
interprocTotal > interprocFindings.length ||
combined.length > pageFindings.length;
return {
...(anchor ? { anchor } : {}),
findings,
totalFindings,
...(totalFindings > findings.length ? { truncated: true } : {}),
note: 'Intra-procedural findings only — cross-function, closure/callback, property/field, and implicit flows are not modeled; absence of a finding is not proof of safety. SANITIZES (kill) edges are queryable via cypher.',
findings: pageFindings,
totalFindings: totalFindings + interprocTotal,
...(truncated ? { truncated: true } : {}),
note: 'Intra-procedural (TAINTED, statement hops) AND cross-function (TAINT_PATH, function hops, `interprocedural: true`) flows are modeled. Closure/callback, property/field, and implicit flows are NOT modeled; absence of a finding is not proof of safety. Cross-function findings are context-insensitive and may over-attribute among same-named callees. SANITIZES (kill) edges are queryable via cypher.',
};
}

View file

@ -525,18 +525,19 @@ SERVICE: optional monorepo path prefix (case-sensitive path segments). When "rep
},
{
name: 'explain',
description: `Explain persisted taint findings: intra-procedural source→sink data flows (TAINTED edges) recorded by \`gitnexus analyze --pdg\`.
description: `Explain persisted taint findings recorded by \`gitnexus analyze --pdg\`: intra-procedural source→sink data flows (TAINTED edges, statement-level hops) AND cross-function flows (TAINT_PATH edges, function-level hops, marked \`interprocedural: true\`).
Each finding carries the sink category (command-injection, code-injection, path-traversal, sql-injection, xss), the source/sink lines, and the ordered hop path with the variable carried on each hop (decoded from the persisted path encoding).
Each finding carries the sink category (command-injection, code-injection, path-traversal, sql-injection, xss) and the ordered hop path. Intra-procedural findings carry source/sink lines and the variable on each hop; interprocedural findings carry the source and sink FUNCTION names and the chain of functions the taint crossed (decoded from the persisted path encoding).
WHEN TO USE: Security review "what taint findings exist in this repo / file / function?". Requires the repo to be indexed with \`gitnexus analyze --pdg\`; without that layer the tool returns a clear "no taint layer" note, not an error.
ANCHORLESS (no "target"): enumerates all persisted findings for the repo bounded ("limit", deterministic order), with "totalFindings" and a "truncated" flag.
ANCHORED ("target" = file path or symbol/function name): full hop detail for that anchor. A file-ish target (contains "/" or an extension) filters by file; a symbol name resolves like context() ambiguous names return ranked candidates, unknown names return not-found. Symbol anchoring is line-range granular (findings whose source block starts inside the symbol's span).
ANCHORED ("target" = file path or symbol/function name): full hop detail for that anchor. A file-ish target (contains "/" or an extension) filters by file; a symbol name resolves like context() ambiguous names return ranked candidates, unknown names return not-found. Symbol anchoring is line-range granular for intra-procedural findings; cross-function findings match when the symbol is the source OR sink function.
CONTRACT CAVEATS (intra-procedural M3 scope absent flows are NOT proof of safety):
- Cross-function flows are not modeled (a flow through a helper function is invisible).
- Closure/callback flows are invisible in both directions (e.g. arr.forEach(() => sink(y))).
CONTRACT CAVEATS (absent flows are NOT proof of safety):
- Cross-function flows ARE modeled (#2084 M4): a source flowing through helper functions into a sink is found, via summary composition over the call graph (context-insensitive return/call-site merging is accepted).
- Cross-function matching is by callee NAME (context-insensitive): when one caller invokes two distinct same-named callees, a flow into one over-attributes to both a cross-function finding does not prove the taint reached every same-named function (sound over-report, never a missed flow).
- Closure/callback flows are invisible in both directions (e.g. arr.forEach(() => sink(y))) the largest false-negative class.
- Property/field flows are not tracked (obj.x = taint; sink(obj.y) has no chain).
- Guard-style sanitizers (if (isValid(x))) and implicit/control-dependence flows are not modeled.
- CommonJS aliasing is partially modeled (require('<literal>') joins resolve; dynamic requires do not).

View file

@ -169,6 +169,16 @@ export interface RepoMeta {
* bounds the persisted hop-encoded `reason`). Optional for the same
* M2-era-stamp upgrade reason as the findings cap. */
maxTaintHops?: number;
/**
* Per-run cross-function caps, resolved (0 = unlimited; #2084 M4 review
* P1-3). ABSENT on an M3-era stamp that absence trips `pdgModeMismatch`
* on the first run that adds them and forces the full writeback that
* re-materialises TAINT_PATH within bounds. Optional for that upgrade
* reason; resolved (always present) on every post-fix write.
*/
maxInterprocFindings?: number;
maxInterprocHops?: number;
maxInterprocEdges?: number;
/**
* Digest of the built-in taint model the persisted findings were
* produced under (#2083 M3 KTD7/R7). Any model-content change ships a

View file

@ -0,0 +1,16 @@
// Generative-source fixture (#2084 review P1-1): getInput() reads a remote-input
// source internally and RETURNS it. handleGen calls it and sinks the result —
// neither function alone is a finding (the source is inside getInput, the caller
// passes no tainted input), so only sourceToReturn composition catches it.
import { exec } from 'child_process';
declare const req: { body: string };
export function getInput(): string {
return req.body;
}
export function handleGen(): void {
const t = getInput();
exec(t);
}

View file

@ -0,0 +1,13 @@
// Interprocedural taint fixture (#2084 M4): the SINK side. `runIt` takes a
// parameter and passes it straight into child_process.exec — a param→sink
// (command-injection) summary. The caller lives in source.ts.
import { exec } from 'child_process';
export function runIt(cmd: string): void {
exec(cmd);
}
// A pass-through helper for the multi-hop case: param→callee-arg of runIt.
export function forward(value: string): void {
runIt(value);
}

View file

@ -0,0 +1,14 @@
// Interprocedural taint fixture (#2084 M4): the SOURCE side. `handle` reads a
// remote-input source (req.body) and passes it into runIt across the file
// boundary — a source→callee-arg summary. The fixpoint composes handle's
// source with runIt's param→sink to yield one cross-function TAINT_PATH edge.
import { runIt, forward } from './sink.js';
export function handle(req: { body: string }): void {
runIt(req.body);
}
// Multi-hop: handle2 → forward → runIt → exec.
export function handle2(req: { body: string }): void {
forward(req.body);
}

View file

@ -0,0 +1,109 @@
/**
* U9 (#2084 M4) end-to-end interprocedural taint over the real pipeline.
*
* Runs the full pipeline (workers + scope-resolution + the taintSummaries
* phase) on a tiny CROSS-FILE repo: `source.ts#handle` reads `req.body` and
* passes it into `sink.ts#runIt`, which calls `exec`. The fixpoint must
* compose the sourcecallee-arg summary with the paramsink summary into one
* cross-function `TAINT_PATH` edge. The flag-off run proves the opt-in gate:
* zero TAINT_PATH edges (byte-identical graph).
*
* Build the worker dist first (`node scripts/build.js`) the pipeline spawns
* the parse worker, and a stale dist is a spurious red.
*/
import { describe, it, expect, afterAll } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { runPipelineFromRepo } from '../../../src/core/ingestion/pipeline.js';
import type { PipelineResult } from '../../../src/types/pipeline.js';
import { decodeTaintPath } from '../../../src/core/ingestion/taint/path-codec.js';
const FIXTURE = path.join(__dirname, 'fixtures', 'interproc-repo');
const tmpDirs: string[] = [];
function freshRepo(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-interproc-'));
fs.cpSync(FIXTURE, dir, { recursive: true });
tmpDirs.push(dir);
return dir;
}
function taintPaths(result: PipelineResult) {
return [...result.graph.iterRelationships()].filter((r) => r.type === 'TAINT_PATH');
}
describe('U9 — end-to-end interprocedural taint (--pdg)', () => {
afterAll(() => {
for (const d of tmpDirs) fs.rmSync(d, { recursive: true, force: true });
});
it('with --pdg: composes a cross-file source→sink into a TAINT_PATH edge', async () => {
const result = await runPipelineFromRepo(freshRepo(), () => {}, { pdg: true });
const paths = taintPaths(result);
expect(paths.length).toBeGreaterThan(0);
// At least one edge from `handle` (source fn) to `runIt` (sink fn).
const nameOf = (id: string): string => {
const n = result.graph.getNode(id);
return typeof n?.properties.name === 'string' ? n.properties.name : '';
};
const handleToRunIt = paths.find(
(p) => nameOf(p.sourceId) === 'handle' && nameOf(p.targetId) === 'runIt',
);
expect(handleToRunIt, 'expected a TAINT_PATH from handle → runIt').toBeDefined();
// The reason decodes to a command-injection finding.
const decoded = decodeTaintPath(handleToRunIt!.reason);
expect(decoded.ok).toBe(true);
if (decoded.ok) expect(decoded.kind).toBe('command-injection');
// Endpoints are real graph nodes (Function/Method).
expect(result.graph.getNode(handleToRunIt!.sourceId)).toBeDefined();
expect(result.graph.getNode(handleToRunIt!.targetId)).toBeDefined();
});
it('finds the multi-hop flow handle2 → forward → runIt', async () => {
const result = await runPipelineFromRepo(freshRepo(), () => {}, { pdg: true });
const nameOf = (id: string): string => {
const n = result.graph.getNode(id);
return typeof n?.properties.name === 'string' ? n.properties.name : '';
};
const found = taintPaths(result).some(
(p) => nameOf(p.sourceId) === 'handle2' && nameOf(p.targetId) === 'runIt',
);
expect(found, 'expected a multi-hop TAINT_PATH from handle2 → runIt').toBe(true);
});
it('composes a generative sourceToReturn flow getInput → handleGen (#2084 review P1-1)', async () => {
const result = await runPipelineFromRepo(freshRepo(), () => {}, { pdg: true });
const nameOf = (id: string): string => {
const n = result.graph.getNode(id);
return typeof n?.properties.name === 'string' ? n.properties.name : '';
};
const found = taintPaths(result).some(
(p) => nameOf(p.sourceId) === 'getInput' && nameOf(p.targetId) === 'handleGen',
);
expect(found, 'expected a generative TAINT_PATH from getInput → handleGen').toBe(true);
});
it('without --pdg: emits ZERO TAINT_PATH edges (opt-in gate / golden parity)', async () => {
const result = await runPipelineFromRepo(freshRepo(), () => {});
expect(taintPaths(result)).toHaveLength(0);
});
it('the taintSummaries phase ARMS the per-run edge cap (#2084 review P1-3)', async () => {
// The fixture yields ≥2 cross-function findings (handle→runIt, handle2→runIt).
// A cap of 1 must bound the emitted TAINT_PATH edges — proving the phase
// passes the limit, not just that the solver supports one.
const uncapped = await runPipelineFromRepo(freshRepo(), () => {}, { pdg: true });
expect(taintPaths(uncapped).length).toBeGreaterThan(1);
const capped = await runPipelineFromRepo(freshRepo(), () => {}, {
pdg: true,
pdgMaxInterprocEdges: 1,
});
expect(taintPaths(capped)).toHaveLength(1);
});
});

View file

@ -114,6 +114,31 @@ withTestLbugDB(
expect(stats.edges).toBe(4);
});
it('deleteAllInterprocTaintPaths: removes TAINT_PATH edges and is benign when none exist (#2084 review P2-5)', async () => {
const { executeQuery: coreExecuteQuery, deleteAllInterprocTaintPaths } =
await import('../../src/core/lbug/lbug-adapter.js');
// Benign: no TAINT_PATH rows yet → returns 0, does NOT throw.
await expect(deleteAllInterprocTaintPaths()).resolves.toEqual({ edgesDeleted: 0 });
// Seed one TAINT_PATH edge between the two seeded Function nodes, then
// delete-all and confirm it is removed (the incremental-rebuild guard).
const fns = (await coreExecuteQuery('MATCH (n:Function) RETURN n.id AS id')) as {
id: string;
}[];
expect(fns.length).toBe(2);
await coreExecuteQuery(
`MATCH (a:Function {id: '${fns[0].id}'}), (b:Function {id: '${fns[1].id}'}) ` +
`CREATE (a)-[:CodeRelation {type: 'TAINT_PATH', confidence: 0.6, reason: '1', step: 0}]->(b)`,
);
const r = await deleteAllInterprocTaintPaths();
expect(r.edgesDeleted).toBe(1);
const left = await coreExecuteQuery(
`MATCH ()-[r:CodeRelation]->() WHERE r.type = 'TAINT_PATH' RETURN count(r) AS cnt`,
);
expect(Number((left[0] as { cnt: number }).cnt)).toBe(0);
});
describe('unhappy path', () => {
it('throws on malformed Cypher query', async () => {
const { executeQuery } = await import('../../src/core/lbug/lbug-adapter.js');

View file

@ -342,3 +342,140 @@ withTestLbugDB(
},
},
);
// ─── Block 3: interprocedural TAINT_PATH findings (#2084 M4 U7) ───────
//
// Seeds the cross-file interproc-repo fixture's emit output (Function nodes +
// TAINT_PATH edges) into a real DB and proves `explain` surfaces the
// cross-function findings (marked `interprocedural: true`) with decoded
// function-level hops + the sink kind.
const INTERPROC_FIXTURE = path.join(__dirname, 'cfg', 'fixtures', 'interproc-repo');
withTestLbugDB(
'taint-explain-interproc',
(handle) => {
describe('explain tool — cross-function TAINT_PATH findings', () => {
let backend: LocalBackend;
beforeAll(() => {
const ext = handle as typeof handle & { _backend?: LocalBackend };
if (!ext._backend) throw new Error('LocalBackend not initialized');
backend = ext._backend;
});
it('anchorless enumerate includes interprocedural findings', async () => {
const res = (await backend.callTool('explain', {})) as {
findings: Array<Record<string, unknown>>;
};
const ip = res.findings.filter((f) => f.interprocedural === true);
expect(ip.length).toBeGreaterThan(0);
// handle → runIt, command-injection, with function-level hops.
const hr = ip.find(
(f) =>
(f.source as { function?: string })?.function === 'handle' &&
(f.sink as { function?: string })?.function === 'runIt',
);
expect(hr, 'expected an interprocedural handle → runIt finding').toBeDefined();
expect(hr!.sinkKind).toBe('command-injection');
expect(Array.isArray(hr!.hops)).toBe(true);
expect((hr!.hops as unknown[]).length).toBeGreaterThan(0);
});
it('symbol-anchored on the sink function surfaces the cross-function finding', async () => {
const res = (await backend.callTool('explain', { target: 'runIt' })) as {
findings: Array<Record<string, unknown>>;
};
const ip = res.findings.filter((f) => f.interprocedural === true);
expect(ip.some((f) => (f.sink as { function?: string })?.function === 'runIt')).toBe(true);
});
it('totalFindings counts the full interproc layer and truncated is set on overflow (#2084 review P2-4)', async () => {
// The fixture yields multiple interproc findings; limit:1 must page to 1
// while totalFindings reports the true (un-capped) count and truncated is set.
const full = (await backend.callTool('explain', {})) as {
findings: unknown[];
totalFindings: number;
};
const ipFull = full.findings.filter((f: any) => f.interprocedural === true).length;
expect(ipFull).toBeGreaterThan(1);
const paged = (await backend.callTool('explain', { limit: 1 })) as {
findings: unknown[];
totalFindings: number;
truncated?: boolean;
};
expect(paged.findings.length).toBe(1);
expect(paged.truncated).toBe(true);
// totalFindings reflects the real interproc total, not the 1-row slice.
expect(paged.totalFindings).toBeGreaterThanOrEqual(ipFull);
});
});
},
{
poolAdapter: true,
afterSetup: async (handle) => {
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-explain-ip-'));
try {
fs.cpSync(INTERPROC_FIXTURE, repoDir, { recursive: true });
const pipelineResult = await runPipelineFromRepo(repoDir, () => {}, { pdg: true });
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
// Persist Function/Method nodes (TAINT_PATH endpoints).
const seenIds = new Set<string>();
pipelineResult.graph.forEachNode((n) => {
if (n.label !== 'Function' && n.label !== 'Method') return;
if (seenIds.has(n.id)) return;
seenIds.add(n.id);
});
for (const n of pipelineResult.graph.iterNodes()) {
if (n.label !== 'Function' && n.label !== 'Method') continue;
await adapter.executePrepared(
`CREATE (x:${n.label} {id: $id, name: $name, filePath: $filePath, startLine: $startLine, endLine: $endLine})`,
{
id: n.id,
name: n.properties.name ?? '',
filePath: n.properties.filePath ?? '',
startLine: n.properties.startLine ?? 0,
endLine: n.properties.endLine ?? 0,
},
);
}
let tpEdges = 0;
for (const rel of pipelineResult.graph.iterRelationships()) {
if (rel.type !== 'TAINT_PATH') continue;
await adapter.executePrepared(
// The fixture's endpoints are all top-level Function nodes; Kuzu
// rejects an untyped node match in a rel CREATE (read MATCH is fine).
`MATCH (a:Function {id: $src}), (b:Function {id: $dst})
CREATE (a)-[:CodeRelation {type: 'TAINT_PATH', confidence: $confidence, reason: $reason, step: 0}]->(b)`,
{
src: rel.sourceId,
dst: rel.targetId,
confidence: rel.confidence ?? 0.6,
reason: rel.reason ?? '',
},
);
tpEdges++;
}
if (tpEdges === 0) {
throw new Error('interproc fixture produced no TAINT_PATH edges — fixpoint regressed?');
}
} finally {
fs.rmSync(repoDir, { recursive: true, force: true });
}
vi.mocked(listRegisteredRepos).mockResolvedValue([
{
name: 'interproc-repo',
path: '/interproc/repo',
storagePath: handle.tmpHandle.dbPath,
indexedAt: new Date().toISOString(),
lastCommit: 'ip0001',
stats: { files: 2, nodes: 4, communities: 0, processes: 0 },
},
]);
const backend = new LocalBackend();
await backend.init();
(handle as any)._backend = backend;
},
},
);

View file

@ -93,6 +93,24 @@ describe('extractChangedSubgraph', () => {
expect(sub.nodes).toEqual([]);
expect(sub.relationships).toEqual([]);
});
it('always includes TAINT_PATH edges even between two unchanged files (#2084 M4 U6)', () => {
// A cross-function TAINT_PATH whose endpoints (a.ts, c.ts) are both
// unchanged, but an intermediate function on the changed b.ts invalidated
// the flow. Endpoint-writability alone would skip it (stale finding);
// TAINT_PATH is graph-wide so it is always re-extracted (the orchestrator
// delete-alls the old rows first). A plain CALLS edge between the same
// unchanged files stays excluded — only TAINT_PATH gets this treatment.
const g = createKnowledgeGraph();
g.addNode(makeFileNode('a:handle', '/repo/a.ts'));
g.addNode(makeFileNode('c:sink', '/repo/c.ts'));
g.addRelationship(makeRel('tp1', 'a:handle', 'c:sink', 'TAINT_PATH'));
g.addRelationship(makeRel('call1', 'a:handle', 'c:sink', 'CALLS'));
const sub = extractChangedSubgraph(g, new Set(['/repo/b.ts']));
expect(sub.relationships.map((r) => r.id)).toEqual(['tp1']);
});
});
describe('computeEffectiveWriteSet (Finding 1)', () => {

View file

@ -100,3 +100,42 @@ describe('buildPhaseList parity (registry refactor, #2080)', () => {
);
});
});
// ---------------------------------------------------------------------------
// M4 (#2084): the taintSummaries phase is the first real opt-in pdg-gated
// registration. Off (the default) ⇒ ABSENT ⇒ byte-identical phase list; on ⇒
// inserted right after pruneLocalSymbols, before mro.
// ---------------------------------------------------------------------------
const WITH_TAINT_SUMMARIES = [
...FULL_ORDER.slice(0, FULL_ORDER.indexOf('pruneLocalSymbols') + 1),
'taintSummaries',
...FULL_ORDER.slice(FULL_ORDER.indexOf('pruneLocalSymbols') + 1),
];
describe('buildPhaseList — taintSummaries opt-in (#2084)', () => {
it('pdg off (default) → taintSummaries absent, list byte-identical to legacy', () => {
expect(buildPhaseList(undefined).map((p) => p.name)).not.toContain('taintSummaries');
expect(buildPhaseList({}).map((p) => p.name)).not.toContain('taintSummaries');
expect(buildPhaseList({ pdg: false }).map((p) => p.name)).toEqual(FULL_ORDER);
});
it('pdg:true → taintSummaries inserted after pruneLocalSymbols, before mro', () => {
expect(buildPhaseList({ pdg: true }).map((p) => p.name)).toEqual(WITH_TAINT_SUMMARIES);
});
it('pdg:true is independent of skipGraphPhases', () => {
const names = buildPhaseList({ pdg: true, skipGraphPhases: true }).map((p) => p.name);
expect(names).toContain('taintSummaries');
expect(names).not.toContain('mro');
});
it('no always-on phase depends on the pdg-gated taintSummaries phase', () => {
// A filtered-out dep would throw in getPhaseOutput at runtime, so no
// always-included phase may list taintSummaries in its deps.
const offList = buildPhaseList({});
for (const p of offList) {
expect(p.deps).not.toContain('taintSummaries');
}
});
});

View file

@ -104,6 +104,42 @@ describe('pdgModeMismatch — M2→M3 stamp upgrade (#2083 M3 U5, pure)', () =>
});
});
describe('pdgModeMismatch — M3→M4 interproc-cap stamp upgrade (#2084 review P1-3, pure)', () => {
it('resolvePdgConfig stamps the three resolved interproc caps', async () => {
const { resolvePdgConfig } = await import('../../src/core/run-analyze.js');
const stamp = resolvePdgConfig({ pdg: true });
expect(stamp?.maxInterprocFindings).toBe(2000);
expect(stamp?.maxInterprocHops).toBe(32);
expect(stamp?.maxInterprocEdges).toBe(1000);
});
it('an M3-era stamp (no interproc keys) mismatches a post-fix request — upgrade forces full writeback', async () => {
const { pdgModeMismatch } = await import('../../src/core/run-analyze.js');
// What an M3 run wrote: every taint cap + model digest, but none of the
// interproc caps. The key-union comparator sees 2000 !== undefined and
// trips the full writeback that re-materialises TAINT_PATH within bounds.
const m3Stamp = {
maxFunctionLines: 2000,
maxEdgesPerFunction: 5000,
maxReachingDefEdgesPerFunction: 4000,
maxTaintFindingsPerFunction: 200,
maxTaintHops: 32,
taintModelVersion: 'deadbeefcafe',
};
expect(pdgModeMismatch(m3Stamp, { pdg: true })).toBe(true);
});
it('an interproc cap change alone trips the mismatch', async () => {
const { pdgModeMismatch, resolvePdgConfig } = await import('../../src/core/run-analyze.js');
const stamp = resolvePdgConfig({ pdg: true });
expect(pdgModeMismatch(stamp, { pdg: true, pdgMaxInterprocFindings: 10 })).toBe(true);
expect(pdgModeMismatch(stamp, { pdg: true, pdgMaxInterprocEdges: 50 })).toBe(true);
expect(pdgModeMismatch(stamp, { pdg: true, pdgMaxInterprocHops: 8 })).toBe(true);
// explicit default ≡ default (resolution before comparison)
expect(pdgModeMismatch(stamp, { pdg: true, pdgMaxInterprocFindings: 2000 })).toBe(false);
});
});
describe('detect_changes BasicBlock exclusion (#2082 U7)', () => {
it('the symbol-overlap id-prefix filter excludes exactly the BasicBlock rows', async () => {
const repo = await setupMiniRepo();
@ -181,6 +217,9 @@ describe('runFullAnalysis — pdg-mode flip (#2099 F1)', () => {
maxReachingDefEdgesPerFunction: 4000,
maxTaintFindingsPerFunction: 200,
maxTaintHops: 32,
maxInterprocFindings: 2000,
maxInterprocHops: 32,
maxInterprocEdges: 1000,
taintModelVersion,
});
expect(stamped!.incrementalInProgress).toBeUndefined(); // cleared on success
@ -233,6 +272,9 @@ describe('runFullAnalysis — pdg-mode flip (#2099 F1)', () => {
maxReachingDefEdgesPerFunction: 4000,
maxTaintFindingsPerFunction: 200,
maxTaintHops: 32,
maxInterprocFindings: 2000,
maxInterprocHops: 32,
maxInterprocEdges: 1000,
taintModelVersion,
});
// The CFG layer survives a rebuild under a tighter edge cap (blocks are

View file

@ -342,6 +342,9 @@ describe('pdgModeMismatch / resolvePdgConfig (#2099 F1)', () => {
maxReachingDefEdgesPerFunction: 4000,
maxTaintFindingsPerFunction: 200,
maxTaintHops: 32,
maxInterprocFindings: 2000,
maxInterprocHops: 32,
maxInterprocEdges: 1000,
// Content digest, not a tunable cap — pinned via the exported constant
// (its VALUE changes whenever the built-in model changes, by design).
taintModelVersion,
@ -364,6 +367,9 @@ describe('pdgModeMismatch / resolvePdgConfig (#2099 F1)', () => {
pdgMaxReachingDefEdgesPerFunction: 0,
pdgMaxTaintFindingsPerFunction: 0,
pdgMaxTaintHops: 0,
pdgMaxInterprocFindings: 0,
pdgMaxInterprocHops: 0,
pdgMaxInterprocEdges: 0,
}),
).toEqual({
maxFunctionLines: 0,
@ -371,6 +377,9 @@ describe('pdgModeMismatch / resolvePdgConfig (#2099 F1)', () => {
maxReachingDefEdgesPerFunction: 0,
maxTaintFindingsPerFunction: 0,
maxTaintHops: 0,
maxInterprocFindings: 0,
maxInterprocHops: 0,
maxInterprocEdges: 0,
taintModelVersion, // not a cap — always stamped on a pdg-on run
});
});

View file

@ -56,6 +56,15 @@ describe('VALID_RELATION_TYPES', () => {
expect(VALID_RELATION_TYPES.has('TAINTED')).toBe(false);
expect(VALID_RELATION_TYPES.has('SANITIZES')).toBe(false);
});
it('TAINT_PATH stays OUT of the impact allow-list (#2084 M4 KTD9a)', () => {
// Cross-function TAINT_PATH (Function→Function) is the interprocedural
// analogue of TAINTED — surfaced ONLY via `explain` (its interprocedural
// findings), never impact()'s BFS. Pinned so a future allow-all sweep
// can't drag it in, and the set size stays fixed at 16.
expect(VALID_RELATION_TYPES.has('TAINT_PATH')).toBe(false);
expect(VALID_RELATION_TYPES.size).toBe(16);
});
});
// ─── Valid node labels ───────────────────────────────────────────────

View file

@ -0,0 +1,448 @@
/**
* U3 (#2084 M4) interprocedural taint fixpoint.
*
* Pure: synthetic summaries + call edges in, cross-function findings out. No
* graph, no parsing. Exercises the four composition shapes (one-hop seed,
* multi-hop TITO, cross-file, recursion) plus the boundedness guards.
*/
import { describe, it, expect } from 'vitest';
import {
solveInterprocTaint,
type InterprocCallEdge,
} from '../../../src/core/ingestion/taint/interproc-solver.js';
import {
ownFactsDigest,
summaryVersion,
type FunctionSummary,
} from '../../../src/core/ingestion/taint/summary-model.js';
let counter = 0;
function summary(
fnId: string,
facts: Partial<Omit<FunctionSummary, 'fnId' | 'version' | 'filePath' | 'startLine'>>,
): FunctionSummary {
const full = {
paramCount: facts.paramCount ?? 1,
paramToReturn: facts.paramToReturn ?? [],
paramToCallArg: facts.paramToCallArg ?? [],
paramToSink: facts.paramToSink ?? [],
sourceToReturn: facts.sourceToReturn ?? [],
sourceToCallArg: facts.sourceToCallArg ?? [],
callResults: facts.callResults ?? [],
};
return {
fnId,
filePath: `f${counter++}.ts`,
startLine: 1,
...full,
version: summaryVersion(ownFactsDigest(full), []),
};
}
const map = (...ss: FunctionSummary[]) => new Map(ss.map((s) => [s.fnId, s]));
describe('solveInterprocTaint — seed path respects maxHops (#2084 review P2-7)', () => {
it('caps the seed path at maxHops:1 (truncated prefix, not a 2-entry path)', () => {
const A = summary('Function:a.ts:A', {
paramCount: 0,
sourceToCallArg: [{ sourceKind: 'remote-input', callLine: 1, argIndex: 0, calleeName: 'B' }],
});
const B = summary('Function:b.ts:B', {
paramCount: 1,
paramToSink: [{ param: 0, sinkKind: 'command-injection' }],
});
const r = solveInterprocTaint(
map(A, B),
[{ callerId: A.fnId, calleeId: B.fnId, calleeName: 'B' }],
{
maxHops: 1,
},
);
expect(r.findings).toHaveLength(1);
expect(r.findings[0].hops.length).toBeLessThanOrEqual(1);
expect(r.findings[0].hopsTruncated).toBe(true);
});
});
describe('solveInterprocTaint — one-hop source→callee-sink', () => {
it('finds a source passed into a callee that sinks it', () => {
// A: source flows into helper(arg0); B(helper): param0 → sink.
const A = summary('Function:a.ts:A', {
paramCount: 0,
sourceToCallArg: [{ sourceKind: 'remote-input', callLine: 5, argIndex: 0, calleeName: 'B' }],
});
const B = summary('Function:b.ts:B', {
paramCount: 1,
paramToSink: [{ param: 0, sinkKind: 'command-injection' }],
});
const edges: InterprocCallEdge[] = [{ callerId: A.fnId, calleeId: B.fnId, calleeName: 'B' }];
const r = solveInterprocTaint(map(A, B), edges);
expect(r.findings).toHaveLength(1);
expect(r.findings[0]).toMatchObject({
sourceFnId: A.fnId,
sinkFnId: B.fnId,
sinkKind: 'command-injection',
});
});
it('does not fire when the callee does not sink the param', () => {
const A = summary('Function:a.ts:A', {
paramCount: 0,
sourceToCallArg: [{ sourceKind: 'remote-input', callLine: 5, argIndex: 0 }],
});
const B = summary('Function:b.ts:B', { paramCount: 1 });
const r = solveInterprocTaint(map(A, B), [
{ callerId: A.fnId, calleeId: B.fnId, calleeName: 'B' },
]);
expect(r.findings).toHaveLength(0);
});
});
describe('solveInterprocTaint — multi-hop TITO', () => {
it('propagates through a chain a → b → c(sink)', () => {
const A = summary('Function:a.ts:A', {
paramCount: 0,
sourceToCallArg: [{ sourceKind: 'remote-input', callLine: 1, argIndex: 0, calleeName: 'B' }],
});
const B = summary('Function:b.ts:B', {
paramCount: 1,
paramToCallArg: [{ param: 0, callLine: 2, argIndex: 0, calleeName: 'C' }],
});
const C = summary('Function:c.ts:C', {
paramCount: 1,
paramToSink: [{ param: 0, sinkKind: 'sql-injection' }],
});
const edges: InterprocCallEdge[] = [
{ callerId: A.fnId, calleeId: B.fnId, calleeName: 'B' },
{ callerId: B.fnId, calleeId: C.fnId, calleeName: 'C' },
];
const r = solveInterprocTaint(map(A, B, C), edges);
expect(r.findings).toHaveLength(1);
expect(r.findings[0].sinkFnId).toBe(C.fnId);
// hop chain: A → B → C
expect(r.findings[0].hops.map((h) => h.fnId)).toEqual([A.fnId, B.fnId, C.fnId]);
});
});
describe('solveInterprocTaint — cross-function sanitizer exclusions (#2084 review P1-2)', () => {
it('a neutralized call-arg edge suppresses the callee sink of that kind', () => {
// A's source flows into relay; relay forwards it to helper with
// command-injection neutralised on the path; helper sinks command-injection.
const A = summary('Function:a.ts:A', {
paramCount: 0,
sourceToCallArg: [
{ sourceKind: 'remote-input', callLine: 1, argIndex: 0, calleeName: 'relay' },
],
});
const relay = summary('Function:relay.ts:relay', {
paramCount: 1,
paramToCallArg: [
{
param: 0,
callLine: 2,
argIndex: 0,
calleeName: 'helper',
neutralized: ['command-injection'],
},
],
});
const helper = summary('Function:h.ts:helper', {
paramCount: 1,
paramToSink: [{ param: 0, sinkKind: 'command-injection' }],
});
const edges: InterprocCallEdge[] = [
{ callerId: A.fnId, calleeId: relay.fnId, calleeName: 'relay' },
{ callerId: relay.fnId, calleeId: helper.fnId, calleeName: 'helper' },
];
const r = solveInterprocTaint(map(A, relay, helper), edges);
expect(r.findings).toHaveLength(0);
});
it('neutralization is kind-scoped — a different sink kind still fires', () => {
const A = summary('Function:a.ts:A', {
paramCount: 0,
sourceToCallArg: [
{ sourceKind: 'remote-input', callLine: 1, argIndex: 0, calleeName: 'relay' },
],
});
const relay = summary('Function:relay.ts:relay', {
paramCount: 1,
paramToCallArg: [
{ param: 0, callLine: 2, argIndex: 0, calleeName: 'helper', neutralized: ['xss'] },
],
});
const helper = summary('Function:h.ts:helper', {
paramCount: 1,
paramToSink: [{ param: 0, sinkKind: 'sql-injection' }],
});
const edges: InterprocCallEdge[] = [
{ callerId: A.fnId, calleeId: relay.fnId, calleeName: 'relay' },
{ callerId: relay.fnId, calleeId: helper.fnId, calleeName: 'helper' },
];
const r = solveInterprocTaint(map(A, relay, helper), edges);
expect(r.findings.some((f) => f.sinkKind === 'sql-injection')).toBe(true);
});
it('shrink-reprocess: a less-neutralized second path re-fires the sink (no FN)', () => {
// helper.param0 is reached from A's source two ways: via relay1 (neutralizes
// command-injection) and via relay2 (neutralizes nothing). The un-sanitized
// path must still produce the finding (intersection on revisit → ∅).
const A = summary('Function:a.ts:A', {
paramCount: 0,
sourceToCallArg: [
{ sourceKind: 'remote-input', callLine: 1, argIndex: 0, calleeName: 'relay1' },
{ sourceKind: 'remote-input', callLine: 2, argIndex: 0, calleeName: 'relay2' },
],
});
const relay1 = summary('Function:r1.ts:relay1', {
paramCount: 1,
paramToCallArg: [
{
param: 0,
callLine: 1,
argIndex: 0,
calleeName: 'helper',
neutralized: ['command-injection'],
},
],
});
const relay2 = summary('Function:r2.ts:relay2', {
paramCount: 1,
paramToCallArg: [{ param: 0, callLine: 1, argIndex: 0, calleeName: 'helper' }],
});
const helper = summary('Function:h.ts:helper', {
paramCount: 1,
paramToSink: [{ param: 0, sinkKind: 'command-injection' }],
});
const edges: InterprocCallEdge[] = [
{ callerId: A.fnId, calleeId: relay1.fnId, calleeName: 'relay1' },
{ callerId: A.fnId, calleeId: relay2.fnId, calleeName: 'relay2' },
{ callerId: relay1.fnId, calleeId: helper.fnId, calleeName: 'helper' },
{ callerId: relay2.fnId, calleeId: helper.fnId, calleeName: 'helper' },
];
const r = solveInterprocTaint(map(A, relay1, relay2, helper), edges);
expect(
r.findings.some((f) => f.sinkFnId === helper.fnId && f.sinkKind === 'command-injection'),
).toBe(true);
});
});
describe('solveInterprocTaint — generative sourceToReturn composition (#2084 review P1-1)', () => {
it('composes a generative call result that hits a sink in the caller', () => {
// getInput() returns a source; handler does exec(getInput()) — recorded as
// a callResult{getInput, dest:sink}. No tainted INPUT, so only return
// composition finds it.
const getInput = summary('Function:g.ts:getInput', {
paramCount: 0,
sourceToReturn: [{ sourceKind: 'remote-input' }],
});
const handler = summary('Function:h.ts:handler', {
paramCount: 0,
callResults: [
{ calleeName: 'getInput', dest: { to: 'sink', sinkKind: 'command-injection' } },
],
});
const edges: InterprocCallEdge[] = [
{ callerId: handler.fnId, calleeId: getInput.fnId, calleeName: 'getInput' },
];
const r = solveInterprocTaint(map(getInput, handler), edges);
expect(r.findings).toHaveLength(1);
expect(r.findings[0]).toMatchObject({
sourceFnId: getInput.fnId,
sinkFnId: handler.fnId,
sinkKind: 'command-injection',
});
});
it('composes a generative result flowing into another callee arg → sink', () => {
// handler: forward(getInput()); forward(z){ exec(z) }.
const getInput = summary('Function:g.ts:getInput', {
paramCount: 0,
sourceToReturn: [{ sourceKind: 'remote-input' }],
});
const forward = summary('Function:f.ts:forward', {
paramCount: 1,
paramToSink: [{ param: 0, sinkKind: 'command-injection' }],
});
const handler = summary('Function:h.ts:handler', {
paramCount: 0,
callResults: [
{ calleeName: 'getInput', dest: { to: 'callArg', toCallee: 'forward', argIndex: 0 } },
],
});
const edges: InterprocCallEdge[] = [
{ callerId: handler.fnId, calleeId: getInput.fnId, calleeName: 'getInput' },
{ callerId: handler.fnId, calleeId: forward.fnId, calleeName: 'forward' },
];
const r = solveInterprocTaint(map(getInput, forward, handler), edges);
expect(r.findings.some((f) => f.sinkFnId === forward.fnId)).toBe(true);
});
it('transitively marks a relay that RETURNS a generative result as generative', () => {
// wrap(){ return getInput() } then handler does exec(wrap()).
const getInput = summary('Function:g.ts:getInput', {
paramCount: 0,
sourceToReturn: [{ sourceKind: 'remote-input' }],
});
const wrap = summary('Function:w.ts:wrap', {
paramCount: 0,
callResults: [{ calleeName: 'getInput', dest: { to: 'return' } }],
});
const handler = summary('Function:h.ts:handler', {
paramCount: 0,
callResults: [{ calleeName: 'wrap', dest: { to: 'sink', sinkKind: 'xss' } }],
});
const edges: InterprocCallEdge[] = [
{ callerId: wrap.fnId, calleeId: getInput.fnId, calleeName: 'getInput' },
{ callerId: handler.fnId, calleeId: wrap.fnId, calleeName: 'wrap' },
];
const r = solveInterprocTaint(map(getInput, wrap, handler), edges);
expect(r.findings.some((f) => f.sinkFnId === handler.fnId && f.sinkKind === 'xss')).toBe(true);
});
it('does NOT compose when the callee is not generative', () => {
const pure = summary('Function:p.ts:pure', { paramCount: 0 }); // no sourceToReturn
const handler = summary('Function:h.ts:handler', {
paramCount: 0,
callResults: [{ calleeName: 'pure', dest: { to: 'sink', sinkKind: 'command-injection' } }],
});
const edges: InterprocCallEdge[] = [
{ callerId: handler.fnId, calleeId: pure.fnId, calleeName: 'pure' },
];
const r = solveInterprocTaint(map(pure, handler), edges);
expect(r.findings).toHaveLength(0);
});
});
describe('solveInterprocTaint — multi-source discrimination', () => {
it('two distinct sources into one sink function both fire (no collapse)', () => {
// A and A2 both pass a source into B's param 0, which sinks it. Without
// source-discriminated state, B.param0 is visited once and only the first
// source's finding survives — the M3 multi-source collapse bug class.
const B = summary('Function:b.ts:B', {
paramCount: 1,
paramToSink: [{ param: 0, sinkKind: 'command-injection' }],
});
const A = summary('Function:a.ts:A', {
paramCount: 0,
sourceToCallArg: [{ sourceKind: 'remote-input', callLine: 1, argIndex: 0, calleeName: 'B' }],
});
const A2 = summary('Function:a2.ts:A2', {
paramCount: 0,
sourceToCallArg: [{ sourceKind: 'remote-input', callLine: 1, argIndex: 0, calleeName: 'B' }],
});
const edges: InterprocCallEdge[] = [
{ callerId: A.fnId, calleeId: B.fnId, calleeName: 'B' },
{ callerId: A2.fnId, calleeId: B.fnId, calleeName: 'B' },
];
const r = solveInterprocTaint(map(A, A2, B), edges);
const sources = new Set(r.findings.map((f) => f.sourceFnId));
expect(sources).toEqual(new Set([A.fnId, A2.fnId]));
});
});
describe('solveInterprocTaint — recursion / cycles', () => {
it('terminates on direct recursion', () => {
// R taints its own param 0 → arg 0 of itself, and sinks param 0.
const R = summary('Function:r.ts:R', {
paramCount: 1,
paramToCallArg: [{ param: 0, callLine: 1, argIndex: 0, calleeName: 'R' }],
paramToSink: [{ param: 0, sinkKind: 'command-injection' }],
});
const S = summary('Function:s.ts:S', {
paramCount: 0,
sourceToCallArg: [{ sourceKind: 'remote-input', callLine: 9, argIndex: 0, calleeName: 'R' }],
});
const edges: InterprocCallEdge[] = [
{ callerId: S.fnId, calleeId: R.fnId, calleeName: 'R' },
{ callerId: R.fnId, calleeId: R.fnId, calleeName: 'R' },
];
const r = solveInterprocTaint(map(R, S), edges);
// Converges; one finding S→R.
expect(r.findings).toHaveLength(1);
expect(r.findings[0]).toMatchObject({ sourceFnId: S.fnId, sinkFnId: R.fnId });
});
it('terminates on mutual recursion f<->g', () => {
const F = summary('Function:f.ts:F', {
paramCount: 1,
paramToCallArg: [{ param: 0, callLine: 1, argIndex: 0 }],
});
const G = summary('Function:g.ts:G', {
paramCount: 1,
paramToCallArg: [{ param: 0, callLine: 2, argIndex: 0 }],
paramToSink: [{ param: 0, sinkKind: 'xss' }],
});
const S = summary('Function:s.ts:S', {
paramCount: 0,
sourceToCallArg: [{ sourceKind: 'remote-input', callLine: 3, argIndex: 0 }],
});
const edges: InterprocCallEdge[] = [
{ callerId: S.fnId, calleeId: F.fnId, calleeName: 'F' },
{ callerId: F.fnId, calleeId: G.fnId, calleeName: 'G' },
{ callerId: G.fnId, calleeId: F.fnId, calleeName: 'F' },
];
const r = solveInterprocTaint(map(F, G, S), edges);
expect(r.findings.some((f) => f.sinkFnId === G.fnId && f.sinkKind === 'xss')).toBe(true);
});
});
describe('solveInterprocTaint — guards', () => {
it('counts an unmatched call site (callee name resolves to no edge)', () => {
const A = summary('Function:a.ts:A', {
paramCount: 0,
// The summary expects to call `Z`, but the only CALLS edge goes to `B`.
sourceToCallArg: [{ sourceKind: 'remote-input', callLine: 99, argIndex: 0, calleeName: 'Z' }],
});
const B = summary('Function:b.ts:B', {
paramCount: 1,
paramToSink: [{ param: 0, sinkKind: 'xss' }],
});
const r = solveInterprocTaint(map(A, B), [
{ callerId: A.fnId, calleeId: B.fnId, calleeName: 'B' },
]);
expect(r.findings).toHaveLength(0);
expect(r.unmatchedCallSites).toBeGreaterThan(0);
});
it('respects an arity guard (argIndex >= callee paramCount)', () => {
const A = summary('Function:a.ts:A', {
paramCount: 0,
sourceToCallArg: [{ sourceKind: 'remote-input', callLine: 1, argIndex: 3 }],
});
const B = summary('Function:b.ts:B', {
paramCount: 1,
paramToSink: [{ param: 0, sinkKind: 'xss' }],
});
const r = solveInterprocTaint(map(A, B), [
{ callerId: A.fnId, calleeId: B.fnId, calleeName: 'B' },
]);
expect(r.findings).toHaveLength(0);
});
it('caps findings and reports the drop', () => {
const sinks = Array.from({ length: 5 }, (_, i) =>
summary(`Function:s${i}.ts:S${i}`, {
paramCount: 1,
paramToSink: [{ param: 0, sinkKind: 'xss' }],
}),
);
const A = summary('Function:a.ts:A', {
paramCount: 0,
sourceToCallArg: sinks.map((_, i) => ({
sourceKind: 'remote-input' as const,
callLine: i + 1,
argIndex: 0,
})),
});
const edges = sinks.map((s) => ({
callerId: A.fnId,
calleeId: s.fnId,
calleeName: s.fnId.split(':').pop() as string,
}));
const r = solveInterprocTaint(map(A, ...sinks), edges, { maxFindings: 2 });
expect(r.findings).toHaveLength(2);
expect(r.droppedFindings).toBe(3);
});
});

View file

@ -0,0 +1,212 @@
/**
* U1 (#2084 M4) per-function taint summary harvest.
*
* Fixtures parse REAL TypeScript through the shared CFG/import harness, so the
* harvester consumes the exact `FunctionCfg` / `FunctionDefUse` /
* `FunctionSiteMatches` structures the pipeline produces. The four summary
* edge categories are asserted directly: paramreturn, paramcallee-arg,
* paramsink, sourcereturn.
*/
import { describe, it, expect } from 'vitest';
import { cfgOf, importsFor } from '../../helpers/ts-cfg-harness.js';
import type { FunctionCfg } from '../../../src/core/ingestion/cfg/types.js';
import { computeReachingDefs } from '../../../src/core/ingestion/cfg/reaching-defs.js';
import {
buildTaintImportIndex,
matchFunctionSites,
} from '../../../src/core/ingestion/taint/match.js';
import type { SourceSinkSanitizerSpec } from '../../../src/core/ingestion/taint/source-sink-config.js';
import { harvestFunctionSummary } from '../../../src/core/ingestion/taint/summary-harvest.js';
const SPEC: SourceSinkSanitizerSpec = {
sources: [{ kind: 'remote-input', objects: ['req'], properties: ['body', 'query', 'params'] }],
sinks: [
{ name: 'exec', kind: 'command-injection', args: [0], global: true },
{ name: 'query', kind: 'sql-injection', args: [0], anyReceiver: true },
],
sanitizers: [{ name: 'escape', neutralizes: ['command-injection'], global: true }],
};
function harvest(code: string, spec: SourceSinkSanitizerSpec = SPEC, fnIndex = 0) {
const cfg: FunctionCfg = cfgOf(code, fnIndex);
const defUse = computeReachingDefs(cfg);
const matches = matchFunctionSites(cfg, spec, buildTaintImportIndex(importsFor(code)));
return harvestFunctionSummary(cfg, defUse, matches).facts;
}
describe('harvestFunctionSummary — param→return', () => {
it('records a param flowing straight to return', () => {
const f = harvest(`function f(x: string) { return x; }`);
expect(f.paramCount).toBe(1);
expect(f.paramToReturn).toEqual([{ param: 0 }]);
});
it('records a param returned through a local assignment', () => {
const f = harvest(`function f(x: string) { const y = x; return y; }`);
expect(f.paramToReturn).toEqual([{ param: 0 }]);
});
it('records receiver-TITO return (x.trim())', () => {
const f = harvest(`function f(x: string) { return x.trim(); }`);
expect(f.paramToReturn.map((r) => r.param)).toContain(0);
});
it('does not record an unrelated param', () => {
const f = harvest(`function f(x: string, y: string) { return x; }`);
expect(f.paramToReturn.map((r) => r.param)).toEqual([0]);
});
});
describe('harvestFunctionSummary — param→callee-arg', () => {
it('records a param flowing into a callee argument', () => {
const f = harvest(`function f(x: string) { helper(x); }`);
const ca = f.paramToCallArg;
expect(ca.length).toBeGreaterThanOrEqual(1);
expect(ca.some((c) => c.param === 0 && c.argIndex === 0 && c.calleeName === 'helper')).toBe(
true,
);
});
it('records the correct argument index', () => {
const f = harvest(`function f(x: string) { helper(a, x); }`);
expect(f.paramToCallArg.some((c) => c.param === 0 && c.argIndex === 1)).toBe(true);
});
});
describe('harvestFunctionSummary — param→sink', () => {
it('records a param reaching a modelled sink', () => {
const f = harvest(`function f(x: string) { exec(x); }`);
expect(f.paramToSink).toEqual([{ param: 0, sinkKind: 'command-injection' }]);
});
it('a sanitizer neutralises the matching sink kind', () => {
const f = harvest(`function f(x: string) { const y = escape(x); exec(y); }`);
// escape neutralises command-injection on the path to exec → no param→sink.
expect(f.paramToSink).toEqual([]);
});
});
describe('harvestFunctionSummary — call-arg sanitizer exclusions (#2084 review P1-2)', () => {
it('carries the neutralized kind onto a param→callee-arg edge', () => {
// x → escape(x) → y → helper(y): the call-arg edge to the user fn `helper`
// records that command-injection was neutralised on the path.
const f = harvest(`function f(x: string) { const y = escape(x); helper(y); }`);
const edge = f.paramToCallArg.find((c) => c.calleeName === 'helper');
expect(edge).toBeDefined();
expect(edge!.neutralized).toEqual(['command-injection']);
});
it('records no neutralized when the param reaches the call directly', () => {
const f = harvest(`function f(x: string) { helper(x); }`);
const edge = f.paramToCallArg.find((c) => c.calleeName === 'helper');
expect(edge).toBeDefined();
expect(edge!.neutralized).toBeUndefined();
});
});
describe('harvestFunctionSummary — source→callee-arg (fixpoint seed)', () => {
it('records a source passed directly into a callee argument', () => {
const f = harvest(`function f() { runIt(req.body); }`);
expect(f.sourceToCallArg.some((s) => s.argIndex === 0 && s.calleeName === 'runIt')).toBe(true);
});
it('records a source passed via a local into a callee argument', () => {
const f = harvest(`function f() { const u = req.body; runIt(u); }`);
expect(f.sourceToCallArg.some((s) => s.calleeName === 'runIt')).toBe(true);
});
});
describe('harvestFunctionSummary — call-result seeds (#2084 review P1-1)', () => {
it('records a generative call result reaching a sink via a local', () => {
const f = harvest(`function f() { const t = getInput(); exec(t); }`);
expect(f.callResults.some((cr) => cr.calleeName === 'getInput' && cr.dest.to === 'sink')).toBe(
true,
);
});
it('records a call result flowing into another callee arg', () => {
const f = harvest(`function f() { const t = getInput(); forward(t); }`);
expect(
f.callResults.some(
(cr) =>
cr.calleeName === 'getInput' &&
cr.dest.to === 'callArg' &&
cr.dest.toCallee === 'forward',
),
).toBe(true);
});
it('records a bare `return getInput()` as a call result → return', () => {
const f = harvest(`function f() { return getInput(); }`);
expect(
f.callResults.some((cr) => cr.calleeName === 'getInput' && cr.dest.to === 'return'),
).toBe(true);
});
it('does not record call results for sink/sanitizer calls', () => {
const f = harvest(`function f(x: string) { exec(escape(x)); }`);
// exec is a sink, escape is a sanitizer — neither is a user-fn call result.
expect(f.callResults.some((cr) => cr.calleeName === 'exec' || cr.calleeName === 'escape')).toBe(
false,
);
});
});
describe('harvestFunctionSummary — source→return', () => {
it('records a generated source returned directly', () => {
const f = harvest(`function f() { return req.body; }`);
expect(f.sourceToReturn).toEqual([{ sourceKind: 'remote-input' }]);
});
it('records a generated source returned via a local', () => {
const f = harvest(`function f() { const u = req.body; return u; }`);
expect(f.sourceToReturn).toEqual([{ sourceKind: 'remote-input' }]);
});
it('is empty when no source is present', () => {
const f = harvest(`function f(x: string) { return x; }`);
expect(f.sourceToReturn).toEqual([]);
});
});
describe('harvestFunctionSummary — documented limitations', () => {
it('all-simple params map to their formal argument position', () => {
const f = harvest(`function f(a: string, b: string) { exec(b); }`);
// `b` is formal param 1 — the index the interproc solver joins against.
expect(f.paramToSink).toEqual([{ param: 1, sinkKind: 'command-injection' }]);
});
it('destructured param before a simple param shifts the index (known FN, pinned)', () => {
// `function f([a, b], x)` — formal positions are [a,b]=0, x=1. The harvest
// assigns by binding ordinal (a=0, b=1, x=2), so x's port is 2, not the
// formal 1 the solver joins against → documented cross-function FN. Pinned
// so the behaviour is a known boundary, not a silent surprise; the proper
// fix (formal-param index from the worker) is deferred.
const f = harvest(`function f([a, b]: string[], x: string) { exec(x); }`);
const xSink = f.paramToSink.find((s) => s.sinkKind === 'command-injection');
expect(xSink).toBeDefined();
// Current (limited) behaviour: ordinal index 2, NOT the formal index 1.
expect(xSink!.param).toBe(2);
});
});
describe('harvestFunctionSummary — edges & gaps', () => {
it('empty summary for a param-less, site-less function', () => {
const f = harvest(`function f() { const a = 1; return a; }`);
expect(f.paramToReturn).toEqual([]);
expect(f.paramToCallArg).toEqual([]);
expect(f.paramToSink).toEqual([]);
expect(f.sourceToReturn).toEqual([]);
});
it('reports a coverage gap when reaching-defs is not computed', () => {
// A hand-built CFG with no bindings → reaching-defs returns no-facts.
const cfg = cfgOf(`function f(x: string) { return x; }`);
const bare = { ...cfg, bindings: undefined } as FunctionCfg;
const defUse = computeReachingDefs(bare);
const matches = matchFunctionSites(bare, SPEC, buildTaintImportIndex([]));
const r = harvestFunctionSummary(bare, defUse, matches);
expect(r.status).toBe('coverage-gap');
});
});

View file

@ -0,0 +1,122 @@
/**
* U2 (#2084 M4) the per-function taint summary model + version codec.
*
* `summaryVersion` is the incremental-invalidation primitive: it must be
* stable for identical facts, change when own facts change, change when any
* callee version changes, and be order-independent over callee versions.
* `ownFactsDigest` must be order-independent within each edge category. The
* model itself must be JSON-plain (structural-clone safe).
*/
import { describe, it, expect } from 'vitest';
import {
ownFactsDigest,
summaryVersion,
type FunctionSummary,
} from '../../../src/core/ingestion/taint/summary-model.js';
type Facts = Parameters<typeof ownFactsDigest>[0];
const baseFacts: Facts = {
paramCount: 2,
paramToReturn: [{ param: 0 }],
paramToCallArg: [{ param: 1, callLine: 10, argIndex: 0, calleeName: 'helper' }],
paramToSink: [{ param: 0, sinkKind: 'sql-injection' }],
sourceToReturn: [{ sourceKind: 'remote-input' }],
sourceToCallArg: [{ sourceKind: 'remote-input', callLine: 7, argIndex: 0, calleeName: 'sink' }],
callResults: [{ calleeName: 'getInput', dest: { to: 'sink', sinkKind: 'command-injection' } }],
};
describe('ownFactsDigest', () => {
it('is stable for identical facts', () => {
expect(ownFactsDigest(baseFacts)).toBe(ownFactsDigest({ ...baseFacts }));
});
it('is order-independent within edge categories', () => {
const reordered: Facts = {
...baseFacts,
paramToReturn: [{ param: 0 }],
paramToSink: [{ param: 0, sinkKind: 'sql-injection' }],
};
const twoSinks: Facts = {
...baseFacts,
paramToSink: [
{ param: 1, sinkKind: 'xss' },
{ param: 0, sinkKind: 'sql-injection' },
],
};
const twoSinksSwapped: Facts = {
...baseFacts,
paramToSink: [
{ param: 0, sinkKind: 'sql-injection' },
{ param: 1, sinkKind: 'xss' },
],
};
expect(ownFactsDigest(reordered)).toBe(ownFactsDigest(baseFacts));
expect(ownFactsDigest(twoSinks)).toBe(ownFactsDigest(twoSinksSwapped));
});
it('changes when own facts change', () => {
const changed: Facts = { ...baseFacts, paramCount: 3 };
expect(ownFactsDigest(changed)).not.toBe(ownFactsDigest(baseFacts));
const extraSink: Facts = {
...baseFacts,
paramToSink: [...baseFacts.paramToSink, { param: 1, sinkKind: 'command-injection' }],
};
expect(ownFactsDigest(extraSink)).not.toBe(ownFactsDigest(baseFacts));
});
it('distinguishes neutralized kinds on a return edge', () => {
const a: Facts = { ...baseFacts, paramToReturn: [{ param: 0, neutralized: ['xss'] }] };
const b: Facts = { ...baseFacts, paramToReturn: [{ param: 0 }] };
expect(ownFactsDigest(a)).not.toBe(ownFactsDigest(b));
});
});
describe('summaryVersion', () => {
it('is stable for identical own digest + callee versions', () => {
const d = ownFactsDigest(baseFacts);
expect(summaryVersion(d, ['aaa', 'bbb'])).toBe(summaryVersion(d, ['aaa', 'bbb']));
});
it('is order-independent over callee versions', () => {
const d = ownFactsDigest(baseFacts);
expect(summaryVersion(d, ['aaa', 'bbb'])).toBe(summaryVersion(d, ['bbb', 'aaa']));
});
it('changes when the own digest changes', () => {
const d1 = ownFactsDigest(baseFacts);
const d2 = ownFactsDigest({ ...baseFacts, paramCount: 9 });
expect(summaryVersion(d1, ['x'])).not.toBe(summaryVersion(d2, ['x']));
});
it('changes when any callee version changes', () => {
const d = ownFactsDigest(baseFacts);
expect(summaryVersion(d, ['aaa', 'bbb'])).not.toBe(summaryVersion(d, ['aaa', 'ccc']));
});
it('distinguishes no-callees from one-callee', () => {
const d = ownFactsDigest(baseFacts);
expect(summaryVersion(d, [])).not.toBe(summaryVersion(d, ['aaa']));
});
});
describe('FunctionSummary plain-data', () => {
it('survives structuredClone (no functions/Maps/Symbols)', () => {
const s: FunctionSummary = {
fnId: 'Function:src/a.ts:f',
filePath: 'src/a.ts',
startLine: 1,
paramCount: 1,
paramToReturn: [{ param: 0 }],
paramToCallArg: [],
paramToSink: [],
sourceToReturn: [],
sourceToCallArg: [],
callResults: [],
version: 'deadbeef',
};
expect(structuredClone(s)).toEqual(s);
});
});