mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
This commit is contained in:
parent
0054496323
commit
129bc84c0d
35 changed files with 3785 additions and 28 deletions
178
.claude/skills/gitnexus/gitnexus-taint-analysis/SKILL.md
Normal file
178
.claude/skills/gitnexus/gitnexus-taint-analysis/SKILL.md
Normal 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).** L1–L4-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).
|
||||
178
gitnexus-claude-plugin/skills/gitnexus-taint-analysis/SKILL.md
Normal file
178
gitnexus-claude-plugin/skills/gitnexus-taint-analysis/SKILL.md
Normal 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).** L1–L4-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).
|
||||
178
gitnexus/skills/gitnexus-taint-analysis.md
Normal file
178
gitnexus/skills/gitnexus-taint-analysis.md
Normal 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).** L1–L4-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).
|
||||
|
|
@ -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
|
||||
* A→C 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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
119
gitnexus/src/core/ingestion/pipeline-phases/taint-summaries.ts
Normal file
119
gitnexus/src/core/ingestion/pipeline-phases/taint-summaries.ts
Normal 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
|
||||
* source→sink 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,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
120
gitnexus/src/core/ingestion/taint/interproc-emit.ts
Normal file
120
gitnexus/src/core/ingestion/taint/interproc-emit.ts
Normal 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;
|
||||
}
|
||||
412
gitnexus/src/core/ingestion/taint/interproc-solver.ts
Normal file
412
gitnexus/src/core/ingestion/taint/interproc-solver.ts
Normal file
|
|
@ -0,0 +1,412 @@
|
|||
/**
|
||||
* Interprocedural taint fixpoint (#2084 M4 U3).
|
||||
*
|
||||
* Composes per-function {@link FunctionSummary} objects over the resolved
|
||||
* `CALLS` graph to find source→sink 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-arg→callee 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 B→sink (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 (caller→callee 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 };
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
147
gitnexus/src/core/ingestion/taint/summary-harvest-driver.ts
Normal file
147
gitnexus/src/core/ingestion/taint/summary-harvest-driver.ts
Normal 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 Function↔CFG 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 };
|
||||
}
|
||||
592
gitnexus/src/core/ingestion/taint/summary-harvest.ts
Normal file
592
gitnexus/src/core/ingestion/taint/summary-harvest.ts
Normal 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 def→use facts yields four edge categories:
|
||||
*
|
||||
* - **param→return** — 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.
|
||||
* - **param→callee-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).
|
||||
* - **param→sink** — a param reaches a modelled sink position (the partial
|
||||
* flow that a cross-function source completes).
|
||||
* - **source→return** — 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;
|
||||
}
|
||||
270
gitnexus/src/core/ingestion/taint/summary-model.ts
Normal file
270
gitnexus/src/core/ingestion/taint/summary-model.ts
Normal 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 (param→return, param→callee-arg), and where it lands
|
||||
* (param→sink). 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(',')}`);
|
||||
}
|
||||
|
|
@ -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 A→C 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 A→C 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
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
16
gitnexus/test/integration/cfg/fixtures/interproc-repo/gen.ts
Normal file
16
gitnexus/test/integration/cfg/fixtures/interproc-repo/gen.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
109
gitnexus/test/integration/cfg/interproc-taint.test.ts
Normal file
109
gitnexus/test/integration/cfg/interproc-taint.test.ts
Normal 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 source→callee-arg summary with the param→sink 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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)', () => {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────────────
|
||||
|
|
|
|||
448
gitnexus/test/unit/taint/interproc-solver.test.ts
Normal file
448
gitnexus/test/unit/taint/interproc-solver.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
212
gitnexus/test/unit/taint/summary-harvest.test.ts
Normal file
212
gitnexus/test/unit/taint/summary-harvest.test.ts
Normal 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: param→return, param→callee-arg,
|
||||
* param→sink, source→return.
|
||||
*/
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
122
gitnexus/test/unit/taint/summary-model.test.ts
Normal file
122
gitnexus/test/unit/taint/summary-model.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue