From 129bc84c0d683b43ea34108de771f6a7951e2c68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Sat, 13 Jun 2026 07:04:14 +0100 Subject: [PATCH] feat(taint): interprocedural taint via function summaries over resolved CALLS (#2084) (#2179) --- .../gitnexus/gitnexus-taint-analysis/SKILL.md | 178 ++++++ .../skills/gitnexus-taint-analysis/SKILL.md | 178 ++++++ gitnexus/skills/gitnexus-taint-analysis.md | 178 ++++++ .../src/core/incremental/subgraph-extract.ts | 16 +- .../core/ingestion/pipeline-phases/index.ts | 1 + .../pipeline-phases/taint-summaries.ts | 119 ++++ gitnexus/src/core/ingestion/pipeline.ts | 18 + .../scope-resolution/pipeline/phase.ts | 23 + .../scope-resolution/pipeline/run.ts | 66 ++ .../core/ingestion/taint/interproc-emit.ts | 120 ++++ .../core/ingestion/taint/interproc-solver.ts | 412 ++++++++++++ .../src/core/ingestion/taint/propagate.ts | 20 +- .../ingestion/taint/source-sink-config.ts | 30 + .../ingestion/taint/summary-harvest-driver.ts | 147 +++++ .../core/ingestion/taint/summary-harvest.ts | 592 ++++++++++++++++++ .../src/core/ingestion/taint/summary-model.ts | 270 ++++++++ gitnexus/src/core/lbug/lbug-adapter.ts | 56 ++ gitnexus/src/core/run-analyze.ts | 33 + gitnexus/src/mcp/local/local-backend.ts | 120 +++- gitnexus/src/mcp/tools.ts | 13 +- gitnexus/src/storage/repo-manager.ts | 10 + .../cfg/fixtures/interproc-repo/gen.ts | 16 + .../cfg/fixtures/interproc-repo/sink.ts | 13 + .../cfg/fixtures/interproc-repo/source.ts | 14 + .../integration/cfg/interproc-taint.test.ts | 109 ++++ .../integration/lbug-core-adapter.test.ts | 25 + .../test/integration/taint-explain.test.ts | 137 ++++ .../unit/incremental-subgraph-extract.test.ts | 18 + .../ingestion/pipeline-phase-registry.test.ts | 39 ++ gitnexus/test/unit/pdg-mode-flip.test.ts | 42 ++ gitnexus/test/unit/run-analyze.test.ts | 9 + gitnexus/test/unit/security.test.ts | 9 + .../test/unit/taint/interproc-solver.test.ts | 448 +++++++++++++ .../test/unit/taint/summary-harvest.test.ts | 212 +++++++ .../test/unit/taint/summary-model.test.ts | 122 ++++ 35 files changed, 3785 insertions(+), 28 deletions(-) create mode 100644 .claude/skills/gitnexus/gitnexus-taint-analysis/SKILL.md create mode 100644 gitnexus-claude-plugin/skills/gitnexus-taint-analysis/SKILL.md create mode 100644 gitnexus/skills/gitnexus-taint-analysis.md create mode 100644 gitnexus/src/core/ingestion/pipeline-phases/taint-summaries.ts create mode 100644 gitnexus/src/core/ingestion/taint/interproc-emit.ts create mode 100644 gitnexus/src/core/ingestion/taint/interproc-solver.ts create mode 100644 gitnexus/src/core/ingestion/taint/summary-harvest-driver.ts create mode 100644 gitnexus/src/core/ingestion/taint/summary-harvest.ts create mode 100644 gitnexus/src/core/ingestion/taint/summary-model.ts create mode 100644 gitnexus/test/integration/cfg/fixtures/interproc-repo/gen.ts create mode 100644 gitnexus/test/integration/cfg/fixtures/interproc-repo/sink.ts create mode 100644 gitnexus/test/integration/cfg/fixtures/interproc-repo/source.ts create mode 100644 gitnexus/test/integration/cfg/interproc-taint.test.ts create mode 100644 gitnexus/test/unit/taint/interproc-solver.test.ts create mode 100644 gitnexus/test/unit/taint/summary-harvest.test.ts create mode 100644 gitnexus/test/unit/taint/summary-model.test.ts diff --git a/.claude/skills/gitnexus/gitnexus-taint-analysis/SKILL.md b/.claude/skills/gitnexus/gitnexus-taint-analysis/SKILL.md new file mode 100644 index 000000000..9bffffdac --- /dev/null +++ b/.claude/skills/gitnexus/gitnexus-taint-analysis/SKILL.md @@ -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). diff --git a/gitnexus-claude-plugin/skills/gitnexus-taint-analysis/SKILL.md b/gitnexus-claude-plugin/skills/gitnexus-taint-analysis/SKILL.md new file mode 100644 index 000000000..9bffffdac --- /dev/null +++ b/gitnexus-claude-plugin/skills/gitnexus-taint-analysis/SKILL.md @@ -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). diff --git a/gitnexus/skills/gitnexus-taint-analysis.md b/gitnexus/skills/gitnexus-taint-analysis.md new file mode 100644 index 000000000..9bffffdac --- /dev/null +++ b/gitnexus/skills/gitnexus-taint-analysis.md @@ -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). diff --git a/gitnexus/src/core/incremental/subgraph-extract.ts b/gitnexus/src/core/incremental/subgraph-extract.ts index 71fe656be..74f01fd48 100644 --- a/gitnexus/src/core/incremental/subgraph-extract.ts +++ b/gitnexus/src/core/incremental/subgraph-extract.ts @@ -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 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); } }); diff --git a/gitnexus/src/core/ingestion/pipeline-phases/index.ts b/gitnexus/src/core/ingestion/pipeline-phases/index.ts index 4d8fa93bd..fa458b6e2 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/index.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/index.ts @@ -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'; diff --git a/gitnexus/src/core/ingestion/pipeline-phases/taint-summaries.ts b/gitnexus/src/core/ingestion/pipeline-phases/taint-summaries.ts new file mode 100644 index 000000000..96417ff30 --- /dev/null +++ b/gitnexus/src/core/ingestion/pipeline-phases/taint-summaries.ts @@ -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 = { + name: 'taintSummaries', + deps: ['scopeResolution', 'pruneLocalSymbols'], + + async execute( + ctx: PipelineContext, + deps: ReadonlyMap>, + ): Promise { + const scope = getPhaseOutput(deps, 'scopeResolution'); + const summaries = scope.functionSummaries; + if (summaries.length === 0) return EMPTY; + + // Index summaries by function node id. + const summaryMap = new Map(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, + }; + }, +}; diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index d8b9fa16b..269724e0d 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -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 }) diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/phase.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/phase.ts index dc0339c42..6b748dd97 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/phase.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/phase.ts @@ -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 = { @@ -143,6 +152,9 @@ export const scopeResolutionPhase: PipelinePhase = { 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 = { ); 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 = { 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 = { 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 = { referenceEdgesEmitted: totalRefs, resolutionOutcomes, perLanguage, + functionSummaries, }; }, }; diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index 2398e5ae6..faa29b17c 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -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; + /** + * 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, }; } diff --git a/gitnexus/src/core/ingestion/taint/interproc-emit.ts b/gitnexus/src/core/ingestion/taint/interproc-emit.ts new file mode 100644 index 000000000..4dc69f747 --- /dev/null +++ b/gitnexus/src/core/ingestion/taint/interproc-emit.ts @@ -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(); + + 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; +} diff --git a/gitnexus/src/core/ingestion/taint/interproc-solver.ts b/gitnexus/src/core/ingestion/taint/interproc-solver.ts new file mode 100644 index 000000000..50d49c4b6 --- /dev/null +++ b/gitnexus/src/core/ingestion/taint/interproc-solver.ts @@ -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; +} + +/** + * 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, + 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(); + const callsByCallerName = new Map>(); + 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(); + const tainted = new Map(); + 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(); + 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(); + 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([...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 }; +} diff --git a/gitnexus/src/core/ingestion/taint/propagate.ts b/gitnexus/src/core/ingestion/taint/propagate.ts index 0330f2d32..e82e7ff1a 100644 --- a/gitnexus/src/core/ingestion/taint/propagate.ts +++ b/gitnexus/src/core/ingestion/taint/propagate.ts @@ -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(KIND_ORDER.map((k, i) => [k, i])); -const sortKinds = (kinds: Iterable): SinkKind[] => - [...new Set(kinds)].sort((a, b) => (kindRank.get(a) ?? 99) - (kindRank.get(b) ?? 99)); const EMPTY_KINDS: ReadonlySet = new Set(); diff --git a/gitnexus/src/core/ingestion/taint/source-sink-config.ts b/gitnexus/src/core/ingestion/taint/source-sink-config.ts index 5909dcb3e..b1f9d3c86 100644 --- a/gitnexus/src/core/ingestion/taint/source-sink-config.ts +++ b/gitnexus/src/core/ingestion/taint/source-sink-config.ts @@ -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(SINK_KIND_ORDER.map((k, i) => [k, i])); + +/** Dedupe + sort sink kinds by {@link SINK_KIND_ORDER} (deterministic). */ +export function sortSinkKinds(kinds: Iterable): 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; +} diff --git a/gitnexus/src/core/ingestion/taint/summary-harvest-driver.ts b/gitnexus/src/core/ingestion/taint/summary-harvest-driver.ts new file mode 100644 index 000000000..8b5a3f864 --- /dev/null +++ b/gitnexus/src/core/ingestion/taint/summary-harvest-driver.ts @@ -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>; + +export function buildFunctionNodeIndex(graph: KnowledgeGraph): FunctionNodeIndex { + const index = new Map>(); + 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 }; +} diff --git a/gitnexus/src/core/ingestion/taint/summary-harvest.ts b/gitnexus/src/core/ingestion/taint/summary-harvest.ts new file mode 100644 index 000000000..4924a0e18 --- /dev/null +++ b/gitnexus/src/core/ingestion/taint/summary-harvest.ts @@ -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 `. 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; + /** 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(); + 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 ` — its `uses` + // are the returned bindings. (`return;` with no value has empty uses.) + const returnUseStmtKeys = new Set(); + 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>>(); // stmtKey → site → argPositions + const sinkKindByEntry = new Map>(); // stmtKey → site → kinds at any pos + const sanitizerResultDefKinds = new Map>(); // 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(); + 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>(); + const kindBySite = new Map(); + for (const sink of sm.sinks) { + const pos = bySite.get(sink.siteIndex) ?? new Set(); + 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(); + 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(); + 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>(); // param → neutralized intersection + const paramReturnSeen = new Set(); + const paramCallArg = new Map(); + const sourceCallArg = new Map(); + // 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>(); + const sourceCallArgKinds = new Map>(); + const intersectKinds = ( + store: Map>, + key: string, + incoming: ReadonlySet, + ): 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(); + const paramSinkOut: ParamToSink[] = []; + const sourceReturn = new Set(); + // Caller-side call-result flows (#2084 review P1-1), deduped by a structural key. + const callResults = new Map(); + 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): void => { + if (!paramReturnSeen.has(param)) { + paramReturnSeen.add(param); + paramReturn.set(param, new Set(exclusions)); + } else { + const cur = paramReturn.get(param) as Set; + 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(); + 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 ` 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([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([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; +} diff --git a/gitnexus/src/core/ingestion/taint/summary-model.ts b/gitnexus/src/core/ingestion/taint/summary-model.ts new file mode 100644 index 000000000..386d3a9b9 --- /dev/null +++ b/gitnexus/src/core/ingestion/taint/summary-model.ts @@ -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 ` 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(',')}`); +} diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts index dea1a4e98..158d324e8 100644 --- a/gitnexus/src/core/lbug/lbug-adapter.ts +++ b/gitnexus/src/core/lbug/lbug-adapter.ts @@ -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 // ============================================================================ diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index 542326a63..8de20925b 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -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 diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 83e46cfeb..99bec1852 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -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 = {}; + 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.', }; } diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index dafdf7caf..4df17eb98 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -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('') joins resolve; dynamic requires do not). diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index a9bfd88a9..56714e5be 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -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 diff --git a/gitnexus/test/integration/cfg/fixtures/interproc-repo/gen.ts b/gitnexus/test/integration/cfg/fixtures/interproc-repo/gen.ts new file mode 100644 index 000000000..2d2a24cf2 --- /dev/null +++ b/gitnexus/test/integration/cfg/fixtures/interproc-repo/gen.ts @@ -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); +} diff --git a/gitnexus/test/integration/cfg/fixtures/interproc-repo/sink.ts b/gitnexus/test/integration/cfg/fixtures/interproc-repo/sink.ts new file mode 100644 index 000000000..b012b3159 --- /dev/null +++ b/gitnexus/test/integration/cfg/fixtures/interproc-repo/sink.ts @@ -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); +} diff --git a/gitnexus/test/integration/cfg/fixtures/interproc-repo/source.ts b/gitnexus/test/integration/cfg/fixtures/interproc-repo/source.ts new file mode 100644 index 000000000..d07ca431f --- /dev/null +++ b/gitnexus/test/integration/cfg/fixtures/interproc-repo/source.ts @@ -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); +} diff --git a/gitnexus/test/integration/cfg/interproc-taint.test.ts b/gitnexus/test/integration/cfg/interproc-taint.test.ts new file mode 100644 index 000000000..1e826c3eb --- /dev/null +++ b/gitnexus/test/integration/cfg/interproc-taint.test.ts @@ -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); + }); +}); diff --git a/gitnexus/test/integration/lbug-core-adapter.test.ts b/gitnexus/test/integration/lbug-core-adapter.test.ts index 1cb5f500d..6cc245410 100644 --- a/gitnexus/test/integration/lbug-core-adapter.test.ts +++ b/gitnexus/test/integration/lbug-core-adapter.test.ts @@ -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'); diff --git a/gitnexus/test/integration/taint-explain.test.ts b/gitnexus/test/integration/taint-explain.test.ts index 6cda28eac..8d999ccfc 100644 --- a/gitnexus/test/integration/taint-explain.test.ts +++ b/gitnexus/test/integration/taint-explain.test.ts @@ -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>; + }; + 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>; + }; + 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(); + 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; + }, + }, +); diff --git a/gitnexus/test/unit/incremental-subgraph-extract.test.ts b/gitnexus/test/unit/incremental-subgraph-extract.test.ts index dc720fd9e..ed51f45b8 100644 --- a/gitnexus/test/unit/incremental-subgraph-extract.test.ts +++ b/gitnexus/test/unit/incremental-subgraph-extract.test.ts @@ -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)', () => { diff --git a/gitnexus/test/unit/ingestion/pipeline-phase-registry.test.ts b/gitnexus/test/unit/ingestion/pipeline-phase-registry.test.ts index 5a0932f15..a36b6b046 100644 --- a/gitnexus/test/unit/ingestion/pipeline-phase-registry.test.ts +++ b/gitnexus/test/unit/ingestion/pipeline-phase-registry.test.ts @@ -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'); + } + }); +}); diff --git a/gitnexus/test/unit/pdg-mode-flip.test.ts b/gitnexus/test/unit/pdg-mode-flip.test.ts index 0441464f1..572007a1c 100644 --- a/gitnexus/test/unit/pdg-mode-flip.test.ts +++ b/gitnexus/test/unit/pdg-mode-flip.test.ts @@ -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 diff --git a/gitnexus/test/unit/run-analyze.test.ts b/gitnexus/test/unit/run-analyze.test.ts index 7c9e2a3e5..e1f442321 100644 --- a/gitnexus/test/unit/run-analyze.test.ts +++ b/gitnexus/test/unit/run-analyze.test.ts @@ -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 }); }); diff --git a/gitnexus/test/unit/security.test.ts b/gitnexus/test/unit/security.test.ts index 514df1078..5f1934068 100644 --- a/gitnexus/test/unit/security.test.ts +++ b/gitnexus/test/unit/security.test.ts @@ -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 ─────────────────────────────────────────────── diff --git a/gitnexus/test/unit/taint/interproc-solver.test.ts b/gitnexus/test/unit/taint/interproc-solver.test.ts new file mode 100644 index 000000000..47e15ab2a --- /dev/null +++ b/gitnexus/test/unit/taint/interproc-solver.test.ts @@ -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>, +): 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); + }); +}); diff --git a/gitnexus/test/unit/taint/summary-harvest.test.ts b/gitnexus/test/unit/taint/summary-harvest.test.ts new file mode 100644 index 000000000..0828d4fe6 --- /dev/null +++ b/gitnexus/test/unit/taint/summary-harvest.test.ts @@ -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'); + }); +}); diff --git a/gitnexus/test/unit/taint/summary-model.test.ts b/gitnexus/test/unit/taint/summary-model.test.ts new file mode 100644 index 000000000..7ceda74d9 --- /dev/null +++ b/gitnexus/test/unit/taint/summary-model.test.ts @@ -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[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); + }); +});