feat(taint): intra-procedural taint analysis (#2083) (#2164)

* feat(taint): harvest occurrence-tagged call/member sites on StatementFacts (#2083 U1)

Worker-side site harvest in TsHarvester: call/new/member-read records with
dotted callee paths, receiver slots, per-argument occurrence tagging with
nested-site links, per-declarator resultDefs, spread/template/require-literal
markers. hasTaintSafeSites validation seam. The pdg parse-cache chunk-key
namespace is versioned (pdg:1 -> pdg:2) instead of a global SCHEMA_BUMP so
flag-off users keep warm caches; bench fingerprints re-baselined for the
three call-bearing scenarios (straight-line/dense-bindings byte-unchanged).

* feat(taint): built-in TS/JS source/sink/sanitizer model + site matcher (#2083 U2)

Typed spec (kind taxonomy; sanitizers carry neutralizes-kinds), the canonical
Express/Node model, and matchFunctionSites: ESM alias/namespace + require-
literal callee resolution, bare-name fallback restricted to true globals,
sanitizers module-or-global only (never user-shadowable by name), spread/
template arg-position rules, deterministic taintModelVersion.

* feat(taint): pure intra-procedural taint propagation engine (#2083 U3)

Two-rule model (statement-local + du-fact worklist) with per-taint
neutralized-kind exclusion sets: sanitizers exclude only the sink kinds
they neutralize (escape(req.body) suppresses res.send but still fires
db.query; exec(path.basename(t)) fires), intersection-over-paths so a
bypass occurrence keeps the taint live, kill locality on resultDefs,
propagate-through args+receiver with viaCall hops, one path per finding,
deterministic caps, coverage-gap statuses. Test-first: 38 scenarios on
real harvested CFGs.

* feat(taint): thread taint caps + model version through pdg config/meta (#2083 U5)

resolvePdgConfig gains maxTaintFindingsPerFunction (200), maxTaintHops (32),
and the taintModelVersion digest; RepoMeta.pdg + RunScopeResolutionInput
surfaces added. The key-union comparator trips full writeback on M2->M3
upgrade and on model-version change without --force (mode-flip tested).
No CLI flags or rc keys (programmatic parity with the other caps).

* feat(taint): in-phase taint emit with sparse TAINTED/SANITIZES edges (#2083 U4)

run.ts pdg window: match-first fast path (solver only when a function has
both a matched source and sink) -> computeReachingDefs with the shared RD
fact derivation -> computeTaintFlows -> per-finding TAINTED (versioned
hop-encoded reason via the shared path codec, statement-level occurrence
identity) + per-kill SANITIZES, dedup-before-budget, truncate-and-warn.
All emit counters surfaced (aggregate warn for gaps/drops, debug for
volume); PROF gains taint=. Flag-off golden untouched.

* feat(mcp): explain tool for persisted taint findings (#2083 U6)

Anchorless calls enumerate the sparse TAINTED table (bounded, deterministic,
limit-clamped); anchored calls (file or symbol via resolveSymbolCandidates)
return full decoded hop detail. sinkKind rides a version-1 codec header
(1;<kind>|hops — no other persisted channel exists; U4/U6 ship together).
RepoMeta.pdg probe yields a no-taint-layer note instead of an error.
TAINTED/SANITIZES pinned OUT of VALID_RELATION_TYPES (KTD9a negative-
membership tests); generators + canonical skill docs + mirrors updated.

* test(taint): acceptance fixture battery, snapshots, and bench gates (#2083 U7)

pdg-repo taint-cases fixtures complete the six plan shapes; committed
findings/kills snapshot via a shared pure-path harness that also feeds the
AE2 exact-equality assertion (stored TAINTED == pure-path findings, the
no-explosion gate). New taint-dense bench scenario with four --check gates:
per-function findings pinned AT the cap, absolute reason-byte + site-bytes
disk ceilings (the load-bearing R10 gate), zero-match pass < 0.5x match-
dense, N-linearity. Pre-existing scenario baselines untouched.

* refactor(taint): share one pointKey helper across propagate + emit (#2083 review)

Extract pointKey(ProgramPoint) to cfg/reaching-defs.ts (colon-separated,
matching the codebase block:stmt id convention) and import it in both
propagate.ts and emit.ts, replacing the two divergent locals (':' vs '.').
Edge-id material now uses the colon form; ids are in-memory only and no
test asserts the pointKey segment shape.

* fix(taint): discriminate taint state by source occurrence (#2083 review)

Two distinct sources flowing into one variable at one def point no longer
collapse to a single TAINTED edge: the taint-state key gains a root
source-occurrence discriminator ({point, siteIndex} — the same fields
recordFinding's identity uses, excluding kind). Def->use fact lookup keys
on the source-independent (binding, def-point) portion. Same-source
multi-path flows still share one state so their exclusion sets intersect
(the raw arm soundly wins); termination holds (finite keys, monotone
shrink, no cross-source ping-pong). Restores the KTD6 identity contract.

* fix(mcp): route dotted symbol names in explain to symbol resolution (#2083 review)

The fileish classifier matched any dotted name (UserController.create)
as a file via its extension-like suffix, so symbol resolution never ran
and the tool returned a silent empty file-anchored result. Tighten the
classifier to require a path separator or a real source extension (derived
from the resolver's EXTENSIONS list, multi-language), so dotted/bare names
route to resolveSymbolCandidates (found / ambiguous / not-found).

* fix(mcp): gate explain no-taint-layer note on taintModelVersion (#2083 review)

An M1/M2-era --pdg index has meta.pdg defined (BasicBlock/REACHING_DEF
recorded) but no taintModelVersion and zero TAINTED rows. The probe keyed
on generic meta.pdg presence, so explain returned the generic empty note
instead of the actionable 'no taint layer — run analyze' hint. Gate on
meta.pdg?.taintModelVersion (the field M3 stamps) so an M2-era index gets
the layer hint; a taint-stamped index with no findings still gets the
generic note.

* fix(taint): sequence-expression value flows only the final operand (#2083 review)

A comma expression in value position (exec((log(x), 'safe'))) default-
descended, fanning every operand's occurrences into the enclosing sink
argument — over-tainting exec's arg 0 with x. Add an explicit walkValue
case that records earlier operands' uses with occurrence fan-out suppressed
(new FactAccumulator.suppressOccurrences) and routes only the last operand
through the value path. Sites-layer only; defs/uses/mayDefs byte-identical
(cfg + reaching-defs snapshots unchanged).

* perf(taint): FIFO head-cursor worklist + dedup before chainHops (#2083 review)

Replace queue.shift() (O(N) dequeue) with a strict-FIFO head cursor plus
order-preserving prefix reclamation; FIFO is load-bearing because chainHops
reads the live taints map whose parent/source/viaCall are rewritten
order-sensitively on monotone shrink, so hop determinism is dequeue-order
contingent. Extract findingKey() and dedup-check before chainHops in the
justify branch — already-recorded identities discard their hop chain
(first write wins), so the ancestry walk was pure waste. The else kill
branch is untouched. Findings + hops byte-identical (snapshot unchanged).

* perf(taint): O(1) member-read dedup via composite-key set (#2083 review)

addMemberRead rescanned the whole per-statement sites array per call to
dedup by (object, property, parent) — O(n^2) on member-read-dense
statements. Track a composite-key Set alongside sites for O(1) dedup.
(The require-literal join is already O(sites) with a no-op body on
non-require sites, so no early-exit is needed there.) Behavior identical:
harvest + model-match + taint snapshots unchanged.

* refactor(taint): drop test-only export; source taint caps via emit.ts (#2083 review)

Remove the sanitizerNeutralizes export (its only consumers were two test
assertions — inlined to entry.neutralizes membership). Re-export the
DEFAULT_PDG_MAX_TAINT_* caps from emit.ts and point run.ts at emit.ts, so
the pipeline's taint dependency surface is the single orchestration module
rather than reaching into propagate.ts.

* test(taint): extract the shared TS CFG/taint test harness (#2083 review)

The parse/collectFunctions/cfgOf/cfgsOf/importsFor harness was copied
byte-for-byte across four suites (harvest, model-match, propagate,
taint-emit). Promote it to test/helpers/ts-cfg-harness.ts and import it.
site-safety/reaching-defs carry a structurally different inlined builder
and are left as-is. Pure extraction, no assertion changes.

* test(mcp): harden explain limit-rejection battery (#2083 review)

Add NaN, Infinity, -Infinity, and a numeric string to the out-of-bounds
limit cases — a regression fence over the interpolated LIMIT, confirming
the Number.isInteger guard rejects every non-integer/non-finite/string
input before it reaches the query.
This commit is contained in:
Gergő Magyar 2026-06-12 07:35:09 +01:00 • committed by GitHub
parent 28f3b99822
commit 14397dd4aa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 6725 additions and 96 deletions

View file

@ -39,7 +39,8 @@ For any task involving code understanding, debugging, impact analysis, or refact
| `check` | Check graph invariants such as circular imports |
| `rename` | Multi-file coordinated rename with confidence-tagged edits |
| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) |
| `list_repos` | Discover indexed repos (paginated — `limit`/`offset`) |
| `explain` | Persisted taint findings — source→sink data flows (needs `analyze --pdg`) |
| `list_repos` | Discover indexed repos (paginated — `limit`/`offset`) |
### Paginating `list_repos`
@ -72,6 +73,16 @@ list_repos { offset: 400 } → repos 401–437, hasMore false
Notes: `offset` ≥ `total` returns an empty page (with `total` still reported). Out-of-range or malformed `limit`/`offset` (non-integer, `limit` outside `[1, 200]`, `offset < 0`) are rejected with a clear error — `limit` above the max is rejected, not silently capped. The order is deterministic (lower-cased name, then path), so paging never skips or duplicates an entry while the registry is unchanged.
### Taint findings (`explain`)
`explain` returns intra-procedural taint findings (`TAINTED` edges) recorded by `gitnexus analyze --pdg` — each with a sink category (command-injection, code-injection, path-traversal, sql-injection, xss), source/sink lines, and the ordered hop path with the variable carried on each hop.
- `explain {}` — enumerate all findings for the repo (bounded by `limit`, deterministic order)
- `explain { target: "src/vuln.ts" }` — findings in a file (suffix path match accepted)
- `explain { target: "runUserCommand" }` — findings in a function (resolved like `context`; ambiguous names return ranked candidates)
A repo indexed without `--pdg` returns a clear "no taint layer" note. Caveats: findings are intra-procedural only — cross-function, closure/callback, property/field, and implicit flows are not modeled, so the absence of a finding is **not** proof of safety. `SANITIZES` (sanitizer-kill) edges are queryable via `cypher`.
## Resources Reference
Lightweight reads (~100-500 tokens) for navigation:

View file

@ -38,7 +38,8 @@ For any task involving code understanding, debugging, impact analysis, or refact
| `detect_changes` | Git-diff impact — what do your current changes affect |
| `rename` | Multi-file coordinated rename with confidence-tagged edits |
| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) |
| `list_repos` | Discover indexed repos (paginated — `limit`/`offset`) |
| `explain` | Persisted taint findings — source→sink data flows (needs `analyze --pdg`) |
| `list_repos` | Discover indexed repos (paginated — `limit`/`offset`) |
### Paginating `list_repos`
@ -71,6 +72,16 @@ list_repos { offset: 400 } → repos 401–437, hasMore false
Notes: `offset` ≥ `total` returns an empty page (with `total` still reported). Out-of-range or malformed `limit`/`offset` (non-integer, `limit` outside `[1, 200]`, `offset < 0`) are rejected with a clear error — `limit` above the max is rejected, not silently capped. The order is deterministic (lower-cased name, then path), so paging never skips or duplicates an entry while the registry is unchanged.
### Taint findings (`explain`)
`explain` returns intra-procedural taint findings (`TAINTED` edges) recorded by `gitnexus analyze --pdg` — each with a sink category (command-injection, code-injection, path-traversal, sql-injection, xss), source/sink lines, and the ordered hop path with the variable carried on each hop.
- `explain {}` — enumerate all findings for the repo (bounded by `limit`, deterministic order)
- `explain { target: "src/vuln.ts" }` — findings in a file (suffix path match accepted)
- `explain { target: "runUserCommand" }` — findings in a function (resolved like `context`; ambiguous names return ranked candidates)
A repo indexed without `--pdg` returns a clear "no taint layer" note. Caveats: findings are intra-procedural only — cross-function, closure/callback, property/field, and implicit flows are not modeled, so the absence of a finding is **not** proof of safety. `SANITIZES` (sanitizer-kill) edges are queryable via `cypher`.
## Resources Reference
Lightweight reads (~100-500 tokens) for navigation:

View file

@ -9,20 +9,20 @@
"_note": "#2081 M1 / #2082 M2: ONE function, N coalescing statements (extendBlock text accumulation + per-statement fact harvest). Runs at 2000->8000. M2 REWROTE the old 'output is constant 4 blocks' note: statement facts make disk/heap LINEAR in N (a free gate on the harvest payload); TIME still guards the concat path (array-join ~1.0; a genuine O(n^2) re-join accumulation is ~3.8). M2 adds rd_scaling_budget (measured ~0.74) and disk_bytes_large_max -- an ABSOLUTE ceiling ~1.35x the measured indexed-encoding bytes (969,986 at N=8000, ~121 B/stmt); a named-record encoding regression (~4x facts bytes) blows it. Re-baseline the fingerprint only on an intentional CFG/harvest-shape change (the canon now includes statements+bindings)."
},
"many-functions": {
"fingerprint": "f3bcc5e6ef4cf58aefe4e7d801a8fea0215494b9688833e501c2afc6df029c1b",
"fingerprint": "d881f60e77f0262bdc1b5c7049aa4acf5071e0eabc536476be293c3a133e626e",
"scaling_budget": 1.5,
"disk_bytes_budget": 1.2,
"heap_budget": 1.3,
"rd_scaling_budget": 2.0,
"_note": "#2081 M1 / #2082 M2: N small branchy functions (collect walk + per-function build + per-function solve). Time ~1.0, disk ~1.01, heap ~1.0, rd ~0.86 (solver is per-function; N functions scale linearly)."
"_note": "#2081 M1 / #2082 M2 / #2083 M3 U1: N small branchy functions (collect walk + per-function build + per-function solve). Time ~1.0, disk ~1.01, heap ~1.0, rd ~0.86 (solver is per-function; N functions scale linearly). M3 U1 re-fingerprinted: taint sites join StatementFacts (a()/b() call sites); disk_large 2565641->2721641 (+6.1% measured site-harvest cost at N=2000)."
},
"branchy": {
"fingerprint": "5b5886521ab21604df8f78af98c8c28a6be8e64c24f3d67b165c2d96ba2a3d52",
"fingerprint": "936765bba5c3f8fc7058737c48351e03e4e1da7fed448467e8fcc8a0fb7786ce",
"scaling_budget": 1.8,
"disk_bytes_budget": 1.2,
"heap_budget": 1.3,
"rd_scaling_budget": 2.0,
"_note": "#2081 M1 / #2082 M2: ONE function, N sequential ifs (block/edge growth in one CFG). Time ~1.1-1.25 (noisiest scenario; budget 1.8 absorbs noise, catches ~4.0 quadratic), disk ~1.03, heap ~1.0, rd ~0.7."
"_note": "#2081 M1 / #2082 M2 / #2083 M3 U1: ONE function, N sequential ifs (block/edge growth in one CFG). Time ~1.1-1.25 (noisiest scenario; budget 1.8 absorbs noise, catches ~4.0 quadratic), disk ~1.03, heap ~1.0, rd ~0.7. M3 U1 re-fingerprinted (s{i}() call sites); disk_large 908964->993854 (+9.3%)."
},
"dense-bindings": {
"fingerprint": "e4d7eb3c7e8b3772423af25cef391e0e6b68067b554819e81b543439a487403f",
@ -33,12 +33,25 @@
"_note": "#2082 M2: N bindings live across ~N blocks in one loop -- bindings x blocks scale JOINTLY (the solver-lattice stressor). The overlay design measures rd ~5.2 normalized: the OUT spine copy on genning blocks is O(V) per block, which is quadratic when V scales with B (bounded in prod by maxFunctionLines; real functions have V~10-40). Budget 10 deliberately tolerates that known shape and exists to catch the repo's recurring per-item-rescan class (a per-use scan over all defs is O(n^3) here, ratio >=16). If rd drops well below 5, tighten."
},
"fact-fanout": {
"fingerprint": "488e63e072d514a9229e21872615e32c7b099ccbd65ec8c045ba517568fd3e5d",
"fingerprint": "83a8243a8aff117f69aeecb39d02a483e6cca70439d75f63e433f4e4ac85578f",
"scaling_budget": 1.8,
"disk_bytes_budget": 1.2,
"heap_budget": 1.3,
"rd_scaling_budget": 3.0,
"facts_large_max": 16000,
"_note": "#2082 M2: N switch-arm defs of one variable + N later uses -- facts are O(defs x uses) BY SPEC, so the gate is BOUNDEDNESS, not linearity: with the production fact limit engaged (DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION=16000) the materialized fact count stays pinned at the limit as N grows (facts_large_max), and rd time stays bounded (measured ~1.4). Losing the maxFacts early-stop shows as facts_large exploding quadratically."
"_note": "#2082 M2 / #2083 M3 U1: N switch-arm defs of one variable + N later uses -- facts are O(defs x uses) BY SPEC, so the gate is BOUNDEDNESS, not linearity: with the production fact limit engaged (DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION=16000) the materialized fact count stays pinned at the limit as N grows (facts_large_max), and rd time stays bounded (measured ~1.4). Losing the maxFacts early-stop shows as facts_large exploding quadratically. M3 U1 re-fingerprinted (u{i}(x) call sites); disk_large 996737->1107627 (+11.1%)."
},
"taint-dense": {
"fingerprint": "218a1a0c7e092550c233607c67daa401543a25bf8d3f122899d30cd9c30c3a89",
"scaling_budget": 1.5,
"disk_bytes_budget": 1.2,
"heap_budget": 1.3,
"rd_scaling_budget": 2.0,
"disk_bytes_large_max": 3150000,
"taint_findings_per_fn_pin": 8,
"taint_scaling_budget": 2.0,
"taint_reason_bytes_large_max": 198000,
"taint_zero_match_budget": 0.5,
"_note": "#2083 M3 U7 (R10): N functions, each with 12 req.body sources + a 4-hop chain + 13 eval sinks (13 deduped findings/fn) at 125->500 fns; the zero-match control (inp.payload/evalish) keeps the identical CFG shape with zero model hits. BOUNDEDNESS pin: kept findings/function == 8 (the scenario cap) at BOTH sizes -- above means the cap was lost, below means detection regressed; total findings grow linearly with N by design. disk_bytes_large_max is the LOAD-BEARING site-harvest absolute ceiling (densest sites of the suite; measured 2335772 at N=500, ceiling ~1.35x). taint_reason_bytes_large_max caps the persisted TAINTED reason bytes (measured 146827 = ~37 B/finding, ceiling ~1.35x; blows on hop-encoding bloat or cap loss). taint_zero_match_budget 0.5 vs measured 0.15: the zero-match pass (match gate only, no solver) must stay a small fraction of the match-dense pass. taint scaling measured ~0.93 (per-function work is N-linear); time/disk/heap/rd ratios all ~1.0."
}
}

View file

@ -48,6 +48,13 @@ import { computeReachingDefs } from '../../src/core/ingestion/cfg/reaching-defs.
import { DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION } from '../../src/core/ingestion/cfg/emit.ts';
import { createTypeScriptCfgVisitor } from '../../src/core/ingestion/cfg/visitors/typescript.ts';
import { getTreeSitterBufferSize } from '../../src/core/ingestion/constants.ts';
import { buildTaintImportIndex, matchFunctionSites } from '../../src/core/ingestion/taint/match.ts';
import { TS_JS_TAINT_MODEL } from '../../src/core/ingestion/taint/typescript-model.ts';
import {
computeTaintFlows,
DEFAULT_PDG_MAX_TAINT_HOPS,
} from '../../src/core/ingestion/taint/propagate.ts';
import { encodeTaintPath } from '../../src/core/ingestion/taint/path-codec.ts';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const BASELINE_PATH = path.resolve(__dirname, 'baselines.json');
@ -141,8 +148,51 @@ const SCENARIOS = [
return s + '}\n';
},
},
{
name: 'taint-dense',
// #2083 M3 U7 (R10): N functions, EACH source/sink-dense — 12 matched
// `req.body` source statements + a 4-hop chained reassignment + 13 `eval`
// sinks per function (13 deduped findings/fn, ABOVE the scenario cap of 8
// so the cap binds). Functions scale with N, so total findings grow
// linearly BY DESIGN; the boundedness gate is the per-function pin: kept
// findings/function stays EXACTLY at the cap as N grows (a cap loss shows
// as 13). This scenario's sites are the densest of the suite, so its
// ABSOLUTE disk_bytes_large_max is the load-bearing site-harvest ceiling
// (the M2 straight-line carrier has no call sites), and the summed
// encoded TAINTED reason bytes get their own absolute ceiling
// (taint_reason_bytes_large_max). The zero-match control (genZero) keeps
// the identical statement/CFG shape with names OUTSIDE the model
// (inp.payload / evalish) — the match-gate must make unmatched functions
// cost ~nothing (no solver call), gated as zero-time/dense-time ratio.
small: 125,
large: 500, // 4x, like the global sizes — per-fn bodies are ~30 lines
taint: { cap: 8 },
gen: (n) => genTaintFunctions(n, false),
genZero: (n) => genTaintFunctions(n, true),
},
];
// taint-dense generator: `zero` swaps every model-matched name for an
// unmatched one without changing statement count, def/use shape, or CFG.
const TAINT_SOURCES_PER_FN = 12;
const TAINT_CHAIN_HOPS = 4;
function genTaintFunctions(n, zero) {
const recv = zero ? 'inp' : 'req';
const prop = zero ? 'payload' : 'body';
const sink = zero ? 'evalish' : 'eval';
let s = '';
for (let i = 0; i < n; i++) {
s += `function f${i}(${recv}) {\n`;
for (let j = 0; j < TAINT_SOURCES_PER_FN; j++) s += ` const s${j} = ${recv}.${prop};\n`;
s += ` let c0 = s0 + '!';\n`;
for (let h = 1; h < TAINT_CHAIN_HOPS; h++) s += ` const c${h} = c${h - 1} + '!';\n`;
for (let j = 0; j < TAINT_SOURCES_PER_FN; j++) s += ` ${sink}(s${j});\n`;
s += ` ${sink}(c${TAINT_CHAIN_HOPS - 1});\n`;
s += '}\n';
}
return s;
}
const SMALL = 500;
const LARGE = 2000; // 4× — O(n) ⇒ ratio ~1, O(n²) ⇒ ratio ~4
const REPS = 15; // median over more reps → stabler time signal at small absolute ms
@ -199,6 +249,57 @@ function measureReachingDefs(cfgs, reps, maxFacts) {
return { ms: median(samples), facts };
}
// ---- taint pass cost (#2083 M3 U7) ----
// Times the EXACT per-function sequence the in-phase emit driver runs on a
// --pdg run for a taint-modeled language: match sites → zero-match fast path
// → computeReachingDefs → computeTaintFlows. `cap` is the scenario's
// maxFindingsPerFunction (deliberately small so the cap BINDS on the dense
// generator). Also sums the encoded TAINTED `reason` bytes for the kept
// findings — the persisted-taint disk posture (R10).
function measureTaint(cfgs, reps, cap) {
const importIndex = buildTaintImportIndex([]); // bench callees are globals
const pass = () => {
let analyzed = 0;
let kept = 0;
let dropped = 0;
let reasonBytes = 0;
for (const c of cfgs) {
const matches = matchFunctionSites(c, TS_JS_TAINT_MODEL, importIndex);
if (!matches.hasSource || !matches.hasSink) continue;
const du = computeReachingDefs(c, {
maxFacts: DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION,
});
const flows = computeTaintFlows(c, du, matches, {
maxFindingsPerFunction: cap,
maxHops: DEFAULT_PDG_MAX_TAINT_HOPS,
});
if (flows.status !== 'computed') continue;
analyzed++;
kept += flows.findings.length;
dropped += flows.droppedFindings;
for (const f of flows.findings) {
// All structural chars + identifier names are single-byte ASCII, so
// string length IS the byte length (path-codec discipline).
reasonBytes += encodeTaintPath(
f.hops.map((h) => ({ name: h.name, line: h.point.line, viaCall: h.viaCall })),
{ truncated: f.hopsTruncated === true, kind: f.sinkKind },
).reason.length;
}
}
return { analyzed, kept, dropped, reasonBytes };
};
pass(); // warm JIT (uncounted)
const samples = [];
let out;
for (let i = 0; i < reps; i++) {
const start = process.hrtime.bigint();
out = pass();
samples.push(Number(process.hrtime.bigint() - start) / 1e6);
}
return { ms: median(samples), ...out };
}
// ---- memory growth: retained heap of the cfgSideChannel payload ----
// Needs `node --expose-gc` to force collection for a clean delta; without it the
@ -279,7 +380,40 @@ function measureScenario(scenario) {
// ratio 0 and the gate would self-disable exactly when the solver is fast.
const rdRatio = rdLarge.ms / Math.max(rdSmall.ms, 0.001) / sizeRatio;
// #2083 M3 U7: taint pass cost + boundedness on taint-bearing scenarios.
let taintMetrics = {};
if (scenario.taint !== undefined) {
const cap = scenario.taint.cap;
const tSmall = measureTaint(small.cfgs, REPS, cap);
const tLarge = measureTaint(large.cfgs, REPS, cap);
const tRatio = tLarge.ms / Math.max(tSmall.ms, 0.001) / sizeRatio;
// Zero-match control: identical CFG shape, no model hits — measures the
// match-gate overhead unmatched functions pay on a real --pdg repo.
const zeroCfgs = collectFunctionCfgs(
parse(scenario.genZero(nLarge)).rootNode,
visitor,
`${scenario.name}-zero.ts`,
NO_CAP,
).cfgs;
const tZero = measureTaint(zeroCfgs, REPS, cap);
taintMetrics = {
taint_ms_small: Number(tSmall.ms.toFixed(3)),
taint_ms_large: Number(tLarge.ms.toFixed(3)),
taint_scaling_ratio: Number(tRatio.toFixed(3)),
// Boundedness: kept findings PER ANALYZED FUNCTION (total findings grow
// linearly with N by design — the per-function pin is the cap gate).
taint_findings_per_fn_small: tSmall.analyzed > 0 ? tSmall.kept / tSmall.analyzed : 0,
taint_findings_per_fn_large: tLarge.analyzed > 0 ? tLarge.kept / tLarge.analyzed : 0,
taint_dropped_large: tLarge.dropped,
taint_reason_bytes_large: tLarge.reasonBytes,
taint_zero_ms_large: Number(tZero.ms.toFixed(3)),
taint_zero_findings: tZero.kept + tZero.dropped,
taint_zero_match_ratio: Number((tZero.ms / Math.max(tLarge.ms, 0.001)).toFixed(3)),
};
}
return {
...taintMetrics,
scenario: scenario.name,
elapsed_ms_small: Number(small.ms.toFixed(3)),
elapsed_ms_large: Number(large.ms.toFixed(3)),
@ -367,6 +501,56 @@ if (!CHECK) {
`${base.disk_bytes_large_max} bytes (constant-factor encoding bloat)`,
);
}
// #2083 M3 U7 gates — taint boundedness (per-function findings pinned at
// the cap as N grows), an ABSOLUTE ceiling on persisted TAINTED reason
// bytes, taint solve-time scaling, and the zero-match fast path staying
// ~free relative to the match-dense pass.
if (base.taint_findings_per_fn_pin !== undefined) {
for (const side of ['small', 'large']) {
const perFn = r[`taint_findings_per_fn_${side}`];
if (perFn !== base.taint_findings_per_fn_pin) {
failures.push(
`${r.scenario}: taint findings/function (${side}) ${perFn} != pin ` +
`${base.taint_findings_per_fn_pin} (cap must BIND exactly: above = cap lost, ` +
`below = detection regressed)`,
);
}
}
if (r.taint_zero_findings !== 0) {
failures.push(
`${r.scenario}: zero-match control produced ${r.taint_zero_findings} findings ` +
`(the control must not match the model — generator drift)`,
);
}
}
if (
base.taint_reason_bytes_large_max !== undefined &&
r.taint_reason_bytes_large > base.taint_reason_bytes_large_max
) {
failures.push(
`${r.scenario}: persisted TAINTED reason bytes ${r.taint_reason_bytes_large} > ceiling ` +
`${base.taint_reason_bytes_large_max} (hop-encoding bloat or cap loss)`,
);
}
if (
base.taint_scaling_budget !== undefined &&
r.taint_scaling_ratio >= base.taint_scaling_budget
) {
failures.push(
`${r.scenario}: taint scaling ratio ${r.taint_scaling_ratio} >= budget ` +
`${base.taint_scaling_budget} (ms ${r.taint_ms_small}->${r.taint_ms_large})`,
);
}
if (
base.taint_zero_match_budget !== undefined &&
r.taint_zero_match_ratio >= base.taint_zero_match_budget
) {
failures.push(
`${r.scenario}: zero-match taint time is ${r.taint_zero_match_ratio} of the match-dense ` +
`pass, >= budget ${base.taint_zero_match_budget} (the match gate must keep unmatched ` +
`functions ~free — no solver call)`,
);
}
// Heap gate only when measured (--expose-gc present) AND a budget exists.
if (
base.heap_budget !== undefined &&

View file

@ -38,6 +38,7 @@ For any task involving code understanding, debugging, impact analysis, or refact
| `detect_changes` | Git-diff impact — what do your current changes affect |
| `rename` | Multi-file coordinated rename with confidence-tagged edits |
| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) |
| `explain` | Persisted taint findings — source→sink data flows (needs `analyze --pdg`) |
| `list_repos` | Discover indexed repos (paginated — `limit`/`offset`) |
### Paginating `list_repos`
@ -71,6 +72,16 @@ list_repos { offset: 400 } → repos 401–437, hasMore false
Notes: `offset` ≥ `total` returns an empty page (with `total` still reported). Out-of-range or malformed `limit`/`offset` (non-integer, `limit` outside `[1, 200]`, `offset < 0`) are rejected with a clear error — `limit` above the max is rejected, not silently capped. The order is deterministic (lower-cased name, then path), so paging never skips or duplicates an entry while the registry is unchanged.
### Taint findings (`explain`)
`explain` returns intra-procedural taint findings (`TAINTED` edges) recorded by `gitnexus analyze --pdg` — each with a sink category (command-injection, code-injection, path-traversal, sql-injection, xss), source/sink lines, and the ordered hop path with the variable carried on each hop.
- `explain {}` — enumerate all findings for the repo (bounded by `limit`, deterministic order)
- `explain { target: "src/vuln.ts" }` — findings in a file (suffix path match accepted)
- `explain { target: "runUserCommand" }` — findings in a function (resolved like `context`; ambiguous names return ranked candidates)
A repo indexed without `--pdg` returns a clear "no taint layer" note. Caveats: findings are intra-procedural only — cross-function, closure/callback, property/field, and implicit flows are not modeled, so the absence of a finding is **not** proof of safety. `SANITIZES` (sanitizer-kill) edges are queryable via `cypher`.
## Resources Reference
Lightweight reads (~100-500 tokens) for navigation:

View file

@ -179,6 +179,7 @@ This project is indexed by GitNexus as **${projectName}**${noStats ? '' : ` (${s
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
- When exploring unfamiliar code, use \`query({query: "concept"})\` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use \`context({name: "symbolName"})\`.
- For security review, \`explain({target: "fileOrSymbol"})\` lists taint findings (source→sink flows; needs \`analyze --pdg\`).
## Never Do

View file

@ -654,6 +654,9 @@ const renderSkillMarkdown = (
`2. \`query({query: "${community.label.toLowerCase()}"})\` \u2014 find related execution flows`,
);
lines.push('3. Read key files listed above for implementation details');
lines.push(
'4. `explain({target: "<file or symbol>"})` — persisted taint findings (source→sink data flows), when indexed with `--pdg`',
);
lines.push('');
return lines.join('\n');

View file

@ -65,7 +65,13 @@ export interface CfgEmitResult {
cappedFunctions: number;
}
const basicBlockId = (
/**
* The single BasicBlock id template (module doc). Exported for the M3 taint
* emit path (taint/emit.ts), whose TAINTED/SANITIZES edges must address the
* SAME persisted block nodes — a re-derived copy of this template would
* silently dangle the moment either drifted.
*/
export const basicBlockId = (
filePath: string,
functionStartLine: number,
functionStartColumn: number,
@ -259,9 +265,10 @@ export interface ReachingDefEmitResult {
* Stable identity for a binding inside edge ids (#2082 M2 KTD3/KTD9):
* `name:declLine:declCol` for declared bindings, `name@module` for synthetic
* ones. Distinct same-name bindings never share a key; identifier characters
* cannot contain the id separators.
* cannot contain the id separators. Exported for the M3 taint emit path —
* TAINTED/SANITIZES ids key bindings with the same discipline.
*/
const bindingKey = (b: BindingEntry): string =>
export const bindingKey = (b: BindingEntry): string =>
b.synthetic ? `${b.name}@module` : `${b.name}:${b.declLine}:${b.declColumn}`;
/**

View file

@ -43,6 +43,16 @@ export interface ProgramPoint {
readonly line: number;
}
/**
* Canonical `block:stmt` string key for a program point. Colon-separated to
* match the codebase's `blockIndex:stmtIndex` id conventions. Shared by the
* taint propagation engine (dedup/state keys) and the taint emit path
* (persisted edge-id material) so the two never drift.
*/
export function pointKey(p: ProgramPoint): string {
return `${p.blockIndex}:${p.stmtIndex}`;
}
/** One def→use fact: the definition at `def` reaches the use at `use`. */
export interface DefUseFact {
/** Index into {@link FunctionDefUse.bindings}. */

View file

@ -42,6 +42,92 @@ export interface BindingEntry {
readonly synthetic?: boolean;
}
/**
* One occurrence of a binding inside a call/new site's argument position
* (#2083 M3 U1). A bare `number` is a DIRECT occurrence (binding index into
* {@link FunctionCfg.bindings}); a `[bindingIdx, viaSiteIdx]` tuple marks an
* occurrence that reaches this argument THROUGH the nested site at
* `viaSiteIdx` (an index into the SAME statement's {@link StatementFacts.sites}
* array). The tag is load-bearing for sanitizer interposition (plan KTD4a):
* a flat per-arg binding set cannot distinguish `exec(escape(x))` (kill) from
* `exec(x)` (finding) — the single most common safe pattern would
* false-positive without it.
*/
export type SiteArgOccurrence = number | readonly [number, number];
/**
* One call site, constructor call, or value-position member read harvested
* from a statement (#2083 M3 U1, plan KTD2). Worker-side substrate for the M3
* taint pass: the M2 facts carry no expression structure, and the main thread
* cannot re-parse (the #1983 OOM shape). Spec-AGNOSTIC — records structure
* only, never source/sink/sanitizer-ness (matching is a main-thread concern).
*
* Integer indices: binding fields (`receiver`/`object`/`resultDefs`/arg
* occurrences) index {@link FunctionCfg.bindings}; site references (`parent`,
* via-tags) index the OWNING statement's `sites` array. JSON-plain; NO field
* here may be named `nodeId` (durable parsedfile-store reviver hazard — see
* {@link BindingEntry}).
*/
export interface SiteRecord {
readonly kind: 'call' | 'new' | 'member-read';
/**
* Dotted callee path for call/new sites whose callee chain is rooted at an
* identifier/`this`/`super` (`child_process.exec`, `req.body.toString`).
* Optional chaining is normalized (`a?.b()` ⇒ `a.b`); string-literal
* subscripts fold into the path (`cp["exec"]` ⇒ `cp.exec`). Absent when the
* chain is not statically resolvable (dynamic key, call-rooted chain).
*/
readonly callee?: string;
/**
* Binding index of the callee chain's ROOT identifier when the callee is a
* member chain (`userInput.trim()` ⇒ `userInput`). Method calls launder
* taint without it (plan KTD5 receiver-position TITO). Absent for bare
* calls (`exec(x)`) and non-identifier roots.
*/
readonly receiver?: number;
/**
* Per-argument-position occurrence entries (trailing empty positions are
* trimmed; absent when no argument carries a binding occurrence). For
* `template: true` sites every substitution occurrence aggregates at
* position 0 (tagged templates have no positional argument list).
*/
readonly args?: ReadonlyArray<readonly SiteArgOccurrence[]>;
/**
* Bindings defined by a declarator/assignment whose ENTIRE value (after
* unwrapping parens/`await`/`as`/`!`) is this call — `const b = escape(t)`
* ⇒ `[b]`. Per-declarator: `const a = t, b = escape(t)` attaches `[b]`
* only. Kill placement (KTD4b) keys on this: a sanitizer kills exactly the
* defs that receive its result directly.
*/
readonly resultDefs?: readonly number[];
/**
* `[siteIdx, argIdx]` of the innermost enclosing call/new site argument
* position this site occurs in (`exec(escape(x))` ⇒ escape's parent is
* `[execSiteIdx, 0]`). Absent for top-level sites.
*/
readonly parent?: readonly [number, number];
/**
* Index of the FIRST spread argument (`exec(...args)` ⇒ 0). Presence means
* position matching must degrade soundly (any sink position ≥ this index —
* plan KTD2/U2). A number (not boolean) because the matcher needs the index.
*/
readonly spread?: number;
/** Tagged-template call (`sql\`…${id}\``) — argument positions are not positional. */
readonly template?: boolean;
/**
* String-literal first argument when the callee is bare `require` —
* CommonJS aliases resolve like ESM imports on the main thread (KTD7).
*/
readonly requireArg?: string;
/** Member read: binding index of the object root (`req.body` ⇒ `req`). */
readonly object?: number;
/**
* Member read: property name (`req.body` ⇒ `'body'`; `req["body"]`
* included; dynamic `req[key]` is never recorded — documented KTD10 FN).
*/
readonly property?: string;
}
/**
* Def/use facts for one harvested statement (or construct header), in
* execution order within its block (#2082 M2 U1). `defs`/`uses` are indices
@ -57,12 +143,19 @@ export interface BindingEntry {
* treating them as must-defs would falsely kill the prior def on the
* not-taken path (a taint false negative on core JS idioms). Optional —
* absent means none.
*
* `sites` (#2083 M3 U1): call/member-read structure for the taint pass —
* see {@link SiteRecord}. Optional and omit-when-empty; absent on pre-M3
* channels and on statements with no calls or member reads. Sites inside
* nested functions are NOT recorded (consistent with def/use invisibility —
* the enclosing `arr.forEach(...)` call IS, with receiver `arr`).
*/
export interface StatementFacts {
readonly line: number;
readonly defs: readonly number[];
readonly uses: readonly number[];
readonly mayDefs?: readonly number[];
readonly sites?: readonly SiteRecord[];
}
/** A basic block: a maximal straight-line run of statements between leaders. */

View file

@ -39,7 +39,7 @@
* parsedfile-store reviver dedups objects keyed on that field name.
*/
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import type { BindingEntry, StatementFacts } from '../types.js';
import type { BindingEntry, SiteArgOccurrence, SiteRecord, StatementFacts } from '../types.js';
/** Node types that own a nested CFG — their subtrees are opaque to harvesting. */
const NESTED_FUNCTION_TYPES = new Set([
@ -83,6 +83,31 @@ const TYPE_CONTEXT_TYPES = new Set([
'asserts_annotation',
]);
/**
* Wrappers that don't change which VALUE flows through them (#2083 M3 U1) —
* unwrapped when resolving call-result attribution (`const b = (await
* escape(t))!` still attaches `resultDefs: [b]` to the escape site) and
* member-chain roots. Distinct from {@link TsHarvester.unwrapLvalue}, which is
* the narrower LVALUE set.
*/
const VALUE_WRAPPER_TYPES = new Set([
'parenthesized_expression',
'non_null_expression',
'as_expression',
'satisfies_expression',
'await_expression',
]);
/** Literal text of a `string` node (concatenated fragments; raw escapes kept). */
const stringLiteralText = (node: SyntaxNode): string => {
let out = '';
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c?.type === 'string_fragment' || c?.type === 'escape_sequence') out += c.text;
}
return out;
};
interface Scope {
readonly parent: Scope | null;
/** name → binding index */
@ -112,6 +137,16 @@ export class TsHarvester {
* here falsely kills the prior def on the not-taken path).
*/
private conditionalDepth = 0;
/**
* Call/new node id → bindings whose declarator/assignment VALUE is exactly
* that call (#2083 M3 U1). Registered by the declarator/assignment handlers
* BEFORE the value walk, consumed by {@link visitCall} when it reaches the
* node — the indirection keeps result-def attribution per-declarator
* (`const a = t, b = escape(t)` attaches `[b]` to the escape site only) and
* top-level-only (`const c = cond ? escape(b) : b` attaches nothing — the
* bypass occurrence must keep `c` taintable, plan KTD4a).
*/
private readonly resultDefTargets = new Map<number, number[]>();
constructor(private readonly fnNode: SyntaxNode) {
this.fnId = fnNode.id;
@ -458,7 +493,9 @@ export class TsHarvester {
// live def (`x = source(); var x; sink(x)` must keep source→sink;
// tri-review P2). `let`/`const` declarators genuinely initialize.
if (name && (value || t === 'lexical_declaration')) {
const snap = acc.defSnapshot();
this.walkDefPattern(name, acc);
if (value) this.registerResultDefs(value, acc.defsSince(snap));
}
if (value) this.walkValue(value, acc);
}
@ -466,7 +503,11 @@ export class TsHarvester {
case 'assignment_expression': {
const left = node.childForFieldName('left');
const right = node.childForFieldName('right');
if (left) this.walkDefPattern(this.unwrapLvalue(left), acc);
if (left) {
const snap = acc.defSnapshot();
this.walkDefPattern(this.unwrapLvalue(left), acc);
if (right) this.registerResultDefs(right, acc.defsSince(snap));
}
if (right) this.walkValue(right, acc);
return;
}
@ -552,6 +593,39 @@ export class TsHarvester {
if (body) this.walkValue(body, acc);
return;
}
case 'call_expression':
// #2083 M3 U1: explicit case (previously default-descended) — same
// uses, plus a taint-site record. MUST keep defs/uses byte-identical.
this.visitCall(node, acc, 'call');
return;
case 'new_expression':
this.visitCall(node, acc, 'new');
return;
case 'member_expression':
case 'subscript_expression':
// #2083 M3 U1: value-position member chain — same uses as the old
// default descent (root identifier + dynamic subscript indices), plus
// a member-read site for the innermost identifier-rooted access.
this.walkChain(node, acc, false);
return;
case 'sequence_expression': {
// Comma operator: only the LAST operand's value flows. Earlier operands
// are evaluated for side effects — record their uses but suppress
// occurrence fan-out so `exec((log(x), 'safe'))` does not taint exec's
// arg 0 with `x` (review fix). Defs/uses stay byte-identical to the old
// default descent; only the sites layer narrows.
const operands: SyntaxNode[] = [];
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c) operands.push(c);
}
const last = operands.length - 1;
operands.forEach((op, i) => {
if (i === last) this.walkValue(op, acc);
else acc.suppressOccurrences(() => this.walkValue(op, acc));
});
return;
}
default:
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
@ -593,8 +667,11 @@ export class TsHarvester {
case 'member_expression':
case 'subscript_expression':
// Property/element write — NOT a scalar def (KTD4); its identifiers
// (object, computed key) are uses.
this.walkValue(node, acc);
// (object, computed key) are uses. WRITE position (#2083 M3 U1): the
// written access itself is not a value read — no member-read site for
// it (`obj.p = q` records nothing; `req.body.x = v`'s mid-chain LOAD
// of `req.body` still does).
this.walkChain(node, acc, true);
return;
default:
for (let i = 0; i < node.namedChildCount; i++) {
@ -603,6 +680,201 @@ export class TsHarvester {
}
}
}
// ── taint-site harvest (#2083 M3 U1) ────────────────────────────────────
/** Strip value-transparent wrappers (`(x)`, `x!`, `x as T`, `await x`). */
private unwrapValueWrappers(node: SyntaxNode): SyntaxNode {
let n = node;
while (VALUE_WRAPPER_TYPES.has(n.type)) {
const inner = n.namedChild(0);
if (!inner) break;
n = inner;
}
return n;
}
/**
* When `value`'s root (after unwrapping) is a call/new node, remember that
* its site should carry `resultDefs: defs` — consumed by {@link visitCall}
* once the value walk reaches the node.
*/
private registerResultDefs(value: SyntaxNode, defs: readonly number[]): void {
if (defs.length === 0) return;
const root = this.unwrapValueWrappers(value);
if (root.type === 'call_expression' || root.type === 'new_expression') {
this.resultDefTargets.set(root.id, [...defs]);
}
}
/**
* Explicit call/new handler: records a call site (callee path, receiver,
* per-arg occurrence entries, spread/template markers, require literal,
* result defs) while reproducing EXACTLY the uses the old default descent
* recorded — callee chain root + dynamic subscript indices + arguments.
*/
private visitCall(node: SyntaxNode, acc: FactAccumulator, kind: 'call' | 'new'): void {
const calleeNode = node.childForFieldName(kind === 'new' ? 'constructor' : 'function');
const argsNode = node.childForFieldName('arguments');
const siteIdx = acc.openCallSite(kind);
acc.pushFrame(siteIdx);
let calleePath: string | undefined;
if (calleeNode) {
const callee = this.unwrapValueWrappers(calleeNode);
if (callee.type === 'identifier') {
// The callee NAME is a statement-level use but NOT a value occurrence
// flowing into any enclosing argument — `exec(escape(x))` must not
// put the `escape` binding itself into exec's arg 0 (only x, tagged
// via the escape site). Receiver-chain roots DO fan out (KTD5 TITO).
acc.addUseWithoutOccurrence(this.resolve(callee));
calleePath = callee.text;
} else if (callee.type === 'member_expression' || callee.type === 'subscript_expression') {
// skipFinalRead: the final access IS the callee, carried by the
// dotted path — recording it as a member read would double-count.
// Mid-chain reads (`req.body` inside `req.body.toString()`) ARE
// recorded (plan KTD2).
const chain = this.walkChain(callee, acc, true);
calleePath = chain.path;
if (chain.rootIdx !== undefined) acc.setSiteReceiver(siteIdx, chain.rootIdx);
} else {
// Call-rooted chains, IIFEs, function expressions — no dotted path;
// the walk still records uses and nested sites.
this.walkValue(callee, acc);
}
if (calleePath !== undefined) acc.setSiteCallee(siteIdx, calleePath);
}
const resultDefs = this.resultDefTargets.get(node.id);
if (resultDefs !== undefined) acc.setSiteResultDefs(siteIdx, resultDefs);
if (argsNode?.type === 'template_string') {
// Tagged template (`sql\`…${id}\``): the `arguments` field is a
// template_string, not an arguments node — substitution occurrences
// aggregate at position 0 and the site is marked non-positional.
acc.setSiteTemplate(siteIdx);
acc.setFrameArg(0);
this.walkValue(argsNode, acc);
} else if (argsNode) {
let pos = 0;
for (let i = 0; i < argsNode.namedChildCount; i++) {
const arg = argsNode.namedChild(i);
if (!arg || arg.type === 'comment') continue;
acc.setFrameArg(pos);
if (arg.type === 'spread_element') {
acc.setSiteSpread(siteIdx, pos);
const inner = arg.namedChild(0);
if (inner) this.walkValue(inner, acc);
} else {
if (kind === 'call' && pos === 0 && calleePath === 'require' && arg.type === 'string') {
// CommonJS `require('lit')` — record the literal so the matcher
// resolves require'd aliases like ESM imports (plan KTD7).
acc.setSiteRequireArg(siteIdx, stringLiteralText(arg));
}
this.walkValue(arg, acc);
}
pos++;
}
}
acc.popFrame();
}
/**
* Member/subscript chain walk shared by value position, write position, and
* callee position. Use-recording is identical to the old default descent
* (chain-root identifier once, dynamic subscript index expressions, full
* walk of non-identifier roots) — NO double-recording. Member-read sites:
* at most ONE per chain — the INNERMOST access — and only when the chain
* root is an identifier and the access's key is static (`.prop` or a
* string-literal subscript); `skipFinalRead` suppresses it when that access
* is the final one (callee / write target). Optional chaining (`?.`) never
* appears in the output (field-based traversal normalizes it); dynamic
* computed keys record nothing (documented KTD10 FN).
*/
private walkChain(
node: SyntaxNode,
acc: FactAccumulator,
skipFinalRead: boolean,
): { path?: string; rootIdx?: number } {
// Collect accesses outer→inner (unshift), then resolve the root.
const accesses: Array<{ prop?: string; dynamicIndex?: SyntaxNode }> = [];
let cur: SyntaxNode = this.unwrapValueWrappers(node);
for (;;) {
if (cur.type === 'member_expression') {
const prop = cur.childForFieldName('property');
accesses.unshift({ prop: prop?.text });
const obj = cur.childForFieldName('object');
if (!obj) break;
cur = this.unwrapValueWrappers(obj);
} else if (cur.type === 'subscript_expression') {
const index = cur.childForFieldName('index');
if (index?.type === 'string') {
accesses.unshift({ prop: stringLiteralText(index) });
} else {
accesses.unshift({ dynamicIndex: index ?? undefined });
}
const obj = cur.childForFieldName('object');
if (!obj) break;
cur = this.unwrapValueWrappers(obj);
} else {
break;
}
}
let rootIdx: number | undefined;
let rootSegment: string | undefined;
if (cur.type === 'identifier') {
rootIdx = this.resolve(cur);
acc.addUse(rootIdx);
rootSegment = cur.text;
} else if (cur.type === 'this' || cur.type === 'super') {
rootSegment = cur.text; // path segment only — `this`/`super` never bind
} else {
this.walkValue(cur, acc); // call-rooted etc. — uses + nested sites
}
// Dynamic subscript index expressions are real value reads (old default
// descent walked them) — inner→outer matches the old recording order.
for (const a of accesses) {
if (a.dynamicIndex) this.walkValue(a.dynamicIndex, acc);
}
const innermost = accesses[0];
if (
rootIdx !== undefined &&
innermost?.prop !== undefined &&
!(skipFinalRead && accesses.length === 1)
) {
acc.addMemberRead(rootIdx, innermost.prop);
}
const path =
rootSegment !== undefined && accesses.every((a) => a.prop !== undefined)
? [rootSegment, ...accesses.map((a) => a.prop as string)].join('.')
: undefined;
return { path, rootIdx };
}
}
/** Mutable build-time view of a {@link SiteRecord}. */
interface MutableSite {
kind: SiteRecord['kind'];
parent?: [number, number];
callee?: string;
receiver?: number;
args?: SiteArgOccurrence[][];
resultDefs?: number[];
spread?: number;
template?: boolean;
requireArg?: string;
object?: number;
property?: string;
}
/**
* One open call/new site during the walk (#2083 M3 U1). `argIdx` is the
* argument position currently being walked, or -1 while outside any argument
* (callee walk) — occurrences recorded then do NOT land in this frame's args
* (they still fan out to enclosing arg-active frames, via-tagged through this
* frame's site: the receiver of a nested call flows into the outer argument
* through that call).
*/
interface SiteFrame {
siteIdx: number;
argIdx: number;
}
/** Ordered, deduplicating def/use collector for one statement record. */
@ -613,6 +885,13 @@ class FactAccumulator {
private readonly defSeen = new Set<number>();
private readonly useSeen = new Set<number>();
private readonly mayDefSeen = new Set<number>();
/** Taint sites recorded for this statement (#2083 M3 U1). */
private readonly sites: MutableSite[] = [];
/** Composite (object|property|parent) keys of recorded member-read sites, so
* dedup is O(1) instead of a rescan of `sites` per read. */
private readonly memberReadKeys = new Set<string>();
/** Stack of open call/new sites — the occurrence fan-out targets. */
private readonly frames: SiteFrame[] = [];
constructor(private readonly line: number) {}
@ -630,6 +909,17 @@ class FactAccumulator {
}
addUse(idx: number): void {
// Occurrence fan-out happens BEFORE the statement-level dedup: `exec(x, x)`
// records x at BOTH arg positions even though `uses` lists it once.
this.recordOccurrence(idx);
this.addUseWithoutOccurrence(idx);
}
/**
* Statement-level use that is NOT a value occurrence in any open site
* argument — bare callee names only (#2083 M3 U1, see visitCall).
*/
addUseWithoutOccurrence(idx: number): void {
if (this.useSeen.has(idx)) return;
this.useSeen.add(idx);
this.uses.push(idx);
@ -643,6 +933,147 @@ class FactAccumulator {
return this.uses.length;
}
// ── site machinery (#2083 M3 U1) ─────────────────────────────────────────
/** `[defs.length, mayDefs.length]` marker for {@link defsSince}. */
defSnapshot(): readonly [number, number] {
return [this.defs.length, this.mayDefs.length];
}
/** Binding indices def'd (must- OR may-) since the snapshot was taken. */
defsSince(snap: readonly [number, number]): number[] {
return [...this.defs.slice(snap[0]), ...this.mayDefs.slice(snap[1])];
}
/** Open a call/new site; parent = innermost enclosing argument position. */
openCallSite(kind: 'call' | 'new'): number {
const site: MutableSite = { kind };
const parent = this.innermostArgPosition();
if (parent) site.parent = parent;
this.sites.push(site);
return this.sites.length - 1;
}
pushFrame(siteIdx: number): void {
this.frames.push({ siteIdx, argIdx: -1 });
}
popFrame(): void {
this.frames.pop();
}
/** Set the argument position the top frame is currently walking. */
setFrameArg(argIdx: number): void {
const top = this.frames[this.frames.length - 1];
if (top) top.argIdx = argIdx;
}
/**
* Run `fn` with all open arg frames temporarily detached (argIdx = -1), so
* identifier reads inside still record USES but do NOT fan occurrences into
* the enclosing sink-argument position. Used for the non-value operands of a
* sequence (comma) expression — only the final operand's value flows.
*/
suppressOccurrences(fn: () => void): void {
const saved = this.frames.map((f) => f.argIdx);
for (const f of this.frames) f.argIdx = -1;
try {
fn();
} finally {
this.frames.forEach((f, i) => {
f.argIdx = saved[i];
});
}
}
setSiteCallee(siteIdx: number, callee: string): void {
this.sites[siteIdx].callee = callee;
}
setSiteReceiver(siteIdx: number, receiver: number): void {
this.sites[siteIdx].receiver = receiver;
}
setSiteResultDefs(siteIdx: number, resultDefs: readonly number[]): void {
this.sites[siteIdx].resultDefs = [...resultDefs];
}
setSiteSpread(siteIdx: number, firstSpreadArg: number): void {
const site = this.sites[siteIdx];
if (site.spread === undefined) site.spread = firstSpreadArg;
}
setSiteTemplate(siteIdx: number): void {
this.sites[siteIdx].template = true;
}
setSiteRequireArg(siteIdx: number, literal: string): void {
this.sites[siteIdx].requireArg = literal;
}
/**
* Record a value-position member read. Exact duplicates within the
* statement (same object/property/parent position) dedup; reads at
* DIFFERENT argument positions stay distinct (`exec(req.body, req.body)`
* is two occurrences — KTD6 finding identity needs both).
*/
addMemberRead(object: number, property: string): void {
const parent = this.innermostArgPosition();
const dedupKey = `${object}|${property}|${parent ? `${parent[0]}:${parent[1]}` : 'top'}`;
if (this.memberReadKeys.has(dedupKey)) return;
this.memberReadKeys.add(dedupKey);
const site: MutableSite = { kind: 'member-read' };
if (parent) site.parent = parent;
site.object = object;
site.property = property;
this.sites.push(site);
}
private innermostArgPosition(): [number, number] | undefined {
for (let i = this.frames.length - 1; i >= 0; i--) {
const f = this.frames[i];
if (f.argIdx >= 0) return [f.siteIdx, f.argIdx];
}
return undefined;
}
/**
* Fan a binding occurrence out to every arg-active open frame. The entry is
* via-tagged with the site of the IMMEDIATELY nested frame when one exists:
* `exec(escape(x))` puts a plain `x` in escape's arg 0 and `[x, escapeIdx]`
* in exec's arg 0 — the KTD4a interposition substrate.
*/
private recordOccurrence(idx: number): void {
for (let i = this.frames.length - 1; i >= 0; i--) {
const f = this.frames[i];
if (f.argIdx < 0) continue;
const via = i + 1 < this.frames.length ? this.frames[i + 1].siteIdx : undefined;
this.pushArgEntry(f.siteIdx, f.argIdx, idx, via);
}
}
private pushArgEntry(
siteIdx: number,
argIdx: number,
bindingIdx: number,
via: number | undefined,
): void {
const site = this.sites[siteIdx];
const args = (site.args ??= []);
while (args.length <= argIdx) args.push([]);
const list = args[argIdx];
// Dedup exact (binding, via) pairs per position — `f(x + x)` is one entry;
// `f(x + g(x))` keeps the plain AND the via-tagged entry (distinct paths).
for (const e of list) {
const match =
typeof e === 'number'
? via === undefined && e === bindingIdx
: via !== undefined && e[0] === bindingIdx && e[1] === via;
if (match) return;
}
list.push(via === undefined ? bindingIdx : [bindingIdx, via]);
}
finish(): StatementFacts {
return {
line: this.line,
@ -651,6 +1082,21 @@ class FactAccumulator {
// Optional field stays absent when empty — keeps the serialized
// side-channel payload lean (most statements have no may-defs).
...(this.mayDefs.length > 0 ? { mayDefs: this.mayDefs } : {}),
// Sites likewise omit-when-empty (#2083 M3 U1): flag-off runs never
// harvest, and most fact-bearing statements carry no calls.
...(this.sites.length > 0 ? { sites: this.sites.map(finalizeSite) } : {}),
};
}
}
/** Trim trailing empty arg positions; drop `args` entirely when all-empty. */
const finalizeSite = (site: MutableSite): SiteRecord => {
const args = site.args;
if (args !== undefined) {
let end = args.length;
while (end > 0 && args[end - 1].length === 0) end--;
if (end === 0) delete site.args;
else if (end < args.length) site.args = args.slice(0, end);
}
return site as SiteRecord;
};

View file

@ -82,6 +82,22 @@ export interface PipelineOptions {
* programmatic / server path only, like the M1 caps.
*/
pdgMaxReachingDefEdgesPerFunction?: number;
/**
* Per-function taint findings cap for the scope-resolution taint pass
* (#2083 M3). `undefined` ⇒ `DEFAULT_PDG_MAX_TAINT_FINDINGS_PER_FUNCTION`
* (200); `0` ⇒ no cap (unlimited). Emit-time-only — NOT folded into the
* parse-cache chunk key; recorded resolved in `RepoMeta.pdg` so a cap
* change forces a full writeback. No CLI flag or rc key (KTD8) —
* programmatic / server path only, like the other pdg caps.
*/
pdgMaxTaintFindingsPerFunction?: number;
/**
* Per-finding taint hop cap (#2083 M3, KTD6 — bounds the persisted
* hop-encoded `reason`). `undefined` ⇒ `DEFAULT_PDG_MAX_TAINT_HOPS` (32);
* `0` ⇒ no cap (unlimited). Same emit-time-only / RepoMeta-stamped /
* no-CLI-flag discipline as `pdgMaxTaintFindingsPerFunction`.
*/
pdgMaxTaintHops?: 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

View file

@ -354,6 +354,8 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
pdg: ctx.options?.pdg === true,
pdgMaxEdgesPerFunction: ctx.options?.pdgMaxEdgesPerFunction,
pdgMaxReachingDefEdgesPerFunction: ctx.options?.pdgMaxReachingDefEdgesPerFunction,
pdgMaxTaintFindingsPerFunction: ctx.options?.pdgMaxTaintFindingsPerFunction,
pdgMaxTaintHops: ctx.options?.pdgMaxTaintHops,
recordResolutionOutcome: (outcome) => {
resolutionOutcomes.push(outcome);
},

View file

@ -40,7 +40,16 @@ import {
isEmitSafeCfg,
DEFAULT_MAX_CFG_EDGES_PER_FUNCTION,
DEFAULT_PDG_MAX_REACHING_DEF_EDGES_PER_FUNCTION,
REACHING_DEF_FACTS_PER_EDGE_CAP,
} from '../../cfg/emit.js';
import {
emitFileTaint,
DEFAULT_PDG_MAX_TAINT_FINDINGS_PER_FUNCTION,
DEFAULT_PDG_MAX_TAINT_HOPS,
type TaintEmitLimits,
} from '../../taint/emit.js';
import { registerBuiltinTaintModels } from '../../taint/typescript-model.js';
import { getSourceSinkConfig } from '../../taint/source-sink-registry.js';
import type { FunctionCfg } from '../../cfg/types.js';
import { resolveDefGraphId } from '../graph-bridge/ids.js';
import { buildPopulatedMethodDispatch } from '../graph-bridge/method-dispatch.js';
@ -273,6 +282,14 @@ interface RunScopeResolutionInput {
/** Per-function REACHING_DEF edge cap (#2082 M2). `undefined` ⇒
* {@link DEFAULT_PDG_MAX_REACHING_DEF_EDGES_PER_FUNCTION}; `0` ⇒ no cap. */
readonly pdgMaxReachingDefEdgesPerFunction?: number;
/** Per-function taint findings cap (#2083 M3, consumed by the U4 taint
* emit step in the pdg window). `undefined` ⇒
* `DEFAULT_PDG_MAX_TAINT_FINDINGS_PER_FUNCTION` (200); `0` ⇒ no cap. */
readonly pdgMaxTaintFindingsPerFunction?: number;
/** Per-finding taint hop cap (#2083 M3 KTD6 — bounds the hop-encoded
* `reason`; consumed by the U4 taint emit step). `undefined` ⇒
* `DEFAULT_PDG_MAX_TAINT_HOPS` (32); `0` ⇒ no cap. */
readonly pdgMaxTaintHops?: number;
/**
* Optional graph-node lookup built ONCE by the caller and shared across
* every language pass. `buildGraphNodeLookup` scans the whole graph and is
@ -713,6 +730,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;
// 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
// can bracket it. Printed as the PROF `taint=` segment.
let taintMs = 0;
if (input.pdg === true) {
let cfgBlocks = 0;
let cfgEdges = 0;
@ -721,6 +743,47 @@ export function runScopeResolution(
let rdDropped = 0;
let rdFacts = 0;
let rdTruncated = 0;
// ── M3 taint setup (#2083 U4) ────────────────────────────────────────
// Explicit model-registration seam (idempotent, cheap) — the registry
// stays empty on non-pdg runs, preserving default-run parity. The
// registry is keyed by `SupportedLanguages` enum VALUES ('typescript' /
// 'javascript'), and `ScopeResolver.language` IS a `SupportedLanguages`
// member registered under those same constants — the join is direct
// equality, no mapping table. A language without a registered spec
// (python, go, …) skips taint entirely: no work, no warn spam (KTD8).
registerBuiltinTaintModels();
const taintSpec = getSourceSinkConfig(provider.language);
// Taint-side solver fact cap: the SAME derivation emitFileReachingDefs
// uses for the RD projection (edge cap × headroom factor, 0 ⇒ unlimited),
// so taint coverage and RD coverage truncate together — a function is
// never a taint coverage gap while its RD projection computed, and the
// RD layer's per-function truncation warn already names it.
const rdEdgeCap =
input.pdgMaxReachingDefEdgesPerFunction ?? DEFAULT_PDG_MAX_REACHING_DEF_EDGES_PER_FUNCTION;
const taintLimits: TaintEmitLimits = {
maxFindingsPerFunction:
input.pdgMaxTaintFindingsPerFunction ?? DEFAULT_PDG_MAX_TAINT_FINDINGS_PER_FUNCTION,
maxHops: input.pdgMaxTaintHops ?? DEFAULT_PDG_MAX_TAINT_HOPS,
maxFacts: rdEdgeCap > 0 ? rdEdgeCap * REACHING_DEF_FACTS_PER_EDGE_CAP : 0,
};
// Cross-file aggregate of EVERY TaintEmitResult counter (the M2 emit
// result shipped with two fields dropped on the floor — R4 forbids that
// here; gaps/drops feed the unconditional warn below, volume feeds the
// per-language debug line).
const taintTotals = {
analyzed: 0,
noMatch: 0,
unsafeSites: 0,
gapTruncated: 0,
gapOverflow: 0,
gapNoFacts: 0,
findings: 0,
kills: 0,
dropped: 0,
hopsTruncated: 0,
gapExamples: [] as string[],
dropExamples: [] as string[],
};
for (const pf of emitParsedFiles) {
const cfgs = pf.cfgSideChannel;
// Defensive: cfgSideChannel is opaque (`unknown`) and crosses the cache /
@ -777,6 +840,39 @@ export function runScopeResolution(
rdDropped += rd.droppedEdges;
rdFacts += rd.facts;
rdTruncated += rd.truncatedFunctions;
// M3 (#2083 U4): taint over the SAME validated CFGs, inside the SAME
// per-file try (a taint throw costs this file's taint layer only —
// its CFG/REACHING_DEF edges above are already in the graph). Skipped
// entirely when the language has no registered model.
if (taintSpec !== undefined) {
const t1 = PROF ? performance.now() : 0;
const taint = emitFileTaint(
graph,
wellFormed,
pf.parsedImports,
taintSpec,
taintLimits,
(message) => logger.warn(message), // unconditional — R4/R6
);
if (PROF) taintMs += performance.now() - t1;
taintTotals.analyzed += taint.functionsAnalyzed;
taintTotals.noMatch += taint.functionsSkippedNoMatch;
taintTotals.unsafeSites += taint.functionsSkippedUnsafeSites;
taintTotals.gapTruncated += taint.functionsCoverageGap.truncated;
taintTotals.gapOverflow += taint.functionsCoverageGap.overflow;
taintTotals.gapNoFacts += taint.functionsCoverageGap['no-facts'];
taintTotals.findings += taint.findingsEmitted;
taintTotals.kills += taint.killsEmitted;
taintTotals.dropped += taint.findingsDropped;
taintTotals.hopsTruncated += taint.hopsTruncatedFindings;
for (const ex of taint.coverageGapExamples) {
if (taintTotals.gapExamples.length < 5) taintTotals.gapExamples.push(ex);
}
for (const ex of taint.droppedExamples) {
if (taintTotals.dropExamples.length < 5) taintTotals.dropExamples.push(ex);
}
}
} catch (err) {
// Last-resort isolation, mirroring the worker-side per-file try/catch:
// a shape the predicate misses must cost this one file's CFG, not
@ -798,9 +894,54 @@ export function runScopeResolution(
(cfgDroppedEdges > 0 ? `, ${cfgDroppedEdges} edges dropped (per-function cap)` : '') +
`; ${rdEdges} REACHING_DEF edges (${rdFacts} facts)` +
(rdDropped > 0 ? `, ${rdDropped} REACHING_DEF edges dropped (per-function cap)` : '') +
(rdTruncated > 0 ? `, ${rdTruncated} function(s) hit the fact limit` : ''),
(rdTruncated > 0 ? `, ${rdTruncated} function(s) hit the fact limit` : '') +
// M3 volume telemetry — only for languages with a registered model.
(taintSpec !== undefined
? `; taint: ${taintTotals.findings} TAINTED, ${taintTotals.kills} SANITIZES ` +
`(${taintTotals.analyzed} function(s) analyzed, ` +
`${taintTotals.noMatch} skipped: no source/sink match` +
(taintTotals.hopsTruncated > 0
? `, ${taintTotals.hopsTruncated} finding(s) with truncated hop paths`
: '') +
`)`
: ''),
);
}
// R4: taint coverage gaps and cap drops surface UNCONDITIONALLY (never
// logger.debug, never input.onWarn) at the per-language aggregate, with
// counts and up to 5 example functions. Per-function warns above cover
// the rare/actionable cases (unsafe sites, cap drops); solver-status gaps
// were already per-function-warned by the RD layer (same solver, same
// fact cap), so this aggregate is their single taint-side surface.
if (taintSpec !== undefined) {
const gapCount =
taintTotals.unsafeSites +
taintTotals.gapTruncated +
taintTotals.gapOverflow +
taintTotals.gapNoFacts;
if (gapCount > 0 || taintTotals.dropped > 0) {
const parts: string[] = [];
if (gapCount > 0) {
parts.push(
`${gapCount} function(s) skipped for taint ` +
`(${taintTotals.gapTruncated} fact-limit, ${taintTotals.gapOverflow} overflow, ` +
`${taintTotals.gapNoFacts} no-facts, ${taintTotals.unsafeSites} malformed sites)` +
(taintTotals.gapExamples.length > 0
? ` — e.g. ${taintTotals.gapExamples.join(', ')}`
: ''),
);
}
if (taintTotals.dropped > 0) {
parts.push(
`${taintTotals.dropped} finding(s) dropped by the per-function cap` +
(taintTotals.dropExamples.length > 0
? ` — e.g. ${taintTotals.dropExamples.join(', ')}`
: ''),
);
}
logger.warn(`[taint] lang=${provider.language}: ${parts.join('; ')}`);
}
}
}
if (PROF) {
@ -813,7 +954,8 @@ export function runScopeResolution(
` resolve=${ns(tPropagate, tResolve).toFixed(0)}ms` +
` emit=${ns(tResolve, tEnd).toFixed(0)}ms` +
// pdg ⊆ emit: the M2 reaching-defs share of the emit bucket (#2082 U4).
(input.pdg === true ? ` pdg=${pdgMs.toFixed(0)}ms` : '') +
// taint ⊆ emit likewise: the M3 match+solve+propagate+emit share (#2083 U4).
(input.pdg === true ? ` pdg=${pdgMs.toFixed(0)}ms taint=${taintMs.toFixed(0)}ms` : '') +
` total=${ns(tStart, tEnd).toFixed(0)}ms` +
` (${parsedFiles.length} files)`,
);

View file

@ -0,0 +1,297 @@
/**
* In-phase taint emission (#2083 M3 U4, plan KTD1/KTD6).
*
* Per-file driver for the M3 taint pass: gate → match → solve → propagate →
* persist sparse `TAINTED` + `SANITIZES` edges. Invoked from the pdg window in
* scope-resolution (`pipeline/run.ts`), immediately after `emitFileReachingDefs`
* inside the SAME per-file try — per-file isolation for free (KTD1). Mirrors
* `emitFileReachingDefs` (cfg/emit.ts) for the budget/dedup/warn discipline and
* the telemetry-result shape.
*
* ## Per-function pipeline (ordering is load-bearing)
*
* 1. `hasTaintSafeSites` — a corrupted-store site annotation degrades to
* SKIP-TAINT-KEEP-RD for this function (counted + warned), never a crash
* (KTD2; the matcher/propagator dereference indices unvalidated).
* 2. `matchFunctionSites` against the language spec (the import index is
* built ONCE per file — imports are a file-level fact).
* 3. ZERO-MATCH FAST PATH: the solver runs only when the function has at
* least one matched source AND one matched sink. In a typical repo almost
* no function has both; an unconditional second `computeReachingDefs` per
* function would ship a near-2× solve cost to every `--pdg` user.
* 4. `computeReachingDefs` with the taint `maxFacts` — by DEFAULT the M2
* derived `DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION` (deliberate
* reuse, not a new constant: the fact-materialization envelope is a
* memory question, O(defs×uses), orthogonal to the findings cap, and M2
* already validated exactly this envelope on the same solver in the same
* window). The run.ts caller derives `limits.maxFacts` from the SAME
* RD-edge-cap formula `emitFileReachingDefs` uses, so taint coverage and
* RD coverage truncate together — a function is never `truncated` for one
* layer and `computed` for the other.
* 5. `computeTaintFlows` — a non-`computed` status is a per-function
* COVERAGE GAP (R4: counted by `gapReason`, function skipped entirely,
* never partially analyzed).
* 6. Emit one `TAINTED` edge per finding and one `SANITIZES` edge per kill.
* Kills are emitted even when findings are zero — a fully-sanitized
* function's kills are exactly its evidence of safety.
*
* ## Identity, dedup, budget (KTD6)
*
* Findings carry STATEMENT-LEVEL identity — function anchor + sink kind +
* source occurrence (point/site/object-binding/property) + sink occurrence
* (point/site/arg/binding) — NOT the REACHING_DEF block-level key (block-pair
* conflation would drop `exec(req.body, req.query)`'s second finding). The
* propagation engine dedups by this exact key BEFORE its deterministic cap
* (`maxFindingsPerFunction`) and counts the overflow; this module templates
* the same coordinates into the edge id (binding identity via the shared
* `bindingKey`; the free-text `property` rides LAST so it can never collide
* into another component) and warns with the drop count on truncation.
*
* `reason` carries the versioned hop encoding (`taint/path-codec.ts` — U6's
* `explain` decodes the same module) for `TAINTED`, and the killed binding's
* plain name for `SANITIZES` (M0/S1 queryability verdict, like REACHING_DEF).
*
* ## Warn split (R4 vs noise)
*
* Unsafe-site skips and cap drops warn PER FUNCTION here (rare, actionable —
* mirrors `emitFileReachingDefs`' malformed/cap warns). Solver coverage gaps
* (`truncated`/`overflow`) do NOT re-warn per function: the RD layer already
* warned for the same function with the same solver status (same `maxFacts`
* derivation — see step 4), and a duplicate `[taint]` line per mega-function
* would be pure spam. They are counted (+ exampled) in the result and the
* run.ts caller aggregates them into ONE unconditional `logger.warn` per
* language (R4) — never dropped on the floor (the M2 lesson).
*/
import type { ParsedImport } from 'gitnexus-shared';
import type { KnowledgeGraph } from '../../graph/types.js';
import { generateId } from '../../../lib/utils.js';
import {
basicBlockId,
bindingKey,
DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION,
} from '../cfg/emit.js';
import { computeReachingDefs, pointKey, type ProgramPoint } from '../cfg/reaching-defs.js';
import type { BindingEntry, FunctionCfg } from '../cfg/types.js';
import { hasTaintSafeSites } from './site-safety.js';
import { buildTaintImportIndex, matchFunctionSites } from './match.js';
import {
computeTaintFlows,
DEFAULT_PDG_MAX_TAINT_FINDINGS_PER_FUNCTION,
DEFAULT_PDG_MAX_TAINT_HOPS,
} from './propagate.js';
// Re-exported so the pipeline (run.ts) sources the taint default caps through
// this orchestration module rather than reaching into propagate.ts directly.
export {
DEFAULT_PDG_MAX_TAINT_FINDINGS_PER_FUNCTION,
DEFAULT_PDG_MAX_TAINT_HOPS,
} from './propagate.js';
import { encodeTaintPath } from './path-codec.js';
import type { SourceSinkSanitizerSpec } from './source-sink-config.js';
/** Cap on example anchors carried per result (aggregate-warn material, R4). */
const MAX_EXAMPLES = 5;
export interface TaintEmitLimits {
/** Per-function findings cap (post-dedup). `undefined` ⇒
* {@link DEFAULT_PDG_MAX_TAINT_FINDINGS_PER_FUNCTION}; `0` ⇒ unlimited. */
readonly maxFindingsPerFunction?: number;
/** Per-finding hop cap (source-side prefix kept). `undefined` ⇒
* {@link DEFAULT_PDG_MAX_TAINT_HOPS}; `0` ⇒ unlimited. */
readonly maxHops?: number;
/**
* Solver fact-materialization cap for the taint-side `computeReachingDefs`
* call. `undefined` ⇒ {@link DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION}
* (the M2 derived default — see the module doc for why it is REUSED rather
* than derived from the findings cap); `0` ⇒ unlimited.
*/
readonly maxFacts?: number;
}
/**
* Full taint-emit telemetry for one file. EVERY counter is surfaced by the
* run.ts aggregate (the M2 emit result had two fields dropped on the floor —
* the plan names that mistake; don't repeat it).
*/
export interface TaintEmitResult {
/** Functions fully propagated (`computeTaintFlows` returned `computed`). */
functionsAnalyzed: number;
/** Functions skipped by the zero-match fast path (no solver call). */
functionsSkippedNoMatch: number;
/** Functions whose `sites` failed {@link hasTaintSafeSites} (skip-taint-keep-RD). */
functionsSkippedUnsafeSites: number;
/** Source+sink functions skipped on a non-`computed` solver status (R4). */
functionsCoverageGap: { truncated: number; overflow: number; 'no-facts': number };
/** TAINTED edges persisted. */
findingsEmitted: number;
/** SANITIZES edges persisted (emitted even when findings are zero). */
killsEmitted: number;
/** Findings dropped by the per-function cap (post-dedup), summed. */
findingsDropped: number;
/** Findings whose persisted hop path is a truncated prefix (hop/byte cap). */
hopsTruncatedFindings: number;
/** ≤{@link MAX_EXAMPLES} `file:line` anchors of gap/unsafe-site functions. */
coverageGapExamples: string[];
/** ≤{@link MAX_EXAMPLES} `file:line` anchors of cap-dropped functions. */
droppedExamples: string[];
}
const pushExample = (list: string[], anchor: string): void => {
if (list.length < MAX_EXAMPLES) list.push(anchor);
};
/**
* Run the taint pass over one file's emit-safe CFGs and persist TAINTED +
* SANITIZES edges. `cfgs` MUST already be `isEmitSafeCfg`-filtered (the same
* `wellFormed` array the caller fed `emitFileCfgs`/`emitFileReachingDefs`) —
* block/edge anchors are trusted here; only the M3 `sites` layer is
* re-validated (`hasTaintSafeSites`). Never throws on well-formed input;
* the caller's per-file try isolates the rest.
*/
export function emitFileTaint(
graph: KnowledgeGraph,
cfgs: readonly FunctionCfg[],
parsedImports: readonly ParsedImport[],
spec: SourceSinkSanitizerSpec,
limits?: TaintEmitLimits,
onWarn?: (message: string) => void,
): TaintEmitResult {
const result: TaintEmitResult = {
functionsAnalyzed: 0,
functionsSkippedNoMatch: 0,
functionsSkippedUnsafeSites: 0,
functionsCoverageGap: { truncated: 0, overflow: 0, 'no-facts': 0 },
findingsEmitted: 0,
killsEmitted: 0,
findingsDropped: 0,
hopsTruncatedFindings: 0,
coverageGapExamples: [],
droppedExamples: [],
};
// Imports are a FILE-level fact — build the index once, not per function.
const importIndex = buildTaintImportIndex(parsedImports);
const maxFindingsPerFunction =
limits?.maxFindingsPerFunction ?? DEFAULT_PDG_MAX_TAINT_FINDINGS_PER_FUNCTION;
const maxHops = limits?.maxHops ?? DEFAULT_PDG_MAX_TAINT_HOPS;
const maxFacts = limits?.maxFacts ?? DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION;
// Defensive cross-CFG id guard: finding identity is unique WITHIN a
// function by construction (the propagation engine dedups), so a repeat can
// only mean two CFGs sharing an anchor — skip, never double-insert.
const seenEdgeIds = new Set<string>();
for (const cfg of cfgs) {
const { filePath, functionStartLine, functionStartColumn } = cfg;
const anchor = `${filePath}:${functionStartLine}`;
if (!hasTaintSafeSites(cfg)) {
result.functionsSkippedUnsafeSites++;
pushExample(result.coverageGapExamples, anchor);
onWarn?.(
`[taint] ${anchor}: malformed site annotations (out-of-range binding/site ` +
`indices) — taint skipped for this function; its CFG and REACHING_DEF ` +
`layers are unaffected`,
);
continue;
}
const matches = matchFunctionSites(cfg, spec, importIndex);
if (!matches.hasSource || !matches.hasSink) {
// Zero-match fast path: no solver call (see module doc step 3).
result.functionsSkippedNoMatch++;
continue;
}
const defUse = computeReachingDefs(cfg, { maxFacts });
const flows = computeTaintFlows(cfg, defUse, matches, { maxFindingsPerFunction, maxHops });
if (flows.status === 'coverage-gap') {
// R4: skipped entirely, counted by reason; aggregate-warned by the
// caller (the RD layer already per-function-warned this solver status).
result.functionsCoverageGap[flows.gapReason ?? 'no-facts']++;
pushExample(result.coverageGapExamples, anchor);
continue;
}
result.functionsAnalyzed++;
const bindings: readonly BindingEntry[] = cfg.bindings ?? [];
const fnAnchor = `${filePath}:${functionStartLine}:${functionStartColumn}`;
const blockId = (p: ProgramPoint): string =>
basicBlockId(filePath, functionStartLine, functionStartColumn, p.blockIndex);
const bKey = (idx: number): string => {
const b = bindings[idx];
return b === undefined ? `#${idx}` : bindingKey(b);
};
// SANITIZES — one edge per kill, REGARDLESS of findings (kills can and do
// exist with zero findings: a fully-sanitized flow IS the kill evidence).
for (const kill of flows.kills) {
const id = generateId(
'SANITIZES',
`${fnAnchor}:${pointKey(kill.sanitizer)}->${pointKey(kill.killedDef)}:` +
bKey(kill.bindingIdx),
);
if (seenEdgeIds.has(id)) continue;
seenEdgeIds.add(id);
graph.addRelationship({
id,
type: 'SANITIZES',
sourceId: blockId(kill.sanitizer),
targetId: blockId(kill.killedDef),
confidence: 1.0,
reason: bindings[kill.bindingIdx]?.name ?? `#${kill.bindingIdx}`,
});
result.killsEmitted++;
}
// TAINTED — one edge per finding (already deduped + capped upstream).
for (const finding of flows.findings) {
const { source, sink } = finding;
// KTD6 statement-level identity: function anchor + kind + source
// occurrence + sink occurrence + binding keys. The rule-(b) occurrence
// coordinates (site index / arg index) distinguish
// `exec(req.body, req.query)`'s two findings; `property` is free-text
// (string-literal subscripts) and rides LAST so it cannot collide into
// another component.
const id = generateId(
'TAINTED',
`${fnAnchor}:${finding.sinkKind}:` +
`${pointKey(source.point)}.${source.siteIndex}:${bKey(source.objectBindingIdx)}:` +
`${pointKey(sink.point)}.${sink.siteIndex}.${sink.argIndex}:${bKey(sink.bindingIdx)}:` +
`${sink.entryName}:${source.property}`,
);
if (seenEdgeIds.has(id)) continue;
seenEdgeIds.add(id);
// `kind` rides the reason's `;<kind>` header — the only persisted
// channel for the finding's category (the edge id embedding it is not a
// stored column; `step` is INT32). U6's `explain` decodes it back.
const encoded = encodeTaintPath(
finding.hops.map((h) => ({ name: h.name, line: h.point.line, viaCall: h.viaCall })),
{ truncated: finding.hopsTruncated === true, kind: finding.sinkKind },
);
if (encoded.truncated) result.hopsTruncatedFindings++;
graph.addRelationship({
id,
type: 'TAINTED',
sourceId: blockId(source.point),
targetId: blockId(sink.point),
confidence: 1.0,
reason: encoded.reason,
});
result.findingsEmitted++;
}
if (flows.droppedFindings > 0) {
result.findingsDropped += flows.droppedFindings;
pushExample(result.droppedExamples, anchor);
onWarn?.(
`[taint] ${anchor}: per-function taint findings cap ` +
`(${maxFindingsPerFunction}) reached — dropped ${flows.droppedFindings} of ` +
`${flows.findings.length + flows.droppedFindings} deduped findings`,
);
}
}
return result;
}

View file

@ -0,0 +1,374 @@
/**
* Import-aware taint-site matcher (#2083 M3 U2, plan KTD7).
*
* Classifies a function's harvested {@link SiteRecord}s against a registered
* {@link SourceSinkSanitizerSpec}: which member reads are SOURCES, which
* call/new sites are SINKS (and at which argument positions), and which are
* SANITIZERS. Pure main-thread data work — sites + bindings come from the U1
* worker harvest, imports from `ParsedFile.parsedImports`; no AST, no I/O.
*
* PRECONDITION: the caller must gate the CFG through `hasTaintSafeSites`
* (taint/site-safety.ts) first — this module dereferences binding/site
* indices without re-validating them.
*
* ## Callee resolution precedence (bare and member-rooted calls)
*
* 1. ESM import join — the callee root's local name is resolved through the
* {@link TaintImportIndex} built from `parsedImports` (`named`/`alias`
* members, `namespace`/default-import module handles); `import { exec as
* run } from 'child_process'` makes `run(c)` resolve to
* `child_process.exec`, and `import * as cp …` makes `cp.exec(c)` resolve
* the same way.
* 2. require-literal join — a binding whose in-function defining site carries
* `requireArg` resolves like a namespace handle (`const cp =
* require('child_process'); cp.exec(c)`). A BARE call of a require-joined
* binding is matched under BOTH interpretations, `<module>.default` (the
* module/default export invoked directly) and `<module>.<localName>`
* (non-renamed destructured require — the harvest attaches `resultDefs`
* to destructured bindings without recording the property path, and the
* binding name IS the member name in the non-renamed case).
* 3. Bare-name fallback — TRUE GLOBALS only (`global: true` entries: `eval`,
* `new Function`, `encodeURIComponent`), and only when the name is neither
* import-bound nor shadowed. Conventional receiver names (`req`/`request`
* member-read sources, `res.send`, `.query`/`.execute`) are matched
* name-based by their own mechanisms, never via the global fallback.
*
* ## Shadowing rule (exact)
*
* A name is treated as function-local — blocking import/global resolution —
* iff the function's binding table contains a NON-`synthetic` entry with that
* name (an in-function `function exec(){}` / `const exec = …`). Synthetic
* bindings (kind `module`, `synthetic: true`) are imports, true globals, or
* enclosing-scope captures and do not shadow. Member-call roots use the
* harvested `receiver` binding index directly (no name scan).
*
* ## Documented resolution gaps (direction stated, per plan KTD10)
*
* - MODULE-LEVEL `const cp = require('child_process')`: the binding is
* synthetic inside the function, produces no `ParsedImport`, and its
* defining site lives outside the function's harvested sites — the
* require join cannot see it. Module-mechanism sinks miss (FN) and
* sanitizers don't kill (FP noise — never a false kill, the safe
* direction). Only in-function requires resolve.
* - RENAMED destructured require (`const { exec: run } = require(…)`):
* the dual interpretation resolves `run` to `child_process.run` — no
* match (FN). Non-renamed destructures resolve exactly.
* - CONSERVATIVE shadow scan for bare calls: ANY non-synthetic binding of
* the callee name anywhere in the function blocks import/global
* resolution, even when the shadow is block-scoped elsewhere and the call
* site actually sees the import (FN; rare; safe for sanitizers).
* - MODULE-LEVEL user declarations are indistinguishable from imports in
* the binding table (both synthetic). ESM forbids a module-level
* declaration colliding with an import name, so the import join is
* authoritative when an import exists; a module-level user function
* shadowing a TRUE GLOBAL (e.g. a local `encodeURIComponent`) is not
* detectable and would still match (pathological; accepted).
* - Handle COPIES (`const c2 = cp; c2.exec(…)`) are not followed — joins
* are one level deep (binding → import/require), never through
* assignments (FN).
* - `this.`/`super.`-rooted and call-rooted callee chains have no
* resolvable root: only the syntactic `anyReceiver`/`receivers`
* mechanisms can match them.
* - `reexport`/`wildcard`/`dynamic-*`/`side-effect` imports introduce no
* matcher-visible local binding and are skipped by the index.
*/
import type { ParsedImport } from 'gitnexus-shared';
import type { FunctionCfg, SiteRecord } from '../cfg/types.js';
import type {
SourceSinkSanitizerSpec,
TaintMemberSourceEntry,
TaintSanitizerEntry,
TaintSinkEntry,
} from './source-sink-config.js';
/** What a local name imported into the file denotes. */
export interface TaintImportBinding {
/** Normalized module specifier (`node:` scheme stripped). */
readonly module: string;
/**
* Exported member bound by a named/aliased import; `undefined` when the
* local name is a MODULE HANDLE (namespace import, or a default import —
* CJS interop makes the default export ≈ the module object).
*/
readonly member?: string;
}
/** Local name → import provenance for one file. Build once per file (U4). */
export type TaintImportIndex = ReadonlyMap<string, TaintImportBinding>;
/** A member-read site matched as a taint source. */
export interface MatchedSourceRead {
/** Index into the owning statement's `sites` array. */
readonly siteIndex: number;
readonly entry: TaintMemberSourceEntry;
}
/** A call/new site matched as a sink. */
export interface MatchedSinkCall {
/** Index into the owning statement's `sites` array. */
readonly siteIndex: number;
readonly entry: TaintSinkEntry;
/**
* Positions (indices into `site.args`) that are registered sink positions
* AND carry at least one recorded binding occurrence, after the spread
* rule (a recorded position ≥ `site.spread` matches when any registered
* position ≥ the spread index exists — runtime positions after a spread
* are unknowable) and the template rule (`template: true` aggregates all
* substitutions at position 0 and matches any-position). Never empty — a
* sink whose dangerous positions carry no occurrences cannot produce a
* finding and is not reported.
*/
readonly argPositions: readonly number[];
}
/** A call site matched as a sanitizer (import-aware/global only — see module doc). */
export interface MatchedSanitizerCall {
/** Index into the owning statement's `sites` array. */
readonly siteIndex: number;
readonly entry: TaintSanitizerEntry;
/**
* Bindings the sanitizer's result defines directly (`const b = escape(t)`
* ⇒ `b`) — U3's kill targets (KTD4b). Empty for value-position sanitizer
* calls (`exec(escape(x))`), whose effect is occurrence INTERPOSITION via
* the site's `parent`/via-tag chain, not a def kill.
*/
readonly resultDefs: readonly number[];
}
/** All matches within one statement. Emitted only when at least one list is non-empty. */
export interface StatementMatches {
readonly blockIndex: number;
readonly statementIndex: number;
readonly line: number;
readonly sources: readonly MatchedSourceRead[];
readonly sinks: readonly MatchedSinkCall[];
readonly sanitizers: readonly MatchedSanitizerCall[];
}
/** Classified sites for one function, in (block, statement, site, entry) order. */
export interface FunctionSiteMatches {
readonly statements: readonly StatementMatches[];
/** Fast-path gates for U4: the solver runs only when both are true. */
readonly hasSource: boolean;
readonly hasSink: boolean;
}
const stripNodeScheme = (specifier: string): string =>
specifier.startsWith('node:') ? specifier.slice('node:'.length) : specifier;
/**
* Build the local-name → module/member index from a file's `parsedImports`.
* Only `named`/`alias`/`namespace` kinds bind matcher-visible local names;
* `importedName === 'default'` collapses to a module handle.
*/
export function buildTaintImportIndex(imports: readonly ParsedImport[]): TaintImportIndex {
const index = new Map<string, TaintImportBinding>();
for (const imp of imports) {
if (imp.kind === 'named' || imp.kind === 'alias') {
const module = stripNodeScheme(imp.targetRaw);
index.set(
imp.localName,
imp.importedName === 'default' ? { module } : { module, member: imp.importedName },
);
} else if (imp.kind === 'namespace') {
index.set(imp.localName, { module: stripNodeScheme(imp.targetRaw) });
}
}
return index;
}
/** Internal: a callee's resolution — canonical dotted names + syntactic path. */
interface ResolvedCallee {
/** Syntactic dotted path segments (`cp.exec` ⇒ `['cp','exec']`). */
readonly path: readonly string[];
/** Module-resolved canonical names this callee may denote. */
readonly canonical: readonly string[];
/** True when the bare root may denote an ECMAScript global (unshadowed, un-imported). */
readonly globalRoot: boolean;
}
/**
* Classify a function's harvested sites against a language spec. See the
* module doc for resolution precedence, the shadowing rule, and gaps.
*/
export function matchFunctionSites(
cfg: FunctionCfg,
spec: SourceSinkSanitizerSpec,
imports: TaintImportIndex,
): FunctionSiteMatches {
const bindings = cfg.bindings ?? [];
// Non-synthetic (in-function-declared) binding indices by name — the
// shadow scan + bare-call require-join lookup.
const nonSyntheticByName = new Map<string, number[]>();
bindings.forEach((b, i) => {
if (b.synthetic === true) return;
const list = nonSyntheticByName.get(b.name);
if (list) list.push(i);
else nonSyntheticByName.set(b.name, [i]);
});
// require-literal join: binding index → module specifier. A binding def'd
// by two DIFFERENT require literals is conflicted → dropped (resolving it
// either way could fabricate a sanitizer kill).
const requireByBinding = new Map<number, string>();
const conflicted = new Set<number>();
for (const block of cfg.blocks) {
for (const stmt of block.statements ?? []) {
for (const site of stmt.sites ?? []) {
if (site.requireArg === undefined || site.resultDefs === undefined) continue;
const module = stripNodeScheme(site.requireArg);
for (const def of site.resultDefs) {
if (conflicted.has(def)) continue;
const prior = requireByBinding.get(def);
if (prior === undefined) requireByBinding.set(def, module);
else if (prior !== module) {
requireByBinding.delete(def);
conflicted.add(def);
}
}
}
}
}
const resolveCallee = (site: SiteRecord): ResolvedCallee | undefined => {
if (site.callee === undefined) return undefined;
const path = site.callee.split('.');
const root = path[0];
const rest = path.slice(1);
const canonical: string[] = [];
let globalRoot = false;
if (site.receiver !== undefined) {
// Member chain with an identifier root — origin known by binding index.
const rb = bindings[site.receiver];
if (rb.synthetic === true) {
const imp = imports.get(rb.name);
if (imp !== undefined) {
const base = imp.member === undefined ? [imp.module] : [imp.module, imp.member];
canonical.push([...base, ...rest].join('.'));
}
} else {
const module = requireByBinding.get(site.receiver);
if (module !== undefined) canonical.push([module, ...rest].join('.'));
}
} else if (path.length === 1) {
// Bare call. `this`/`super`/call-rooted chains never get here (those
// are dotted-without-receiver or callee-less).
const locals = nonSyntheticByName.get(root);
if (locals !== undefined) {
// Shadowed by an in-function declaration — only the require join
// applies, under the dual interpretation (module doc).
for (const idx of locals) {
const module = requireByBinding.get(idx);
if (module !== undefined) canonical.push(`${module}.default`, `${module}.${root}`);
}
} else {
const imp = imports.get(root);
if (imp !== undefined) {
canonical.push(
imp.member === undefined ? `${imp.module}.default` : `${imp.module}.${imp.member}`,
);
} else {
globalRoot = true;
}
}
}
return { path, canonical, globalRoot };
};
/** The spread/template/registered-position rule (see MatchedSinkCall doc). */
const positionMatches = (entry: TaintSinkEntry, site: SiteRecord, p: number): boolean => {
if (site.template === true) return true;
if (entry.args === undefined) return true;
if (site.spread !== undefined && p >= site.spread) {
const spread = site.spread;
return entry.args.some((q) => q >= spread);
}
return entry.args.includes(p);
};
const sinkMechanismHit = (
entry: TaintSinkEntry,
site: SiteRecord,
r: ResolvedCallee,
): boolean => {
if (entry.module !== undefined) return r.canonical.includes(`${entry.module}.${entry.name}`);
if (entry.global === true) {
return (
r.globalRoot &&
r.path.length === 1 &&
r.path[0] === entry.name &&
(entry.newOnly !== true || site.kind === 'new')
);
}
if (entry.anyReceiver === true) {
return r.path.length >= 2 && r.path[r.path.length - 1] === entry.name;
}
if (entry.receivers !== undefined) {
return r.path.length === 2 && entry.receivers.includes(r.path[0]) && r.path[1] === entry.name;
}
return false;
};
// Sanitizers: module + global mechanisms ONLY — never receiver-conventional,
// never bare-name for non-globals (a false kill is the forbidden direction).
const sanitizerMechanismHit = (entry: TaintSanitizerEntry, r: ResolvedCallee): boolean => {
if (entry.module !== undefined) return r.canonical.includes(`${entry.module}.${entry.name}`);
if (entry.global === true) {
return r.globalRoot && r.path.length === 1 && r.path[0] === entry.name;
}
return false;
};
const statements: StatementMatches[] = [];
let hasSource = false;
let hasSink = false;
cfg.blocks.forEach((block, blockIndex) => {
block.statements?.forEach((stmt, statementIndex) => {
const sites = stmt.sites;
if (sites === undefined || sites.length === 0) return;
const sources: MatchedSourceRead[] = [];
const sinks: MatchedSinkCall[] = [];
const sanitizers: MatchedSanitizerCall[] = [];
sites.forEach((site, siteIndex) => {
if (site.kind === 'member-read') {
if (site.object === undefined || site.property === undefined) return;
const objectName = bindings[site.object].name;
const property = site.property;
for (const entry of spec.sources) {
if (entry.objects.includes(objectName) && entry.properties.includes(property)) {
sources.push({ siteIndex, entry });
}
}
return;
}
// call / new
const resolved = resolveCallee(site);
if (resolved === undefined) return;
for (const entry of spec.sinks) {
if (!sinkMechanismHit(entry, site, resolved)) continue;
const argPositions: number[] = [];
site.args?.forEach((occurrences, p) => {
if (occurrences.length > 0 && positionMatches(entry, site, p)) argPositions.push(p);
});
if (argPositions.length > 0) sinks.push({ siteIndex, entry, argPositions });
}
for (const entry of spec.sanitizers) {
if (!sanitizerMechanismHit(entry, resolved)) continue;
sanitizers.push({ siteIndex, entry, resultDefs: site.resultDefs ?? [] });
}
});
if (sources.length === 0 && sinks.length === 0 && sanitizers.length === 0) return;
hasSource ||= sources.length > 0;
hasSink ||= sinks.length > 0;
statements.push({ blockIndex, statementIndex, line: stmt.line, sources, sinks, sanitizers });
});
});
return { statements, hasSource, hasSink };
}

View file

@ -0,0 +1,267 @@
/**
* Taint-path reason codec (#2083 M3 U4/U6, plan KTD6).
*
* THE one shared encoder/decoder for the hop-encoded `reason` carried on
* persisted `TAINTED` edges: the U4 emit path writes it, the U6 MCP `explain`
* tool reads it. Two hand-rolled copies of a wire format drift — both sides
* MUST import from here.
*
* ## Wire format (version `1`)
*
* ```
* 1[;<kind>]|<name>:<line>[:<flags>]|<name>:<line>[:<flags>]|…[|~]
* ```
*
* - One-character version prefix (`TAINT_PATH_CODEC_VERSION`), then an
* OPTIONAL `;<kind>` header segment, then ordered source→sink hops, each
* `variable:line[:flags]`.
* - `kind` is the finding's sink category (`SinkKind`, e.g.
* `command-injection`). It rides the reason because it is the ONLY
* persisted channel: the CodeRelation columns are
* `type/confidence/reason/step` — `step` is INT32 and the emit-time edge id
* (which embeds the kind) is not a stored column. The U6 `explain` tool
* reads it for finding classification. Charset `[a-z0-9-]` (printable
* ASCII, disjoint from every structural delimiter); `;` itself is printable
* ASCII and never appears in hop names (identifier charset) or flags.
* U6 deviation note: this header was added by U6 WITHIN version `1` —
* U4 and U6 ship in the same release, so no reason string without the
* header was ever persisted by a released build; the decoder still accepts
* header-less strings (`kind` simply decodes as `undefined`).
* - `flags` is a lowercase-letter set; only `c` (= the hop passed through an
* unmodeled call, KTD5 `viaCall`) is defined today — the rest of the
* alphabet is RESERVED, and the decoder accepts unknown flag letters so a
* future writer's output stays decodable.
* - A trailing `|~` segment is the TRUNCATION MARKER: the encoded path is a
* source-side PREFIX of the real one (hop cap, byte cap, or an unencodable
* hop name). Decoders MUST report it as "path incomplete" — never an error.
*
* ## Delimiter / round-trip discipline (KTD6)
*
* Every structural character (`|`, `:`, `~`, digits, flag letters) is
* printable ASCII: `sanitizeUTF8` (csv-generator.ts) strips control
* characters, lone surrogates, and U+FFFE/FFFF — printable ASCII passes
* through byte-exact, so the encoding survives `escapeCSVField ∘
* sanitizeUTF8` and the DB load unchanged (pinned by the round-trip test).
* None of the delimiters can appear in a JS identifier.
*
* Hop names are identifier-charset by U1 construction (the harvest records
* binding names), but the encoder DEFENDS anyway: a hop whose name falls
* outside the safe charset (or whose line is not a non-negative integer) is
* never emitted — encoding stops at the offending hop and sets the truncation
* marker, preserving the prefix-of-the-true-path invariant rather than
* corrupting the format. (`#` is in the charset: JS private names are
* `#field`, and the propagation engine's fallback hop names are `#<idx>`.)
*
* The byte cap (`TAINT_REASON_MAX_BYTES`, KTD6's "absolute reason-byte cap")
* bounds the persisted reason column regardless of hop caps: overflow drops
* TRAILING hops (keeps the source side) and sets the marker. All structural
* chars and valid names are single-byte ASCII, so `string.length` IS the
* byte length.
*/
/** One-character format version prefix. Bump on any wire-format change. */
export const TAINT_PATH_CODEC_VERSION = '1';
/**
* Absolute cap on the encoded reason's byte length (KTD6). 4096 comfortably
* holds ~100 hops of realistic identifiers — far beyond the default hop cap
* (32) — while bounding the persisted column even at `maxHops: 0` (unlimited).
*/
export const TAINT_REASON_MAX_BYTES = 4096;
/** The truncation-marker segment content (rides as a trailing `|~`). */
export const TAINT_PATH_TRUNCATION_MARKER = '~';
/**
* Safe hop-name charset: ASCII identifier characters plus `#` (JS private
* names / the propagation engine's `#<idx>` fallback). Deliberately ASCII-only
* — a Unicode identifier is VALID JS but is skipped (truncation marker) rather
* than risking a `sanitizeUTF8` byte change breaking decode (defensive
* simplification; documented FN on path completeness, never on the finding).
*/
const SAFE_NAME = /^[A-Za-z0-9_$#]+$/;
/** Decoder-side flags charset — `c` defined, the rest reserved (see module doc). */
const FLAGS = /^[a-z]*$/;
/**
* Kind-header charset: lowercase + digits + hyphen — covers every `SinkKind`
* label and stays disjoint from the structural delimiters (`;|:~`).
*/
const SAFE_KIND = /^[a-z0-9-]+$/;
/** Encoder input hop — shape-compatible with `TaintHop` (propagate.ts). */
export interface TaintPathHopInput {
readonly name: string;
readonly line: number;
readonly viaCall?: boolean;
}
export interface EncodeTaintPathOptions {
/**
* The hop list is already a truncated prefix (e.g. the propagation engine's
* `hopsTruncated` from its hop cap) — emit the marker even when every hop
* fits.
*/
readonly truncated?: boolean;
/** Byte cap override (tests). Default {@link TAINT_REASON_MAX_BYTES}. */
readonly maxBytes?: number;
/**
* Finding sink category (`SinkKind`) carried in the `;<kind>` header — the
* only persisted channel for it (see the module doc). A value outside the
* `[a-z0-9-]` charset is DROPPED (header omitted), never corrupted into the
* wire string; `SinkKind` is a closed lowercase-hyphen union so this is
* purely defensive.
*/
readonly kind?: string;
}
export interface EncodedTaintPath {
/** The wire string for the TAINTED edge's `reason` column. */
readonly reason: string;
/** True when the marker was emitted (caller-flagged, byte cap, or bad hop). */
readonly truncated: boolean;
}
export interface DecodedTaintHop {
readonly variable: string;
readonly line: number;
/** The hop passed through an unmodeled call (flag `c`, KTD5). */
readonly viaCall: boolean;
}
export interface DecodedTaintPath {
readonly ok: true;
readonly version: string;
/** Finding sink category from the `;<kind>` header; absent when not encoded. */
readonly kind?: string;
/** Ordered source→sink hops (a PREFIX when `truncated`). */
readonly hops: readonly DecodedTaintHop[];
/** Path incomplete (trailing `|~`) — informational, NOT an error. */
readonly truncated: boolean;
}
/** Typed parse failure — the decoder never throws. */
export interface TaintPathDecodeFailure {
readonly ok: false;
readonly error: string;
}
export type TaintPathDecodeResult = DecodedTaintPath | TaintPathDecodeFailure;
/**
* Encode an ordered hop list into the versioned `reason` wire string.
* Deterministic; never throws. See the module doc for the format and the
* three truncation triggers (caller flag, unencodable hop, byte cap).
*/
export function encodeTaintPath(
hops: readonly TaintPathHopInput[],
options?: EncodeTaintPathOptions,
): EncodedTaintPath {
// Kind header (defensively validated — see EncodeTaintPathOptions.kind).
const kindHeader =
typeof options?.kind === 'string' && SAFE_KIND.test(options.kind) ? `;${options.kind}` : '';
// Floor: version char + kind header + room for the marker — a smaller cap
// could not hold even the empty truncated path. The header is identity
// material (finding classification), so it is never sacrificed to the byte
// cap; trailing hops are.
const maxBytes = Math.max(
options?.maxBytes ?? TAINT_REASON_MAX_BYTES,
TAINT_PATH_CODEC_VERSION.length + kindHeader.length + 2,
);
let truncated = options?.truncated === true;
const segments: string[] = [];
let total = TAINT_PATH_CODEC_VERSION.length + kindHeader.length;
for (const hop of hops) {
if (
typeof hop.name !== 'string' ||
!SAFE_NAME.test(hop.name) ||
!Number.isInteger(hop.line) ||
hop.line < 0
) {
// Unencodable hop: drop it AND everything after it so the emitted hops
// stay a faithful source-side prefix (a silent mid-path gap would lie).
truncated = true;
break;
}
const segment = `|${hop.name}:${hop.line}${hop.viaCall === true ? ':c' : ''}`;
if (total + segment.length > maxBytes) {
truncated = true;
break;
}
segments.push(segment);
total += segment.length;
}
if (truncated) {
// Make room for the trailing `|~` marker (drop trailing hops as needed).
while (segments.length > 0 && total + 2 > maxBytes) {
total -= (segments.pop() as string).length;
}
}
const reason =
TAINT_PATH_CODEC_VERSION +
kindHeader +
segments.join('') +
(truncated ? `|${TAINT_PATH_TRUNCATION_MARKER}` : '');
return { reason, truncated };
}
/**
* Decode a `reason` wire string. Returns a typed failure for anything that is
* not a well-formed version-`1` path — never throws. A truncated path decodes
* `ok: true` with `truncated: true` ("path incomplete", per KTD6).
*/
export function decodeTaintPath(reason: unknown): TaintPathDecodeResult {
if (typeof reason !== 'string' || reason.length === 0) {
return { ok: false, error: 'empty or non-string reason' };
}
const version = reason[0];
if (version !== TAINT_PATH_CODEC_VERSION) {
return { ok: false, error: `unsupported taint-path version '${version}'` };
}
let body = reason.slice(1);
// Optional `;<kind>` header segment (finding sink category — see module doc).
let kind: string | undefined;
if (body.startsWith(';')) {
const headerEnd = body.indexOf('|');
kind = headerEnd === -1 ? body.slice(1) : body.slice(1, headerEnd);
if (!SAFE_KIND.test(kind)) {
return { ok: false, error: `invalid kind header '${kind}'` };
}
body = headerEnd === -1 ? '' : body.slice(headerEnd);
}
const hops: DecodedTaintHop[] = [];
if (body.length === 0)
return { ok: true, version, ...(kind ? { kind } : {}), hops, truncated: false };
if (!body.startsWith('|')) {
return { ok: false, error: 'malformed body: expected a hop separator after the version' };
}
const parts = body.slice(1).split('|');
let truncated = false;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (part === TAINT_PATH_TRUNCATION_MARKER) {
if (i !== parts.length - 1) {
return { ok: false, error: 'truncation marker not in trailing position' };
}
truncated = true;
break;
}
const fields = part.split(':');
if (fields.length < 2 || fields.length > 3) {
return { ok: false, error: `malformed hop segment '${part}'` };
}
const [name, lineStr, flags = ''] = fields;
if (!SAFE_NAME.test(name)) {
return { ok: false, error: `invalid hop variable '${name}'` };
}
if (!/^\d+$/.test(lineStr)) {
return { ok: false, error: `invalid hop line '${lineStr}'` };
}
if (!FLAGS.test(flags)) {
return { ok: false, error: `invalid hop flags '${flags}'` };
}
hops.push({ variable: name, line: Number(lineStr), viaCall: flags.includes('c') });
}
return { ok: true, version, ...(kind ? { kind } : {}), hops, truncated };
}

View file

@ -0,0 +1,880 @@
/**
* Pure intra-procedural taint propagation engine (#2083 M3 U3).
*
* Forward taint reachability over one function's reaching-definition facts
* (M2 `computeReachingDefs`) and matched taint sites (U2 `matchFunctionSites`)
* — sources in, findings + sanitizer kills + coverage status out. PURE AND
* DETERMINISTIC, mirroring the reaching-defs contract: no graph, no I/O, no
* logger; insertion-ordered worklist; explicitly sorted outputs; snapshot
* tests and content-derived edge ids (U4) rely on it.
*
* PRECONDITIONS: the caller gates the CFG through `hasTaintSafeSites`
* (taint/site-safety.ts) and the emit-safety checks before calling — this
* module dereferences binding/site/statement indices without re-validating.
*
* ## The two-rule model (plan HTD)
*
* - **Rule (b), statement-local:** a matched SOURCE occurrence (member read)
* whose intra-statement occurrence path — the member-read's `parent` chain —
* reaches a matched SINK argument position produces an immediate single-hop
* finding (`exec(req.body)`). The same statement SEEDS taint: every binding
* the statement defines becomes tainted (see the precision floor below).
* - **Rule (a), worklist:** for each tainted `(binding, defPoint)`, every
* def→use fact delivers the taint to a use statement, where occurrences of
* the binding in matched sink argument positions produce findings and the
* statement's own defs are tainted onward. The fact graph contains genuine
* cycles (loop back-edges, same-statement self-facts) — the visited-set
* discipline below is load-bearing, not defensive.
*
* ## Sanitizer semantics — the KIND-SET exclusion model (KTD4, sharpened)
*
* The plan sketches a binary kill; this module implements the strictly more
* precise SOUND refinement: a taint carries a set of *excluded* (neutralized)
* `SinkKind`s accumulated through sanitizer hops, and a sink fires unless its
* kind is in the taint's exclusion set. A binary kill is the special case
* where the sanitizer neutralizes the sink's kind; the kind-set model
* additionally keeps `const b = escape(req.body); db.query(b)` a FINDING
* (an HTML escaper does not neutralize SQL — un-tainting `b` outright would
* be a suppressed live injection, the forbidden false-negative direction)
* while still suppressing `res.send(b)` (xss IS neutralized).
*
* - **Occurrence interposition (KTD4a):** evaluated over the U1 site
* structure. An occurrence reaching a sink arg / def-feeding position
* through a matched sanitizer site accumulates that sanitizer's
* `neutralizes` kinds on that PATH; a direct occurrence contributes the
* empty set. Per-position narrowing (`entry.args`) is respected; receiver
* flow through a sanitizer is NOT neutralized (the receiver is not the
* sanitized payload), and spread/template positions are never neutralized
* (position unprovable) — both sound-direction choices (under-kill).
* - **Intersection over paths:** a def fed by several occurrence paths
* excludes a kind only when EVERY path neutralizes it
* (`const c = cond ? escape(b) : b` taints `c` with NO exclusions — the
* direct arm's ∅ intersects everything away). Equally, a taint re-derived
* along a second route keeps the INTERSECTION of the exclusion sets and is
* re-processed whenever the set SHRINKS — a less-neutralized taint is
* strictly more dangerous. Exclusion sets only shrink over a finite
* lattice, so the worklist terminates.
* - **Kill locality (KTD4b):** a kill applies to the def the sanitizer
* produces (`SiteRecord.resultDefs`) only; the flowing binding's own taint
* is untouched (`const c = escape(b); exec(b)` still finds `b`'s flow).
* `x = escape(x)` works because taint keys on the DEF POINT: the
* sanitizer statement's def enters the set with the sanitizer's kinds
* excluded, while the seed def keeps flowing wherever the CFG still
* carries it (zero-iteration loops, conditional sanitizers — may-path
* mechanics need no special handling here, kills are absent from facts).
*
* ## Statement-coalescing precision floor (documented FP)
*
* Statement facts conflate multi-declarator statements: a statement that
* uses tainted `b` and defines `c` taints `c` with NO exclusions even when
* the two are textually unrelated (`const a = clean(z), b = g(t)` floor-
* taints `a` from `t` — pinned by a test). The per-declarator `resultDefs`
* precision narrows the EXCLUSION computation (and powers kills) only — a
* def in a call's `resultDefs` is fed exactly through that call, so its
* exclusions come from the paths into it; when the tainted input provably
* never flows into that call, the floor still taints the def (sound) but
* records no kill (a kill requires evidence of flow through the sanitizer).
*
* ## Propagate-through (KTD5)
*
* Taint in any argument or in the receiver of an UNMODELED call flows to
* the call's result defs, marked `viaCall` on the hop so `explain` can
* express lower confidence. An occurrence that reaches the unmodeled call
* only through a sanitizer carries the neutralization through
* (`const y = unknownFn(escape(b))` excludes the sanitizer's kinds — the
* plan's deliberate precision choice over flat-conservative).
*
* ## Kills output
*
* `kills` records every sanitizer that ACTUALLY neutralized kinds on a
* flowing taint — U4 emits `SANITIZES` edges from them. Two shapes share the
* record: result-def kills (`killedDef` = the def the sanitizer produces;
* `bindingIdx` = that def's binding) and value-position interposition kills
* (`exec(escape(x))` — no def exists; `killedDef` = the sink statement's own
* point, `bindingIdx` = the interposed binding). Interposition kills are
* recorded only when the (input, sink, position) produced no finding — a
* bypassed sanitizer (`exec(x + escape(x))`) killed nothing.
*/
import type { FunctionCfg, SiteRecord, StatementFacts } from '../cfg/types.js';
import { pointKey } from '../cfg/reaching-defs.js';
import type { DefUseFact, FunctionDefUse, ProgramPoint } from '../cfg/reaching-defs.js';
import type {
FunctionSiteMatches,
MatchedSanitizerCall,
MatchedSinkCall,
StatementMatches,
} from './match.js';
import type { SinkKind, SourceKind } from './source-sink-config.js';
/**
* Default per-function findings cap (U5 config resolution; cfg/emit.ts
* DEFAULT_* pattern). Resolved into the RepoMeta `pdg` stamp by
* `resolvePdgConfig` so a cap change trips full writeback; `0` = unlimited
* is preserved like the other pdg caps. 200 is generous — a real function
* with more deduped source→sink findings is a fixture or a disaster, and
* the truncation is deterministic + counted (`droppedFindings`).
*/
export const DEFAULT_PDG_MAX_TAINT_FINDINGS_PER_FUNCTION = 200;
/**
* Default per-finding hop cap (U5; joins the RepoMeta `pdg` stamp like the
* findings cap). Bounds the persisted `reason` hop encoding (KTD6 pins the
* hop cap in config); 32 intra-procedural def→use hops is far beyond any
* legible path — overflow keeps the source-side prefix and sets
* `hopsTruncated`, parsed downstream as "path incomplete", never an error.
*/
export const DEFAULT_PDG_MAX_TAINT_HOPS = 32;
export interface TaintLimits {
/**
* Maximum findings per function AFTER dedup; the sorted finding list is
* truncated deterministically and the overflow counted in
* `droppedFindings`. `undefined`/0 ⇒ unlimited.
*/
readonly maxFindingsPerFunction?: number;
/**
* Maximum hops retained per finding (source-side prefix kept); overflow
* sets `hopsTruncated`. `undefined`/0 ⇒ unlimited.
*/
readonly maxHops?: number;
}
/** One hop of a finding's path — enough for U4's reason codec (name, line, flag). */
export interface TaintHop {
/** Index into the function's binding table. */
readonly bindingIdx: number;
/** Resolved binding name (carried so U4 never re-joins the table). */
readonly name: string;
readonly point: ProgramPoint;
/** The value passed through an unmodeled call to get here (KTD5). */
readonly viaCall?: boolean;
}
/**
* The KTD6 rule-(b) source identity material: the matched member-read
* occurrence itself — statement point + site index + object/property. For
* worklist findings this is the ROOT source the taint chain was seeded from.
*/
export interface TaintSourceOccurrence {
readonly point: ProgramPoint;
/** Index into the source statement's `sites` array. */
readonly siteIndex: number;
readonly objectBindingIdx: number;
readonly property: string;
readonly kind: SourceKind;
}
/** The sink side of a finding's identity: point + site + argument + binding. */
export interface TaintSinkOccurrence {
readonly point: ProgramPoint;
/** Index into the sink statement's `sites` array. */
readonly siteIndex: number;
/** Matched sink argument position the tainted occurrence landed in. */
readonly argIndex: number;
/**
* The binding whose occurrence reached the sink position (for rule-(b)
* findings: the source member-read's object binding).
*/
readonly bindingIdx: number;
/** The matched sink entry's `name` (e.g. `exec`) — finding classification. */
readonly entryName: string;
}
export interface TaintFinding {
readonly sinkKind: SinkKind;
readonly source: TaintSourceOccurrence;
readonly sink: TaintSinkOccurrence;
/**
* Ordered source→sink path, one path per finding (the CodeQL
* `--max-paths=1` convention): the taint chain's def hops followed by the
* sink-use hop. Rule-(b) findings carry the single sink-statement hop.
*/
readonly hops: readonly TaintHop[];
readonly hopsTruncated?: boolean;
}
/** A sanitizer that neutralized kinds on a flowing taint — U4's SANITIZES rows. */
export interface SanitizerKill {
/** Statement point of the sanitizer call site. */
readonly sanitizer: ProgramPoint;
/**
* The killed def's point (result-def kills — always the sanitizer's own
* statement in the intra-statement model) or the suppressed sink
* statement's point (value-position interposition kills).
*/
readonly killedDef: ProgramPoint;
/** The killed def's binding, or the interposed binding for value-position kills. */
readonly bindingIdx: number;
/** Sorted, deduped kinds the sanitizer neutralized at that position. */
readonly neutralized: readonly SinkKind[];
}
export interface FunctionTaintResult {
/**
* `computed` — full propagation ran.
* `coverage-gap` — the solver result was not `computed`; the function is
* skipped for findings entirely (R4: never partially
* analyzed), `gapReason` carries the solver status.
*/
readonly status: 'computed' | 'coverage-gap';
readonly gapReason?: 'truncated' | 'overflow' | 'no-facts';
/** Deduped (KTD6 identity), deterministically sorted, capped. */
readonly findings: readonly TaintFinding[];
readonly kills: readonly SanitizerKill[];
/** Findings dropped by `maxFindingsPerFunction` (post-dedup). */
readonly droppedFindings: number;
}
/** Canonical SinkKind order for deterministic `neutralized` arrays. */
const KIND_ORDER: readonly SinkKind[] = [
'code-injection',
'command-injection',
'path-traversal',
'sql-injection',
'xss',
];
const kindRank = new Map<SinkKind, number>(KIND_ORDER.map((k, i) => [k, i]));
const sortKinds = (kinds: Iterable<SinkKind>): SinkKind[] =>
[...new Set(kinds)].sort((a, b) => (kindRank.get(a) ?? 99) - (kindRank.get(b) ?? 99));
const EMPTY_KINDS: ReadonlySet<SinkKind> = new Set();
/** One intra-statement occurrence path (interposition evidence). */
interface OccPath {
/** Kinds neutralized along the path (union of traversed sanitizer hops). */
readonly kinds: ReadonlySet<SinkKind>;
/** The path traverses an unmodeled call/new site. */
readonly viaCall: boolean;
/** Matched sanitizers traversed, with the kinds each contributed. */
readonly sanitizers: ReadonlyArray<{ siteIndex: number; kinds: readonly SinkKind[] }>;
}
const DIRECT_PATH: OccPath = { kinds: EMPTY_KINDS, viaCall: false, sanitizers: [] };
/** Per-statement match/site context, indexed once per visited statement. */
interface StmtContext {
readonly point: ProgramPoint;
readonly facts: StatementFacts;
readonly sites: readonly SiteRecord[];
readonly sinksBySite: ReadonlyMap<number, readonly MatchedSinkCall[]>;
readonly sanitizersBySite: ReadonlyMap<number, readonly MatchedSanitizerCall[]>;
/** binding → site indices whose `resultDefs` contain it (kill targets). */
readonly resultDefSites: ReadonlyMap<number, readonly number[]>;
}
/** One tainted (binding, defPoint) with the current minimal exclusion set. */
interface TaintState {
readonly bindingIdx: number;
readonly point: ProgramPoint;
/** Mutable: only ever SHRINKS (intersection on re-derivation). */
exclusions: ReadonlySet<SinkKind>;
/** Taint-chain parent key, or undefined for seeds. */
parentKey?: string;
/** Root source occurrence the chain was seeded from. */
source: TaintSourceOccurrence;
viaCall: boolean;
/** Exclusion-set size at last processing — skips no-op requeues. */
processedSize: number;
}
/**
* Compute taint flows for one function. See the module doc for the two-rule
* model, the kind-set exclusion semantics, and the precision floor.
*/
export function computeTaintFlows(
cfg: FunctionCfg,
defUse: FunctionDefUse,
matches: FunctionSiteMatches,
limits?: TaintLimits,
): FunctionTaintResult {
if (defUse.status !== 'computed') {
return {
status: 'coverage-gap',
gapReason: defUse.status,
findings: [],
kills: [],
droppedFindings: 0,
};
}
const bindings = defUse.bindings;
// ── per-statement context (built lazily; statements revisit often) ────────
const matchByPoint = new Map<string, StatementMatches>();
for (const sm of matches.statements) {
matchByPoint.set(`${sm.blockIndex}:${sm.statementIndex}`, sm);
}
const ctxCache = new Map<string, StmtContext | undefined>();
const contextAt = (blockIndex: number, stmtIndex: number): StmtContext | undefined => {
const key = `${blockIndex}:${stmtIndex}`;
if (ctxCache.has(key)) return ctxCache.get(key);
const facts = cfg.blocks[blockIndex]?.statements?.[stmtIndex];
let ctx: StmtContext | undefined;
if (facts) {
const sm = matchByPoint.get(key);
const sinksBySite = new Map<number, MatchedSinkCall[]>();
const sanitizersBySite = new Map<number, MatchedSanitizerCall[]>();
for (const s of sm?.sinks ?? []) {
const list = sinksBySite.get(s.siteIndex);
if (list) list.push(s);
else sinksBySite.set(s.siteIndex, [s]);
}
for (const s of sm?.sanitizers ?? []) {
const list = sanitizersBySite.get(s.siteIndex);
if (list) list.push(s);
else sanitizersBySite.set(s.siteIndex, [s]);
}
const resultDefSites = new Map<number, number[]>();
facts.sites?.forEach((site, siteIndex) => {
for (const d of site.resultDefs ?? []) {
const list = resultDefSites.get(d);
if (list) list.push(siteIndex);
else resultDefSites.set(d, [siteIndex]);
}
});
ctx = {
point: { blockIndex, stmtIndex, line: facts.line },
facts,
sites: facts.sites ?? [],
sinksBySite,
sanitizersBySite,
resultDefSites,
};
}
ctxCache.set(key, ctx);
return ctx;
};
/** Kinds the matched sanitizers at `siteIndex` neutralize for input position `argPos`. */
const neutralizedAt = (ctx: StmtContext, siteIndex: number, argPos: number): SinkKind[] => {
const sans = ctx.sanitizersBySite.get(siteIndex);
if (!sans) return [];
const site = ctx.sites[siteIndex];
// Spread/template positions are never provably the sanitized argument —
// do not neutralize (sound: under-kill). Exact positions check `args`.
if (site.template === true || (site.spread !== undefined && argPos >= site.spread)) return [];
const kinds: SinkKind[] = [];
for (const san of sans) {
if (san.entry.args === undefined || san.entry.args.includes(argPos)) {
kinds.push(...san.entry.neutralizes);
}
}
return sortKinds(kinds);
};
/** A call/new site the model does not understand (anything but a matched sanitizer). */
const isUnmodeledCall = (ctx: StmtContext, siteIndex: number): boolean => {
const site = ctx.sites[siteIndex];
return site.kind !== 'member-read' && !ctx.sanitizersBySite.has(siteIndex);
};
const emerge = (ctx: StmtContext, siteIndex: number, argPos: number, inner: OccPath): OccPath => {
const added = neutralizedAt(ctx, siteIndex, argPos);
return {
kinds: added.length === 0 ? inner.kinds : new Set([...inner.kinds, ...added]),
viaCall: inner.viaCall || isUnmodeledCall(ctx, siteIndex),
sanitizers:
added.length === 0 ? inner.sanitizers : [...inner.sanitizers, { siteIndex, kinds: added }],
};
};
/**
* STRICT occurrence paths of binding `b` flowing OUT of site `siteIndex`'s
* result — found arg entries and the receiver only, each with the site's
* own neutralization/viaCall applied. Empty when `b` provably never flows
* in (the caller falls back to the floor and records NO kill). `guard`
* breaks corrupted-store via cycles (site-safety checks ranges, not
* acyclicity).
*/
const flowsOutOf = (
ctx: StmtContext,
b: number,
siteIndex: number,
guard: Set<number>,
): OccPath[] => {
if (guard.has(siteIndex)) return [];
guard.add(siteIndex);
const site = ctx.sites[siteIndex];
const out: OccPath[] = [];
site.args?.forEach((entries, argPos) => {
for (const e of entries) {
if (typeof e === 'number') {
if (e === b) out.push(emerge(ctx, siteIndex, argPos, DIRECT_PATH));
} else if (e[0] === b) {
// Via-tagged: the occurrence reaches this position THROUGH the
// nested site. When the nested site shows no recognized channel
// for `b` (callee-chain occurrences — dynamic subscript keys), the
// via-tag is still evidence of flow: fall back to a direct,
// UN-neutralized path (sound: never a false kill).
const inner = flowsOutOf(ctx, b, e[1], guard);
const paths =
inner.length > 0
? inner
: [{ ...DIRECT_PATH, viaCall: isUnmodeledCall(ctx, e[1]) } satisfies OccPath];
for (const p of paths) out.push(emerge(ctx, siteIndex, argPos, p));
}
}
});
if (site.receiver === b) {
// Receiver TITO (KTD5): the receiver's value flows through the call
// into its result — but a sanitizer does not neutralize its receiver.
out.push({ ...DIRECT_PATH, viaCall: isUnmodeledCall(ctx, siteIndex) });
}
guard.delete(siteIndex);
return out;
};
/** Occurrence paths of `b` INTO sink position (s, p) — no emergence from s. */
const pathsIntoPosition = (
ctx: StmtContext,
b: number,
siteIndex: number,
argPos: number,
): OccPath[] => {
const entries = ctx.sites[siteIndex].args?.[argPos] ?? [];
const out: OccPath[] = [];
for (const e of entries) {
if (typeof e === 'number') {
if (e === b) out.push(DIRECT_PATH);
} else if (e[0] === b) {
const inner = flowsOutOf(ctx, b, e[1], new Set());
if (inner.length > 0) out.push(...inner);
else out.push({ ...DIRECT_PATH, viaCall: isUnmodeledCall(ctx, e[1]) });
}
}
return out;
};
/**
* Walk a SOURCE member-read's `parent` chain. Linear (each site has one
* parent); invokes `onPosition` with the accumulated path BEFORE the
* ancestor's own emergence (the value flows INTO the ancestor at that
* position) — sink checks and stop-at-site joins both hang off it.
*/
const climbSourceChain = (
ctx: StmtContext,
srcSiteIndex: number,
onPosition: (siteIndex: number, argPos: number, sofar: OccPath) => boolean,
): void => {
const visited = new Set<number>([srcSiteIndex]);
let cur = ctx.sites[srcSiteIndex];
let sofar: OccPath = DIRECT_PATH;
while (cur.parent) {
const [siteIndex, argPos] = cur.parent;
if (visited.has(siteIndex)) return; // corrupted-store parent cycle
visited.add(siteIndex);
if (onPosition(siteIndex, argPos, sofar)) return;
sofar = emerge(ctx, siteIndex, argPos, sofar);
cur = ctx.sites[siteIndex];
}
};
/** Source path INTO site `target` (with target's emergence), or undefined. */
const sourceFlowsOutOf = (
ctx: StmtContext,
srcSiteIndex: number,
target: number,
): OccPath | undefined => {
let found: OccPath | undefined;
climbSourceChain(ctx, srcSiteIndex, (siteIndex, argPos, sofar) => {
if (siteIndex !== target) return false;
found = emerge(ctx, target, argPos, sofar);
return true;
});
return found;
};
// ── accumulators ──────────────────────────────────────────────────────────
const findingsByIdentity = new Map<string, TaintFinding>();
const killsByIdentity = new Map<
string,
{ kill: Omit<SanitizerKill, 'neutralized'>; kinds: Set<SinkKind> }
>();
const recordKill = (
sanitizer: ProgramPoint,
killedDef: ProgramPoint,
bindingIdx: number,
kinds: readonly SinkKind[],
): void => {
if (kinds.length === 0) return;
const key = `${pointKey(sanitizer)}|${pointKey(killedDef)}|${bindingIdx}`;
const existing = killsByIdentity.get(key);
if (existing) for (const k of kinds) existing.kinds.add(k);
else
killsByIdentity.set(key, {
kill: { sanitizer, killedDef, bindingIdx },
kinds: new Set(kinds),
});
};
// KTD6 statement-level finding identity: source occurrence + sink occurrence
// + kind (NOT entryName). Computed standalone so the worklist can dedup-check
// BEFORE the cost of chainHops (first write wins; dedup-before-budget).
const findingKey = (
sinkKind: SinkKind,
source: TaintSourceOccurrence,
sink: Pick<TaintSinkOccurrence, 'point' | 'siteIndex' | 'argIndex' | 'bindingIdx'>,
): string =>
[
sinkKind,
pointKey(source.point),
source.siteIndex,
source.objectBindingIdx,
source.property,
pointKey(sink.point),
sink.siteIndex,
sink.argIndex,
sink.bindingIdx,
].join('|');
const recordFinding = (
sinkKind: SinkKind,
source: TaintSourceOccurrence,
sink: TaintSinkOccurrence,
hops: TaintHop[],
hopsTruncated: boolean,
): void => {
const key = findingKey(sinkKind, source, sink);
if (findingsByIdentity.has(key)) return;
const maxHops = limits?.maxHops && limits.maxHops > 0 ? limits.maxHops : Infinity;
let truncated = hopsTruncated;
let kept = hops;
if (hops.length > maxHops) {
kept = hops.slice(0, maxHops);
truncated = true;
}
findingsByIdentity.set(key, {
sinkKind,
source,
sink,
hops: kept,
...(truncated ? { hopsTruncated: true } : {}),
});
};
// ── taint state ───────────────────────────────────────────────────────────
const taints = new Map<string, TaintState>();
const queue: string[] = [];
/** The (binding, def-point) portion of a state key — the def→use fact-table
* lookup key, source-independent. */
const defKey = (bindingIdx: number, point: ProgramPoint): string =>
`${bindingIdx}:${pointKey(point)}`;
/** Full taint-state key: the def-point portion plus a ROOT source-occurrence
* discriminator ({point, siteIndex} — the same source fields recordFinding's
* identity uses, deliberately excluding `kind`). Distinct sources reaching
* one def get distinct states, so a second source is no longer dropped
* (KTD6); same-source multi-path derivations still share a key so their
* exclusion sets intersect (the raw arm soundly wins). */
const stateKey = (
bindingIdx: number,
point: ProgramPoint,
source: TaintSourceOccurrence,
): string => `${defKey(bindingIdx, point)}#${pointKey(source.point)}:${source.siteIndex}`;
const deriveTaint = (
bindingIdx: number,
point: ProgramPoint,
exclusions: ReadonlySet<SinkKind>,
parentKey: string | undefined,
source: TaintSourceOccurrence,
viaCall: boolean,
): void => {
const key = stateKey(bindingIdx, point, source);
const existing = taints.get(key);
if (!existing) {
taints.set(key, {
bindingIdx,
point,
exclusions,
parentKey,
source,
viaCall,
processedSize: -1,
});
queue.push(key);
return;
}
// Monotone shrink: keep the intersection; re-process only when it got
// strictly smaller (a less-neutralized derivation is more dangerous).
const inter = new Set<SinkKind>();
for (const k of existing.exclusions) if (exclusions.has(k)) inter.add(k);
if (inter.size < existing.exclusions.size) {
existing.exclusions = inter;
existing.parentKey = parentKey;
existing.source = source;
existing.viaCall = viaCall;
queue.push(key);
}
};
/** Taint-chain hops from seed to `key`, with a cycle guard (re-derivation
* can rewire parents into a loop — truncate instead of spinning). */
const chainHops = (key: string): { hops: TaintHop[]; truncated: boolean } => {
const reversed: TaintHop[] = [];
const seen = new Set<string>();
let cur: string | undefined = key;
let truncated = false;
while (cur !== undefined) {
if (seen.has(cur)) {
truncated = true;
break;
}
seen.add(cur);
const t = taints.get(cur);
if (!t) break;
reversed.push({
bindingIdx: t.bindingIdx,
name: bindings[t.bindingIdx]?.name ?? `#${t.bindingIdx}`,
point: t.point,
...(t.viaCall ? { viaCall: true } : {}),
});
cur = t.parentKey;
}
return { hops: reversed.reverse(), truncated };
};
/** Intersection of path kind-sets; viaCall = any path through a call. */
const summarizePaths = (
paths: readonly OccPath[],
): { kinds: ReadonlySet<SinkKind>; viaCall: boolean } => {
let kinds: ReadonlySet<SinkKind> | undefined;
let viaCall = false;
for (const p of paths) {
viaCall ||= p.viaCall;
if (kinds === undefined) {
kinds = p.kinds;
} else {
const inter = new Set<SinkKind>();
for (const k of kinds) if (p.kinds.has(k)) inter.add(k);
kinds = inter;
}
}
return { kinds: kinds ?? EMPTY_KINDS, viaCall };
};
/**
* Taint every def of `ctx`'s statement from one input. `pathsInto(c)`
* supplies the input's strict occurrence paths into call site `c` —
* defining the resultDefs precision and the kill evidence; an empty list
* means "no provable flow" and the floor applies (taint with the input's
* own exclusions, no kill).
*/
const feedDefs = (
ctx: StmtContext,
inputExclusions: ReadonlySet<SinkKind>,
parentKey: string | undefined,
source: TaintSourceOccurrence,
pathsInto: (siteIndex: number) => OccPath[],
): void => {
const defs = [...ctx.facts.defs, ...(ctx.facts.mayDefs ?? [])];
if (defs.length === 0) return;
const seen = new Set<number>();
for (const d of defs) {
if (seen.has(d)) continue;
seen.add(d);
const rdSites = ctx.resultDefSites.get(d);
let addKinds: ReadonlySet<SinkKind> = EMPTY_KINDS;
let viaCall = false;
if (rdSites) {
const paths: OccPath[] = [];
for (const c of rdSites) paths.push(...pathsInto(c));
if (paths.length > 0) {
const summary = summarizePaths(paths);
addKinds = summary.kinds;
viaCall = summary.viaCall;
for (const p of paths) {
for (const san of p.sanitizers) {
recordKill(ctx.point, ctx.point, d, san.kinds);
}
}
}
// else: floor — tainted with no exclusions added, no kill (the input
// provably never flows into the producing call; conflation FP pinned).
}
const exclusions =
addKinds.size === 0 ? inputExclusions : new Set([...inputExclusions, ...addKinds]);
deriveTaint(d, ctx.point, exclusions, parentKey, source, viaCall);
}
};
// ── rule (b) + seeding: statements with matched sources ───────────────────
for (const sm of matches.statements) {
if (sm.sources.length === 0) continue;
const ctx = contextAt(sm.blockIndex, sm.statementIndex);
if (!ctx) continue;
for (const src of sm.sources) {
const srcSite = ctx.sites[src.siteIndex];
if (srcSite?.object === undefined || srcSite.property === undefined) continue;
const sourceOcc: TaintSourceOccurrence = {
point: ctx.point,
siteIndex: src.siteIndex,
objectBindingIdx: srcSite.object,
property: srcSite.property,
kind: src.entry.kind,
};
// Statement-local sink checks along the member-read's parent chain.
climbSourceChain(ctx, src.siteIndex, (siteIndex, argPos, sofar) => {
for (const sink of ctx.sinksBySite.get(siteIndex) ?? []) {
if (!sink.argPositions.includes(argPos)) continue;
const kind = sink.entry.kind;
if (!sofar.kinds.has(kind)) {
recordFinding(
kind,
sourceOcc,
{
point: ctx.point,
siteIndex,
argIndex: argPos,
bindingIdx: srcSite.object as number,
entryName: sink.entry.name,
},
[
{
bindingIdx: srcSite.object as number,
name: bindings[srcSite.object as number]?.name ?? `#${srcSite.object}`,
point: ctx.point,
...(sofar.viaCall ? { viaCall: true } : {}),
},
],
false,
);
} else {
for (const san of sofar.sanitizers) {
if (san.kinds.includes(kind)) {
recordKill(ctx.point, ctx.point, srcSite.object as number, san.kinds);
}
}
}
}
return false;
});
// Seed every def of the statement (precision floor + resultDefs kills).
feedDefs(ctx, EMPTY_KINDS, undefined, sourceOcc, (c) => {
const p = sourceFlowsOutOf(ctx, src.siteIndex, c);
return p ? [p] : [];
});
}
}
// ── rule (a): worklist over def→use facts ─────────────────────────────────
const factsByDef = new Map<string, DefUseFact[]>();
for (const f of defUse.facts) {
const key = defKey(f.bindingIdx, f.def);
const list = factsByDef.get(key);
if (list) list.push(f);
else factsByDef.set(key, [f]);
}
// Strict-FIFO worklist via a head cursor (not Array.shift, which is O(N) per
// dequeue). FIFO order is load-bearing beyond perf: chainHops reconstructs
// hops from the live `taints` map, whose parentKey/source/viaCall are
// rewritten order-sensitively on monotone shrink — so hop-content
// determinism is contingent on dequeue order matching enqueue order. Do NOT
// sort or reprioritize the worklist.
let head = 0;
while (head < queue.length) {
const key = queue[head++];
// Reclaim the consumed prefix periodically so the array doesn't grow
// unbounded across a long run (order-preserving — pure memory hygiene).
if (head > 1024 && head * 2 > queue.length) {
queue.splice(0, head);
head = 0;
}
const t = taints.get(key) as TaintState;
if (t.processedSize === t.exclusions.size) continue; // no-op requeue
t.processedSize = t.exclusions.size;
const b = t.bindingIdx;
const E = t.exclusions;
// Facts are keyed by (binding, def-point) only — look up by the def portion
// of this state, not the source-discriminated state key.
for (const fact of factsByDef.get(defKey(b, t.point)) ?? []) {
const ctx = contextAt(fact.use.blockIndex, fact.use.stmtIndex);
if (!ctx) continue;
// Sink check: occurrences of `b` at matched sink argument positions.
for (const [siteIndex, sinks] of ctx.sinksBySite) {
for (const sink of sinks) {
const kind = sink.entry.kind;
for (const argPos of sink.argPositions) {
const paths = pathsIntoPosition(ctx, b, siteIndex, argPos);
if (paths.length === 0) continue;
if (E.has(kind)) continue; // suppressed at def time; kill already recorded
const justify = paths.find((p) => !p.kinds.has(kind));
if (justify) {
const sinkOcc = {
point: ctx.point,
siteIndex,
argIndex: argPos,
bindingIdx: b,
entryName: sink.entry.name,
};
// Dedup BEFORE chainHops: already-recorded identities discard
// their hop chain anyway (first write wins), so skip the walk.
if (findingsByIdentity.has(findingKey(kind, t.source, sinkOcc))) continue;
const chain = chainHops(key);
chain.hops.push({
bindingIdx: b,
name: bindings[b]?.name ?? `#${b}`,
point: ctx.point,
...(justify.viaCall ? { viaCall: true } : {}),
});
recordFinding(kind, t.source, sinkOcc, chain.hops, chain.truncated);
} else {
// EVERY path interposed — value-position kill(s) held.
for (const p of paths) {
for (const san of p.sanitizers) {
if (san.kinds.includes(kind)) recordKill(ctx.point, ctx.point, b, san.kinds);
}
}
}
}
}
}
// Def-feed: the use statement's own defs become tainted.
feedDefs(ctx, E, key, t.source, (c) => flowsOutOf(ctx, b, c, new Set()));
}
}
// ── deterministic assembly ────────────────────────────────────────────────
const comparePoints = (a: ProgramPoint, b: ProgramPoint): number =>
a.blockIndex - b.blockIndex || a.stmtIndex - b.stmtIndex;
const findings = [...findingsByIdentity.values()].sort(
(a, b) =>
comparePoints(a.source.point, b.source.point) ||
a.source.siteIndex - b.source.siteIndex ||
comparePoints(a.sink.point, b.sink.point) ||
a.sink.siteIndex - b.sink.siteIndex ||
a.sink.argIndex - b.sink.argIndex ||
a.sink.bindingIdx - b.sink.bindingIdx ||
(kindRank.get(a.sinkKind) ?? 99) - (kindRank.get(b.sinkKind) ?? 99),
);
const maxFindings =
limits?.maxFindingsPerFunction && limits.maxFindingsPerFunction > 0
? limits.maxFindingsPerFunction
: Infinity;
const kept = findings.length > maxFindings ? findings.slice(0, maxFindings) : findings;
const kills = [...killsByIdentity.values()]
.map(({ kill, kinds }) => ({ ...kill, neutralized: sortKinds(kinds) }))
.sort(
(a, b) =>
comparePoints(a.sanitizer, b.sanitizer) ||
comparePoints(a.killedDef, b.killedDef) ||
a.bindingIdx - b.bindingIdx,
);
return {
status: 'computed',
findings: kept,
kills,
droppedFindings: findings.length - kept.length,
};
}

View file

@ -0,0 +1,103 @@
/**
* Taint-site safety validation (#2083 M3 U1, plan KTD2).
*
* Mirrors `hasEmitSafeFacts` (cfg/emit.ts): an untrusted `cfgSideChannel`
* element — possibly from a corrupted durable parsedfile store — must never
* crash the taint pass or fabricate matches from out-of-range indices. The
* degradation contract is per-FUNCTION and one-directional: a CFG whose sites
* fail this check is SKIPPED FOR TAINT ONLY — the BasicBlock/CFG layer and
* the REACHING_DEF projection (guarded by their own checks) are unaffected.
*
* Checked: exactly the indices the taint matcher dereferences — binding
* indices (`receiver`/`object`/`resultDefs`/arg occurrences) against the
* function's binding table, and intra-statement site references (`parent`
* site / via-tags) against the OWNING statement's `sites` array. Site
* references are statement-local by construction (each statement's
* FactAccumulator starts at index 0); a cross-statement reference is
* corruption, not a feature.
*
* Lives in `taint/` (not cfg/emit.ts): U4's taint emit path is the only
* consumer, and the guard must evolve with the matcher that dereferences
* these fields.
*/
import type { FunctionCfg, SiteRecord } from '../cfg/types.js';
const SITE_KINDS = new Set<SiteRecord['kind']>(['call', 'new', 'member-read']);
/**
* Whether a structurally-valid CFG's M3 `sites` annotations are safe to feed
* to the taint matcher/propagator. `true` when no statement carries sites
* (pre-M3 channel, or no calls) — absence is the well-formed empty case.
*/
export const hasTaintSafeSites = (cfg: FunctionCfg): boolean => {
// Sites carry binding indices — a channel with sites but no binding table
// has nothing to range-check them against: reject (checked per statement).
const bindingCount = Array.isArray(cfg.bindings) ? cfg.bindings.length : -1;
for (const block of cfg.blocks) {
const stmts = block.statements;
if (stmts === undefined) continue;
if (!Array.isArray(stmts)) return false;
for (const s of stmts) {
if (s?.sites === undefined) continue;
if (bindingCount < 0) return false;
if (!isSafeSiteList(s.sites, bindingCount)) return false;
}
}
return true;
};
const isSafeSiteList = (sites: unknown, bindingCount: number): boolean => {
if (!Array.isArray(sites)) return false;
const siteCount = sites.length;
const bindingInRange = (i: unknown): boolean =>
Number.isInteger(i) && (i as number) >= 0 && (i as number) < bindingCount;
const siteInRange = (i: unknown): boolean =>
Number.isInteger(i) && (i as number) >= 0 && (i as number) < siteCount;
for (const site of sites as ReadonlyArray<Partial<SiteRecord> | null | undefined>) {
if (site === null || typeof site !== 'object') return false;
if (typeof site.kind !== 'string' || !SITE_KINDS.has(site.kind)) return false;
if (site.callee !== undefined && typeof site.callee !== 'string') return false;
if (site.receiver !== undefined && !bindingInRange(site.receiver)) return false;
if (site.requireArg !== undefined && typeof site.requireArg !== 'string') return false;
if (site.template !== undefined && typeof site.template !== 'boolean') return false;
if (
site.spread !== undefined &&
(!Number.isInteger(site.spread) || (site.spread as number) < 0)
) {
return false;
}
if (site.parent !== undefined) {
const p = site.parent;
if (!Array.isArray(p) || p.length !== 2) return false;
if (!siteInRange(p[0])) return false;
if (!Number.isInteger(p[1]) || (p[1] as number) < 0) return false;
}
if (site.resultDefs !== undefined) {
if (!Array.isArray(site.resultDefs) || !site.resultDefs.every(bindingInRange)) return false;
}
if (site.args !== undefined) {
if (!Array.isArray(site.args)) return false;
for (const position of site.args) {
if (!Array.isArray(position)) return false;
for (const entry of position) {
if (typeof entry === 'number') {
if (!bindingInRange(entry)) return false;
} else if (Array.isArray(entry) && entry.length === 2) {
if (!bindingInRange(entry[0]) || !siteInRange(entry[1])) return false;
} else {
return false;
}
}
}
}
if (site.kind === 'member-read') {
// The matcher dereferences both unconditionally on member reads.
if (!bindingInRange(site.object) || typeof site.property !== 'string') return false;
} else {
if (site.object !== undefined && !bindingInRange(site.object)) return false;
if (site.property !== undefined && typeof site.property !== 'string') return false;
}
}
return true;
};

View file

@ -1,38 +1,119 @@
/**
* Source/sink/sanitizer config model (issue #2080, taint/PDG substrate M0).
* Source/sink/sanitizer config model (issue #2080 M0 seam, extended by #2083
* M3 U2).
*
* The per-language taint configuration *shape*. M0 ships only the type and an
* (empty) registry seam — no analysis consumes it yet. M3 (#2083, intra-proc
* taint) populates per-language specs and reads them when emitting TAINTED /
* SANITIZES edges.
* The per-language taint configuration *shape*. M0 shipped only the bare
* `{name, args?}` callable matcher and an empty registry seam; M3 U2 extends
* it with the `kind` taxonomy and the resolution-mechanism fields the
* import-aware matcher (`taint/match.ts`) needs, and fills the registry with
* the built-in TS/JS model (`taint/typescript-model.ts`).
*
* Kept deliberately minimal: enough for M3 to express "callable X is a
* source / sink / sanitizer, optionally for argument position N" without M0
* committing to matcher semantics it cannot yet validate. The shape is
* expected to grow (e.g. sanitizer escape conditions, return-position taint)
* when M3 makes contact with real flows; that is a forward-declared-interface
* design choice, not a finished contract.
* Design rule: entries describe WHAT a callable is (category + how its name
* resolves), never HOW matching works — matching semantics (import joins,
* shadow checks, spread/template position rules) live in the matcher so the
* spec stays declarative data that can hash into `taintModelVersion`.
*/
/** Categories of taint sources. M3 ships remote HTTP input only. */
export type SourceKind = 'remote-input';
/**
* Identifies a callable that participates in taint flow. `name` is matched
* against a resolved callable (simple or qualified name — exact matching
* semantics are M3's call). `args` optionally narrows to specific 0-based
* argument positions that carry taint (for a source/sink) or clear it (for a
* sanitizer); omit to mean "unspecified / all".
* Vulnerability categories for sinks. Sanitizers reference the SAME taxonomy
* via {@link TaintSanitizerEntry.neutralizes}: a sanitizer kill applies only
* when it neutralizes the matched sink's kind (`path.basename` strips
* directories, not shell metacharacters — a kind-blind kill is a suppressed
* live command injection, the forbidden false-negative direction).
*/
export interface TaintCallableMatcher {
export type SinkKind =
| 'command-injection'
| 'code-injection'
| 'path-traversal'
| 'sql-injection'
| 'xss';
/**
* Identifies a callable that participates in taint flow. `name` is the
* callable's own (unqualified) name — qualification comes from the
* resolution-mechanism fields on the extending entry types, not from dotted
* `name` strings. `args` optionally narrows to specific 0-based argument
* positions that carry taint into a sink (or are cleared by a sanitizer);
* omit to mean "all positions".
*/
export interface TaintCallableMatcher<K extends string = string> {
readonly name: string;
readonly args?: readonly number[];
/** Category label — drives finding classification and sanitizer kind-compat. */
readonly kind: K;
}
/**
* The taint configuration for a single language: which callables introduce
* taint (sources), which are dangerous to reach with tainted input (sinks),
* and which clear taint (sanitizers).
* A sink callable. Exactly one resolution mechanism should be set per entry:
*
* - `module` — the callable lives in a package/builtin module; the matcher
* resolves call sites against it import-aware (ESM `parsedImports` aliases,
* namespace handles, and the CommonJS `require('<literal>')` join). `name`
* is the exported member (`'exec'` of `'child_process'`); the pseudo-name
* `'default'` denotes invoking the module's default export / the module
* handle itself.
* - `global` — a true ECMAScript global (`eval`, `Function`); matched by bare
* name only when the name is not shadowed by an in-function declaration and
* not bound by an import. `newOnly` further restricts to `new` expressions
* (`new Function(body)`).
* - `anyReceiver` — a method matched on ANY receiver chain by its final
* segment (`.query(sql)` / `.execute(sql)` on whatever the DB handle is
* named) — deliberately name-conventional, like Semgrep's default rules.
* - `receivers` — a method matched only on the listed conventional receiver
* names (`res.send` / `res.write`); exactly `<receiver>.<name>`, name-based.
*/
export interface TaintSinkEntry extends TaintCallableMatcher<SinkKind> {
readonly module?: string;
readonly global?: boolean;
/** Only meaningful with `global`: match `new <name>(…)` sites only. */
readonly newOnly?: boolean;
readonly anyReceiver?: boolean;
readonly receivers?: readonly string[];
}
/**
* A sanitizer callable. Carries the sink kinds it `neutralizes` instead of a
* `kind` of its own. STRICTER resolution than sinks by design: only the
* `module` (import-aware) and `global` mechanisms exist — never a bare-name
* convention — because a sanitizer mis-match is a false KILL (a user's own
* `escape` helper must not suppress findings), while a sink mis-match is
* merely noise. `args` narrows which argument positions are cleared (omit =
* all).
*/
export interface TaintSanitizerEntry {
readonly name: string;
readonly args?: readonly number[];
readonly neutralizes: readonly SinkKind[];
readonly module?: string;
readonly global?: boolean;
}
/**
* A member-read taint source: reading `<object>.<property>` where the object
* is one of the conventional receiver `objects` names (`req`/`request`) and
* the property is one of `properties` (`body`, `query`, …). Matching is
* name-based on the harvested `member-read` site (Semgrep-convention, not
* type-aware — the accepted M3 FP/FN trade recorded in the plan's risk
* table). One entry fans out over the objects × properties product.
*/
export interface TaintMemberSourceEntry {
readonly kind: SourceKind;
readonly objects: readonly string[];
readonly properties: readonly string[];
}
/**
* The taint configuration for a single language: which member reads introduce
* taint (sources), which callables are dangerous to reach with tainted input
* (sinks), and which callables clear it (sanitizers). M3 sources are
* member-read entries only; call-result sources are a forward extension
* (add a union variant), not a missing case.
*/
export interface SourceSinkSanitizerSpec {
readonly sources: readonly TaintCallableMatcher[];
readonly sinks: readonly TaintCallableMatcher[];
readonly sanitizers: readonly TaintCallableMatcher[];
readonly sources: readonly TaintMemberSourceEntry[];
readonly sinks: readonly TaintSinkEntry[];
readonly sanitizers: readonly TaintSanitizerEntry[];
}

View file

@ -1,10 +1,12 @@
/**
* Per-language source/sink/sanitizer registry seam (issue #2080).
*
* A keyed registry of {@link SourceSinkSanitizerSpec} by language id. M0 stands
* up the empty seam — no language is registered and nothing in the pipeline
* reads it. M3 (#2083) registers per-language specs and queries this registry
* when emitting taint edges.
* A keyed registry of {@link SourceSinkSanitizerSpec} by language id. M0 stood
* up the empty seam; M3 U2 (#2083) fills it with the built-in TS/JS model via
* the EXPLICIT `registerBuiltinTaintModels()` seam in `typescript-model.ts` —
* deliberately not an import side-effect. The U4 taint emit path must call it
* once before the pdg window consumes the registry (idempotent; the registry
* itself stays empty until then, preserving default-run parity).
*
* The store is module-level (matching the codebase's other per-language
* registries). {@link clearSourceSinkRegistry} resets it for test isolation.

View file

@ -0,0 +1,108 @@
/**
* Built-in TS/JS taint model (#2083 M3 U2, plan KTD7).
*
* The canonical Express/Node source/sink/sanitizer set, registered for the
* `typescript` and `javascript` language ids via the EXPLICIT
* {@link registerBuiltinTaintModels} seam — deliberately not an import
* side-effect, so the U4 emit path controls WHEN registration happens (call
* it once before the pdg window runs; it is idempotent — the registry is
* last-write-wins on the same language id).
*
* `taintModelVersion` is a deterministic digest of the FULL model content
* (entries, kinds, args, modules). It joins the RepoMeta `pdg` stamp in U5 so
* that ANY model change — adding an entry, relabeling a kind — trips full
* writeback on an existing `--pdg` index (R7): persisted findings must never
* outlive the model that produced them.
*/
import { createHash } from 'node:crypto';
import { SupportedLanguages } from 'gitnexus-shared';
import type { SourceSinkSanitizerSpec } from './source-sink-config.js';
import { registerSourceSinkConfig } from './source-sink-registry.js';
/**
* The built-in TS/JS model. Module provenance uses bare specifier names —
* the matcher normalizes the `node:` scheme prefix, so `import { exec } from
* 'node:child_process'` resolves identically.
*/
export const TS_JS_TAINT_MODEL: SourceSinkSanitizerSpec = {
sources: [
// Express-convention request member reads, matched name-based on the
// receiver (`req`/`request`) — the plan's accepted Semgrep-style trade.
{
kind: 'remote-input',
objects: ['req', 'request'],
properties: ['body', 'query', 'params', 'headers', 'cookies'],
},
],
sinks: [
// Command execution — the command string is argument 0.
{ name: 'exec', kind: 'command-injection', args: [0], module: 'child_process' },
{ name: 'execSync', kind: 'command-injection', args: [0], module: 'child_process' },
{ name: 'spawn', kind: 'command-injection', args: [0], module: 'child_process' },
// Code evaluation. `eval` takes code at 0; `new Function(...)` treats
// EVERY argument as source text (params + body), so `args` is omitted
// (= all positions) rather than pinned to 0.
{ name: 'eval', kind: 'code-injection', args: [0], global: true },
{ name: 'Function', kind: 'code-injection', global: true, newOnly: true },
// Filesystem path consumption — path argument 0.
{ name: 'readFile', kind: 'path-traversal', args: [0], module: 'fs' },
{ name: 'readFileSync', kind: 'path-traversal', args: [0], module: 'fs' },
{ name: 'writeFile', kind: 'path-traversal', args: [0], module: 'fs' },
{ name: 'writeFileSync', kind: 'path-traversal', args: [0], module: 'fs' },
// SQL — `.query(sql)` / `.execute(sql)` member calls on ANY receiver
// (mysql2/pg/knex handles go by many names; receiver-conventional).
{ name: 'query', kind: 'sql-injection', args: [0], anyReceiver: true },
{ name: 'execute', kind: 'sql-injection', args: [0], anyReceiver: true },
// Reflected XSS — Express response writes, conventional receiver `res`.
{ name: 'send', kind: 'xss', args: [0], receivers: ['res'] },
{ name: 'write', kind: 'xss', args: [0], receivers: ['res'] },
],
sanitizers: [
// URL-encoding: neutralizes markup injection AND path separators
// (`%2F` is not a separator inside a path component).
{ name: 'encodeURIComponent', neutralizes: ['xss', 'path-traversal'], global: true },
// `escape-html` exports its function as the module default — the
// `'default'` pseudo-name matches the default-imported / require'd
// module handle being invoked directly.
{ name: 'default', neutralizes: ['xss'], module: 'escape-html' },
{ name: 'encode', neutralizes: ['xss'], module: 'he' },
{ name: 'basename', neutralizes: ['path-traversal'], module: 'path' },
{ name: 'escape', neutralizes: ['xss'], module: 'validator' },
],
};
/**
* Deterministic digest of a spec's full content. Key order is canonicalized
* (recursively sorted) so the version reflects CONTENT, not literal layout;
* array order is semantic (entry identity) and intentionally preserved.
*/
export function computeTaintModelVersion(spec: SourceSinkSanitizerSpec): string {
return createHash('sha256').update(canonicalJson(spec)).digest('hex').slice(0, 12);
}
function canonicalJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
if (value !== null && typeof value === 'object') {
const entries = Object.entries(value as Record<string, unknown>)
.filter(([, v]) => v !== undefined)
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`);
return `{${entries.join(',')}}`;
}
return JSON.stringify(value);
}
/** Version stamp of the built-in TS/JS model (joins the RepoMeta pdg key in U5). */
export const taintModelVersion: string = computeTaintModelVersion(TS_JS_TAINT_MODEL);
/**
* Register the built-in model for TypeScript and JavaScript. Explicit init
* seam for the U4 emit path (call before the pdg window consumes the
* registry); idempotent. Vue and other TS-adjacent language ids are
* deliberately NOT registered — the M3 scope is TS/JS only.
*/
export function registerBuiltinTaintModels(): void {
registerSourceSinkConfig(SupportedLanguages.TypeScript, TS_JS_TAINT_MODEL);
registerSourceSinkConfig(SupportedLanguages.JavaScript, TS_JS_TAINT_MODEL);
}

View file

@ -49,6 +49,11 @@ import {
DEFAULT_MAX_CFG_EDGES_PER_FUNCTION,
DEFAULT_PDG_MAX_REACHING_DEF_EDGES_PER_FUNCTION,
} from './ingestion/cfg/emit.js';
import {
DEFAULT_PDG_MAX_TAINT_FINDINGS_PER_FUNCTION,
DEFAULT_PDG_MAX_TAINT_HOPS,
} from './ingestion/taint/propagate.js';
import { taintModelVersion } from './ingestion/taint/typescript-model.js';
import { computeFileHashes, diffFileHashes } from '../storage/file-hash.js';
import {
extractChangedSubgraph,
@ -141,6 +146,13 @@ export interface AnalyzeOptions {
/** Per-function REACHING_DEF edge cap (#2082 M2). Forwarded to
* `PipelineOptions.pdgMaxReachingDefEdgesPerFunction`. */
pdgMaxReachingDefEdgesPerFunction?: number;
/** Per-function taint findings cap (#2083 M3). Forwarded to
* `PipelineOptions.pdgMaxTaintFindingsPerFunction`. No CLI flag or rc key
* (KTD8) — programmatic / server path only, like the other pdg caps. */
pdgMaxTaintFindingsPerFunction?: number;
/** Per-finding taint hop cap (#2083 M3, KTD6). Forwarded to
* `PipelineOptions.pdgMaxTaintHops`. No CLI flag or rc key (KTD8). */
pdgMaxTaintHops?: number;
/**
* Default branch threaded into generated AGENTS.md / CLAUDE.md so the
* regression-compare example uses the configured branch instead of a
@ -343,7 +355,12 @@ export const collectBranchCacheKeys = async (
*/
type PdgOptions = Pick<
AnalyzeOptions,
'pdg' | 'pdgMaxFunctionLines' | 'pdgMaxEdgesPerFunction' | 'pdgMaxReachingDefEdgesPerFunction'
| 'pdg'
| 'pdgMaxFunctionLines'
| 'pdgMaxEdgesPerFunction'
| 'pdgMaxReachingDefEdgesPerFunction'
| 'pdgMaxTaintFindingsPerFunction'
| 'pdgMaxTaintHops'
>;
export const resolvePdgConfig = (options: PdgOptions): RepoMeta['pdg'] =>
@ -354,6 +371,17 @@ export const resolvePdgConfig = (options: PdgOptions): RepoMeta['pdg'] =>
maxReachingDefEdgesPerFunction:
options.pdgMaxReachingDefEdgesPerFunction ??
DEFAULT_PDG_MAX_REACHING_DEF_EDGES_PER_FUNCTION,
// #2083 M3: taint caps + model identity. The key-union comparator in
// pdgModeMismatch picks these up structurally — an M2-era stamp lacks
// all three, so the first M3 run over an M2 `--pdg` index trips a full
// writeback that populates TAINTED/SANITIZES rows without `--force`.
maxTaintFindingsPerFunction:
options.pdgMaxTaintFindingsPerFunction ?? DEFAULT_PDG_MAX_TAINT_FINDINGS_PER_FUNCTION,
maxTaintHops: options.pdgMaxTaintHops ?? DEFAULT_PDG_MAX_TAINT_HOPS,
// 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.
taintModelVersion,
}
: undefined;
@ -753,6 +781,8 @@ export async function runFullAnalysis(
pdgMaxFunctionLines: options.pdgMaxFunctionLines,
pdgMaxEdgesPerFunction: options.pdgMaxEdgesPerFunction,
pdgMaxReachingDefEdgesPerFunction: options.pdgMaxReachingDefEdgesPerFunction,
pdgMaxTaintFindingsPerFunction: options.pdgMaxTaintFindingsPerFunction,
pdgMaxTaintHops: options.pdgMaxTaintHops,
fetchWrappers: options.fetchWrappers,
},
);

View file

@ -54,8 +54,29 @@ import {
import { PhaseTimer } from '../../core/search/phase-timer.js';
import { checkStalenessAsync, checkCwdMatch } from '../../core/git-staleness.js';
import { logger } from '../../core/logger.js';
import { LIST_REPOS_DEFAULT_LIMIT, LIST_REPOS_MAX_LIMIT } from '../tools.js';
import {
LIST_REPOS_DEFAULT_LIMIT,
LIST_REPOS_MAX_LIMIT,
EXPLAIN_DEFAULT_LIMIT,
EXPLAIN_MAX_LIMIT,
} from '../tools.js';
import { findImportCycles } from '../../core/graph/import-cycles.js';
import { decodeTaintPath } from '../../core/ingestion/taint/path-codec.js';
import { EXTENSIONS } from '../../core/ingestion/import-resolvers/utils.js';
/** Real source-file extensions (`.ts`, `.py`, …) from the resolver's list,
* excluding the empty entry and the `/index.*` forms — used to decide whether
* an `explain` target is a file path vs a (possibly dotted) symbol name. */
const SOURCE_FILE_EXTENSIONS: readonly string[] = EXTENSIONS.filter(
(e) => e.startsWith('.') && !e.includes('/'),
);
/** A target is path-ish if it has a path separator or ends in a known source
* extension. A bare dotted symbol (`UserController.create`) is NOT path-ish. */
function looksLikeFilePath(target: string): boolean {
if (/[\\/]/.test(target)) return true;
const lower = target.toLowerCase();
return SOURCE_FILE_EXTENSIONS.some((ext) => lower.endsWith(ext));
}
// AI context generation is CLI-only (gitnexus analyze)
// import { generateAIContextFiles } from '../../cli/ai-context.js';
@ -1243,6 +1264,8 @@ export class LocalBackend {
}
case 'context':
return this.context(repo, params);
case 'explain':
return this.explain(repo, params);
case 'impact':
return this.impact(repo, params);
case 'detect_changes':
@ -2730,6 +2753,259 @@ export class LocalBackend {
};
}
/**
* Explain tool (#2083 M3 U6) — persisted taint-finding explanation.
* WAL-aware wrapper mirroring `context`.
*/
private async explain(
repo: RepoHandle,
params: { target?: string; limit?: number },
): Promise<any> {
try {
return await this._explainImpl(repo, params);
} catch (err: any) {
const msg = (err instanceof Error ? err.message : String(err)) || 'Explain query failed';
if (isWalCorruptionError(err)) {
return {
error: msg,
recoverySuggestion: WAL_RECOVERY_SUGGESTION,
};
}
throw err;
}
}
/**
* Taint findings are persisted as `TAINTED` rows in CodeRelation whose
* endpoints are BOTH BasicBlock nodes — the label anchor restricts every
* query here to the BasicBlock→BasicBlock partition of the rel table
* (which holds only the sparse, per-function-capped pdg layers), never a
* global symbol-space scan (the S1 verdict; LadybugDB has no rel-property
* index, so the label anchor IS the bound).
*
* Anchoring granularity:
* - file target → BasicBlock id prefix (`BasicBlock:<filePath>:` — the
* shared `basicBlockId` template) with an exact-or-suffix path match so
* `vuln.ts` finds `src/vuln.ts`.
* - symbol target → resolved via `resolveSymbolCandidates` (the context()
* path: ambiguous ⇒ ranked candidates, unknown ⇒ not-found), then the
* file id-prefix PLUS source-block startLine within the symbol's
* [startLine, endLine] span. Findings are intra-procedural, so filtering
* the SOURCE endpoint is sufficient — both endpoints share the function.
* Symbols without a line span degrade to the file-level filter.
*
* The per-finding `sinkKind` and hop path decode from the persisted
* `reason` via the SHARED `taint/path-codec.ts` (the U4 write path encodes
* with the same module — `;<kind>` header + ordered `variable:line` hops).
*/
private async _explainImpl(
repo: RepoHandle,
params: { target?: string; limit?: number },
): Promise<any> {
await this.ensureInitialized(repo);
const rawLimit = params.limit ?? EXPLAIN_DEFAULT_LIMIT;
if (!Number.isInteger(rawLimit) || rawLimit < 1 || rawLimit > EXPLAIN_MAX_LIMIT) {
return {
error: `Invalid "limit": expected an integer in [1, ${EXPLAIN_MAX_LIMIT}], got ${JSON.stringify(params.limit)}.`,
};
}
const limit = rawLimit;
const NO_TAINT_NOTE =
'no taint layer — run gitnexus analyze --pdg to record taint findings for this repo';
// Cheap meta probe: the TAINT layer exists iff the pdg stamp carries a
// `taintModelVersion` (the field M3 added). An M1/M2-era `--pdg` index has
// `meta.pdg` defined but no taintModelVersion — BasicBlock/REACHING_DEF
// exist, zero TAINTED rows do — so it must surface the no-taint-layer hint,
// not the generic "analyzed, nothing found" note. An unreadable meta (e.g.
// a seeded test DB) falls through to the row-existence probe below.
let pdgStamped: boolean | undefined;
try {
const meta = await loadMeta(path.dirname(repo.lbugPath));
if (meta) pdgStamped = meta.pdg?.taintModelVersion !== undefined;
} catch {
/* meta unreadable — decide from the DB below */
}
if (pdgStamped === false) {
return { findings: [], totalFindings: 0, note: NO_TAINT_NOTE };
}
// Resolve the optional anchor into a WHERE clause on the SOURCE block.
const target = typeof params.target === 'string' ? params.target.trim() : '';
let anchorClause = '';
const queryParams: Record<string, unknown> = {};
let anchor: { file: string; symbol?: string; startLine?: number; endLine?: number } | undefined;
// Build the anchor as a file filter (used only when `target` is path-ish).
const buildFileAnchor = (): void => {
// Exact path via the BasicBlock id-prefix template, OR a
// path-separator-aligned suffix so partial paths work like context()'s
// file_path hint ("vuln.ts" ⇒ "src/vuln.ts", never "devuln.ts").
anchorClause =
'AND (a.id STARTS WITH $idPrefix OR a.filePath = $targetPath OR a.filePath ENDS WITH $targetSuffix)';
queryParams.idPrefix = `BasicBlock:${target}:`;
queryParams.targetPath = target;
queryParams.targetSuffix = `/${target}`;
anchor = { file: target as string };
};
// Resolve `target` as a symbol into the anchor. Returns an early-return
// payload (not_found / ambiguous) or undefined on success.
const resolveSymbolAnchor = async (): Promise<Record<string, unknown> | undefined> => {
const outcome = await this.resolveSymbolCandidates(repo, { name: target as string }, {});
if (outcome.kind === 'not_found') {
return { error: `Symbol '${target}' not found` };
}
if (outcome.kind === 'ambiguous') {
return {
status: 'ambiguous',
message: `Found ${outcome.candidates.length} symbols matching '${target}'. Re-call explain with the file path, or disambiguate via context() first.`,
candidates: outcome.candidates.map((c) => ({
uid: c.id,
name: c.name,
kind: c.type,
filePath: c.filePath,
line: c.startLine,
score: Number(c.score.toFixed(2)),
})),
};
}
const sym = outcome.symbol;
queryParams.idPrefix = `BasicBlock:${sym.filePath}:`;
anchor = { file: sym.filePath, symbol: sym.name };
if (
typeof sym.startLine === 'number' &&
typeof sym.endLine === 'number' &&
sym.endLine >= sym.startLine
) {
anchorClause =
'AND a.id STARTS WITH $idPrefix AND a.startLine >= $symStart AND a.startLine <= $symEnd';
queryParams.symStart = sym.startLine;
queryParams.symEnd = sym.endLine;
anchor.startLine = sym.startLine;
anchor.endLine = sym.endLine;
} else {
// No usable span — degrade to the file-level filter (documented).
anchorClause = 'AND a.id STARTS WITH $idPrefix';
}
return undefined;
};
// Bounded by construction: the BasicBlock→BasicBlock partition holds only
// the sparse pdg layers, TAINTED rows are per-function-capped at analyze
// time, and the page is LIMIT-bounded (the limit is a validated integer —
// interpolated because LadybugDB does not parameterize LIMIT).
const runAnchoredQuery = async (): Promise<{ rows: unknown[]; totalFindings: number }> => {
const matchClause = `
MATCH (a:BasicBlock)-[r:CodeRelation]->(b:BasicBlock)
WHERE r.type = 'TAINTED' ${anchorClause}`;
const [qRows, countRows] = await Promise.all([
executeParameterized(
repo.lbugPath,
`${matchClause}
RETURN a.id AS sourceBlockId, a.filePath AS file, a.startLine AS sourceStart,
b.startLine AS sinkStart, r.reason AS reason, b.id AS sinkBlockId
ORDER BY sourceBlockId, sinkBlockId, reason
LIMIT ${limit}`,
queryParams,
),
executeParameterized(
repo.lbugPath,
`${matchClause}
RETURN COUNT(*) AS total`,
queryParams,
),
]);
return {
rows: qRows,
totalFindings: Number((countRows[0] as any)?.total ?? (countRows[0] as any)?.[0] ?? 0),
};
};
if (target) {
if (looksLikeFilePath(target)) {
buildFileAnchor();
} else {
// A bare or dotted symbol name (`UserController.create`) — resolve as a
// symbol rather than silently file-anchoring to an empty result.
const early = await resolveSymbolAnchor();
if (early) return early;
}
}
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.
return { findings: [], totalFindings: 0, note: NO_TAINT_NOTE };
}
if (totalFindings === 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".
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) {
return { findings: [], totalFindings: 0, note: NO_TAINT_NOTE };
}
}
const findings = rows.map((r: any) => {
const sourceBlockId = String(r.sourceBlockId ?? r[0] ?? '');
const file = String(r.file ?? r[1] ?? '');
const sourceStart = (r.sourceStart ?? r[2]) as number | undefined;
const sinkStart = (r.sinkStart ?? r[3]) as number | undefined;
const reason = r.reason ?? r[4];
// basicBlockId = `BasicBlock:<filePath>:<fnLine>:<fnCol>:<blockIdx>` —
// split from the RIGHT (the filePath may itself contain ':').
const idParts = sourceBlockId.split(':');
const fnLine = Number(idParts[idParts.length - 3]);
const decoded = decodeTaintPath(reason);
if (!decoded.ok) {
// Unreadable reason (foreign/corrupt row): surface the finding's
// existence with its block anchors, never throw.
return {
file,
...(Number.isInteger(fnLine) ? { functionLine: fnLine } : {}),
sinkKind: 'unknown',
source: { line: sourceStart },
sink: { line: sinkStart },
hops: [],
pathIncomplete: true,
};
}
const hops = decoded.hops.map((h) => ({
variable: h.variable,
line: h.line,
...(h.viaCall ? { viaCall: true } : {}),
}));
const first = hops[0];
const last = hops[hops.length - 1];
return {
file,
...(Number.isInteger(fnLine) ? { functionLine: fnLine } : {}),
sinkKind: decoded.kind ?? 'unknown',
source: first ? { variable: first.variable, line: first.line } : { line: sourceStart },
sink: { line: last?.line ?? sinkStart },
hops,
...(decoded.truncated ? { pathIncomplete: true } : {}),
};
});
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.',
};
}
/**
* Legacy explore — kept for backwards compatibility with resources.ts.
* Routes cluster/process types to direct graph queries.

View file

@ -335,6 +335,9 @@ async function getContextResource(backend: LocalBackend, repoName?: string): Pro
lines.push(' - query: Process-grouped code intelligence (execution flows related to a concept)');
lines.push(' - context: 360-degree symbol view (categorized refs, process participation)');
lines.push(' - impact: Blast radius analysis (what breaks if you change a symbol)');
lines.push(
' - explain: Persisted taint findings — source→sink data flows with per-hop variables (requires analyze --pdg)',
);
lines.push(' - detect_changes: Git-diff impact analysis (what do your changes affect)');
lines.push(' - rename: Multi-file coordinated rename with confidence tags');
lines.push(' - cypher: Raw graph queries');

View file

@ -62,6 +62,16 @@ const DESTRUCTIVE_TOOL_ANNOTATIONS: ToolAnnotations = {
export const LIST_REPOS_DEFAULT_LIMIT = 50;
export const LIST_REPOS_MAX_LIMIT = 200;
/**
* Pagination bounds for the `explain` tool (#2083 M3 U6). Findings are sparse
* and capped per function at analyze time, but a large repo can still
* accumulate enough TAINTED rows to blow MCP/LLM token limits — the response
* is page-bounded like `list_repos`. Exported so the backend clamp
* (`local-backend.ts`) and the schema stay a single source of truth.
*/
export const EXPLAIN_DEFAULT_LIMIT = 50;
export const EXPLAIN_MAX_LIMIT = 200;
export const GITNEXUS_TOOLS: ToolDefinition[] = [
{
name: 'list_repos',
@ -513,6 +523,50 @@ SERVICE: optional monorepo path prefix (case-sensitive path segments). When "rep
required: ['target', 'direction'],
},
},
{
name: 'explain',
description: `Explain persisted taint findings: intra-procedural source→sink data flows (TAINTED edges) recorded by \`gitnexus analyze --pdg\`.
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).
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).
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))).
- Property/field flows are not tracked (obj.x = taint; sink(obj.y) has no chain).
- Guard-style sanitizers (if (isValid(x))) and implicit/control-dependence flows are not modeled.
- CommonJS aliasing is partially modeled (require('<literal>') joins resolve; dynamic requires do not).
- Exception-path over-approximation can produce false-positive noise.
Findings are deliberately NOT part of impact()'s traversal or the web schema — explain is the dedicated taint consumer. SANITIZES (kill) edges are queryable via cypher.`,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {
target: {
type: 'string',
description:
'Optional anchor: a file path (e.g. "src/handlers/run.ts" — suffix match accepted) or a symbol/function name (resolved like context()). Omit to enumerate all findings for the repo.',
},
limit: {
type: 'integer',
description: `Max findings returned (default: ${EXPLAIN_DEFAULT_LIMIT}, max: ${EXPLAIN_MAX_LIMIT}). "totalFindings" reports the full matched count; "truncated" is set when the page is smaller.`,
default: EXPLAIN_DEFAULT_LIMIT,
minimum: 1,
maximum: EXPLAIN_MAX_LIMIT,
},
repo: {
type: 'string',
description: 'Repository name or path. Omit if only one repo is indexed.',
},
},
required: [],
},
},
{
name: 'route_map',
description: `Show API route mappings: which components/hooks fetch which API endpoints, and which handler files serve them.
@ -647,6 +701,7 @@ const BRANCH_SCOPED_TOOLS = new Set([
'cypher',
'context',
'detect_changes',
'explain',
'check',
'impact',
'rename',

View file

@ -184,7 +184,16 @@ export const computeChunkHash = (
// option-blind-key trap. `def` marks an unset (default) value so two
// default-cap runs share a key. The emit-time edge cap is deliberately
// absent — see the PdgCacheKey doc comment.
const ns = `pdg:1;maxFn=${opts.maxFunctionLines ?? 'def'}`;
//
// NAMESPACE VERSION (`pdg:2`): bumped when the worker-emitted
// `cfgSideChannel` SHAPE changes for pdg-mode runs only — pdg:1→2 in #2083
// M3 U1 (TsHarvester emits taint `sites` on StatementFacts). Invalidates
// pdg-mode chunks and their durable parsedfile-cache entries; flag-off
// chunk keys never reach this line and stay byte-identical, so non-pdg
// users pay nothing. Deliberately NOT a SCHEMA_BUMP — that gates the whole
// cache version and would force a full cold re-parse on EVERY user (the M1
// bump comment above records that cost).
const ns = `pdg:2;maxFn=${opts.maxFunctionLines ?? 'def'}`;
return sha256Hex(`${ns}\n${joined}`);
};

View file

@ -158,6 +158,24 @@ export interface RepoMeta {
* type for that reason; resolved (always present) on every M2+ write.
*/
maxReachingDefEdgesPerFunction?: number;
/**
* Per-function taint findings cap, resolved (0 = unlimited; #2083 M3).
* ABSENT on an M1/M2-era stamp — like `maxReachingDefEdgesPerFunction`,
* that absence is what trips `pdgModeMismatch` on the first M3 run and
* forces the full writeback that populates TAINTED/SANITIZES rows.
*/
maxTaintFindingsPerFunction?: number;
/** Per-finding taint hop cap, resolved (0 = unlimited; #2083 M3 KTD6 —
* bounds the persisted hop-encoded `reason`). Optional for the same
* M2-era-stamp upgrade reason as the findings cap. */
maxTaintHops?: number;
/**
* Digest of the built-in taint model the persisted findings were
* produced under (#2083 M3 KTD7/R7). Any model-content change ships a
* new digest → mismatch → full writeback repopulates taint edges
* without `--force`. Optional: absent on pre-M3 stamps.
*/
taintModelVersion?: string;
};
}

View file

@ -0,0 +1,123 @@
/**
* Shared pure-path taint harness over the pdg-repo fixture (#2083 M3 U7).
*
* Runs the SAME per-function pipeline the in-phase emit driver runs —
* collect CFGs → site-safety gate → match → zero-match fast path →
* `computeReachingDefs` → `computeTaintFlows` — but build-free on the main
* thread (parse via tree-sitter directly, like reaching-defs-snapshot).
*
* Two consumers, deliberately fed from ONE module so they cannot drift:
* - `taint-snapshot.test.ts` serializes the per-function results (AE1/AE3);
* - `pipeline-pdg.test.ts` sums findings/kills as the EXPECTED stored-row
* counts for the sparse-persistence gate (AE2): the real worker pipeline
* must persist exactly one TAINTED row per pure-path finding and one
* SANITIZES row per kill — O(findings), never a REACHING_DEF-style
* explosion.
*
* Limits mirror the run.ts derivation: findings/hops caps at their U5
* defaults, `maxFacts` at the RD-edge-cap formula's default product
* (`DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION` — same number).
*/
import fs from 'fs';
import path from 'path';
import Parser from 'tree-sitter';
import TypeScript from 'tree-sitter-typescript';
import type { ParsedImport } from 'gitnexus-shared';
import { collectFunctionCfgs } from '../../src/core/ingestion/cfg/collect.js';
import { computeReachingDefs } from '../../src/core/ingestion/cfg/reaching-defs.js';
import { DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION } from '../../src/core/ingestion/cfg/emit.js';
import { getProvider } from '../../src/core/ingestion/languages/index.js';
import { SupportedLanguages } from '../../src/config/supported-languages.js';
import { hasTaintSafeSites } from '../../src/core/ingestion/taint/site-safety.js';
import { buildTaintImportIndex, matchFunctionSites } from '../../src/core/ingestion/taint/match.js';
import { TS_JS_TAINT_MODEL } from '../../src/core/ingestion/taint/typescript-model.js';
import {
computeTaintFlows,
DEFAULT_PDG_MAX_TAINT_FINDINGS_PER_FUNCTION,
DEFAULT_PDG_MAX_TAINT_HOPS,
type FunctionTaintResult,
} from '../../src/core/ingestion/taint/propagate.js';
import type { FunctionCfg } from '../../src/core/ingestion/cfg/types.js';
/** The taint-bearing fixture files (sample.ts is the zero-match control). */
export const TAINT_FIXTURE_FILES = ['vuln.ts', 'taint-cases.ts', 'sample.ts'] as const;
/**
* Hand-built `ParsedImport` lists matching each fixture file's import
* statements (the build-free path has no extractor run). MUST stay in sync
* with the fixture sources — the AE2 equality against the real pipeline
* (which uses extracted `parsedImports`) breaks loudly if they drift.
*/
export const TAINT_FIXTURE_IMPORTS: Record<string, readonly ParsedImport[]> = {
'vuln.ts': [
{ kind: 'named', localName: 'exec', importedName: 'exec', targetRaw: 'child_process' },
],
'taint-cases.ts': [
{ kind: 'named', localName: 'exec', importedName: 'exec', targetRaw: 'child_process' },
],
'sample.ts': [],
};
export interface FixtureFunctionTaint {
readonly file: string;
readonly startLine: number;
readonly cfg: FunctionCfg;
/**
* `no-match` — the zero-match fast path skipped the solver entirely;
* `unsafe-sites` — `hasTaintSafeSites` rejected the harvest;
* otherwise the `computeTaintFlows` status (`computed` / `coverage-gap`).
*/
readonly status: 'no-match' | 'unsafe-sites' | FunctionTaintResult['status'];
/** Present iff the solver ran (status `computed` or `coverage-gap`). */
readonly flows?: FunctionTaintResult;
}
/** Run the pure taint path over one fixture file. Deterministic. */
export function computeFixtureFileTaint(
fixtureDir: string,
file: string,
): readonly FixtureFunctionTaint[] {
const visitor = getProvider(SupportedLanguages.TypeScript).cfgVisitor;
if (!visitor) throw new Error('no cfgVisitor for TypeScript');
const source = fs.readFileSync(path.join(fixtureDir, file), 'utf8');
const parser = new Parser();
parser.setLanguage(TypeScript.typescript);
const cfgs = collectFunctionCfgs(parser.parse(source).rootNode, visitor, file).cfgs;
const imports = TAINT_FIXTURE_IMPORTS[file];
if (imports === undefined) {
throw new Error(`no FIXTURE_IMPORTS entry for ${file} — add it (see module doc)`);
}
const importIndex = buildTaintImportIndex(imports);
return cfgs.map((cfg) => {
const base = { file, startLine: cfg.functionStartLine, cfg };
if (!hasTaintSafeSites(cfg)) return { ...base, status: 'unsafe-sites' as const };
const matches = matchFunctionSites(cfg, TS_JS_TAINT_MODEL, importIndex);
if (!matches.hasSource || !matches.hasSink) return { ...base, status: 'no-match' as const };
const defUse = computeReachingDefs(cfg, {
maxFacts: DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION,
});
const flows = computeTaintFlows(cfg, defUse, matches, {
maxFindingsPerFunction: DEFAULT_PDG_MAX_TAINT_FINDINGS_PER_FUNCTION,
maxHops: DEFAULT_PDG_MAX_TAINT_HOPS,
});
return { ...base, status: flows.status, flows };
});
}
/** Run the pure taint path over the whole fixture battery, in file order. */
export function computeFixtureTaint(fixtureDir: string): readonly FixtureFunctionTaint[] {
return TAINT_FIXTURE_FILES.flatMap((f) => computeFixtureFileTaint(fixtureDir, f));
}
/** Pure-path totals — the AE2 expected stored-row counts. */
export function fixtureTaintTotals(fixtureDir: string): { findings: number; kills: number } {
let findings = 0;
let kills = 0;
for (const fn of computeFixtureTaint(fixtureDir)) {
findings += fn.flows?.findings.length ?? 0;
kills += fn.flows?.kills.length ?? 0;
}
return { findings, kills };
}

View file

@ -0,0 +1,68 @@
/**
* Shared TS CFG/taint unit-test harness (#2083 review).
*
* Parses real TypeScript source through the worker-side CFG visitor and the
* scope-capture import interpreter, so taint unit tests run against the exact
* structures the pipeline feeds `computeReachingDefs` / `matchFunctionSites` /
* `computeTaintFlows`, never hand-built mocks. Extracted from the byte-identical
* copies that lived in model-match / propagate / taint-emit / harvest tests.
*/
import Parser from 'tree-sitter';
import TypeScript from 'tree-sitter-typescript';
import type { ParsedImport } from 'gitnexus-shared';
import type { SyntaxNode } from '../../src/core/ingestion/utils/ast-helpers.js';
import {
createTypeScriptCfgVisitor,
TS_FUNCTION_TYPES,
} from '../../src/core/ingestion/cfg/visitors/typescript.js';
import type { FunctionCfg } from '../../src/core/ingestion/cfg/types.js';
import { emitTsScopeCaptures } from '../../src/core/ingestion/languages/typescript/captures.js';
import { interpretTsImport } from '../../src/core/ingestion/languages/typescript/interpret.js';
const visitor = createTypeScriptCfgVisitor();
export function parse(code: string): SyntaxNode {
const parser = new Parser();
parser.setLanguage(TypeScript.typescript);
return parser.parse(code).rootNode;
}
export function collectFunctions(root: SyntaxNode): SyntaxNode[] {
const out: SyntaxNode[] = [];
const stack = [root];
while (stack.length) {
const n = stack.pop() as SyntaxNode;
if (TS_FUNCTION_TYPES.has(n.type)) out.push(n);
for (let i = n.namedChildCount - 1; i >= 0; i--) {
const c = n.namedChild(i);
if (c) stack.push(c);
}
}
return out;
}
/** The CFG of the function at `index` (default 0). */
export function cfgOf(code: string, index = 0): FunctionCfg {
const fns = collectFunctions(parse(code));
const fn = fns[index];
if (!fn) throw new Error(`no function at index ${index}`);
const cfg = visitor.buildFunctionCfg(fn, 'fixture.ts');
if (!cfg) throw new Error('buildFunctionCfg returned undefined');
return cfg;
}
/** Every function's CFG, in source order. */
export function cfgsOf(code: string): FunctionCfg[] {
return collectFunctions(parse(code))
.map((fn) => visitor.buildFunctionCfg(fn, 'fixture.ts'))
.filter((c): c is FunctionCfg => c !== undefined);
}
/** Real ParsedImports via the TS scope-capture + interpreter path. */
export function importsFor(src: string): ParsedImport[] {
return emitTsScopeCaptures(src, 'fixture.ts')
.filter((m) => m['@import.statement'] !== undefined)
.map((m) => interpretTsImport(m))
.filter((p): p is ParsedImport => p !== null);
}

View file

@ -0,0 +1,102 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`U7 — taint findings/kills snapshot on the pdg-repo fixture battery > matches the committed findings/kills for every fixture function 1`] = `
[
{
"dropped": 0,
"file": "vuln.ts",
"findings": [
"cmd@10->cmd@11:command-injection",
],
"kills": [],
"startLine": 9,
"status": "computed",
},
{
"dropped": 0,
"file": "vuln.ts",
"findings": [],
"kills": [
"value@17<-17:path-traversal,xss",
],
"startLine": 16,
"status": "computed",
},
{
"dropped": 0,
"file": "taint-cases.ts",
"findings": [
"req@15:command-injection",
],
"kills": [],
"startLine": 14,
"status": "computed",
},
{
"dropped": 0,
"file": "taint-cases.ts",
"findings": [
"a@20->b@21->c@22->c@23:command-injection",
],
"kills": [],
"startLine": 19,
"status": "computed",
},
{
"dropped": 0,
"file": "taint-cases.ts",
"findings": [
"text@34->text@38:xss",
],
"kills": [
"text@36<-36:path-traversal,xss",
],
"startLine": 29,
"status": "computed",
},
{
"dropped": 0,
"file": "taint-cases.ts",
"findings": [
"cmd@44->cmd@48:command-injection",
],
"kills": [],
"startLine": 43,
"status": "computed",
},
{
"dropped": 0,
"file": "taint-cases.ts",
"findings": [
"raw@54->built@55*->built@56:command-injection",
],
"kills": [],
"startLine": 53,
"status": "computed",
},
{
"dropped": 0,
"file": "taint-cases.ts",
"findings": [],
"kills": [],
"startLine": 59,
"status": "no-match",
},
{
"dropped": 0,
"file": "sample.ts",
"findings": [],
"kills": [],
"startLine": 5,
"status": "no-match",
},
{
"dropped": 0,
"file": "sample.ts",
"findings": [],
"kills": [],
"startLine": 14,
"status": "no-match",
},
]
`;

View file

@ -0,0 +1,61 @@
// Taint acceptance battery (#2083 M3 U7): the plan's six fixture shapes not
// already covered by vuln.ts (which carries the reassignment source→sink flow
// and the must-def sanitized variant). Lives in its OWN file — sample.ts's
// line numbers anchor pre-existing REACHING_DEF assertions and must not
// shift, and vuln.ts's lines anchor the U6 explain-tool assertions.
//
// The taint-snapshot test hand-builds this file's import list (test/helpers/
// taint-fixture.ts) — keep the imports below in sync with FIXTURE_IMPORTS.
import { exec } from 'child_process';
// Direct source→sink, statement-local rule (b): req.body lands in the exec
// argument with no def anywhere on the statement → single-hop finding.
export function directSourceToSink(req: { body: string }): void {
exec(req.body);
}
// Multi-hop chain (3+ hops): the taint walks a → b → c → sink.
export function multiHopChain(req: { body: string }): void {
const a = req.body;
const b = a;
const c = b;
exec(c);
}
// Conditional sanitizer (may-def leg): the encode runs only when !trusted, so
// the unsanitized seed def still reaches the sink → the finding SURVIVES,
// and the sanitizer's own def is killed (one SANITIZES edge, binding `text`).
export function conditionalSanitizer(
req: { query: string },
res: { send(v: string): void },
trusted: boolean,
): void {
let text = req.query;
if (!trusted) {
text = encodeURIComponent(text);
}
res.send(text);
}
// Loop-carried taint: `cmd = cmd + part` feeds itself through the back edge —
// the worklist reaches a fixpoint (monotone visited set) and the sink fires.
export function loopCarried(req: { body: string }, parts: string[]): void {
let cmd = req.body;
for (const part of parts) {
cmd = cmd + part;
}
exec(cmd);
}
// Through-call (KTD5): `decorate` is unmodeled — taint propagates through to
// its result with the hop marked viaCall (lower-confidence evidence).
export function throughCall(req: { body: string }): void {
const raw = req.body;
const built = decorate(raw);
exec(built);
}
function decorate(s: string): string {
return 'sh -c ' + s;
}

View file

@ -0,0 +1,19 @@
// Taint fixture (#2083 M3 U4): one vulnerable source→sink flow and one
// sanitized variant. Lives in its OWN file — sample.ts's line numbers anchor
// pre-existing REACHING_DEF assertions and must not shift.
import { exec } from 'child_process';
// Vulnerable: req.body (remote-input source) flows unsanitized into
// child_process.exec (command-injection sink) → one TAINTED edge.
export function runUserCommand(req: { body: string }): void {
const cmd = req.body;
exec(cmd);
}
// Sanitized: encodeURIComponent neutralizes xss before the res.send sink —
// the finding is suppressed and the kill persists as a SANITIZES edge.
export function sendEncoded(req: { query: string }, res: { send(v: string): void }): void {
const value = encodeURIComponent(req.query);
res.send(value);
}

View file

@ -4,6 +4,8 @@ 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';
import { fixtureTaintTotals } from '../../helpers/taint-fixture.js';
// U7 — end-to-end proof that the `--pdg` opt-in reaches BOTH sinks: the parse
// worker builds a per-function CFG (workerData.pdg) and scope-resolution emits
@ -17,6 +19,8 @@ function counts(result: PipelineResult): {
basicBlocks: number;
cfgEdges: number;
reachingDefs: number;
tainted: number;
sanitizes: number;
} {
let basicBlocks = 0;
result.graph.forEachNode((n) => {
@ -24,11 +28,15 @@ function counts(result: PipelineResult): {
});
let cfgEdges = 0;
let reachingDefs = 0;
let tainted = 0;
let sanitizes = 0;
for (const rel of result.graph.iterRelationships()) {
if (rel.type === 'CFG') cfgEdges++;
if (rel.type === 'REACHING_DEF') reachingDefs++;
if (rel.type === 'TAINTED') tainted++;
if (rel.type === 'SANITIZES') sanitizes++;
}
return { basicBlocks, cfgEdges, reachingDefs };
return { basicBlocks, cfgEdges, reachingDefs, tainted, sanitizes };
}
const tmpDirs: string[] = [];
@ -69,11 +77,74 @@ describe('U7 — end-to-end --pdg pipeline', () => {
}
}, 60000);
// M3 (#2083 U4/U7): the taint layer rides the same gate. The fixture's
// vuln.ts carries one vulnerable flow (req.body → child_process.exec) and
// one sanitized variant (encodeURIComponent before res.send); taint-cases.ts
// adds the U7 acceptance battery (direct, multi-hop, conditional-sanitizer,
// loop-carried, through-call).
it('with --pdg on: emits TAINTED + SANITIZES edges with decodable hop reasons', async () => {
const result = await runPipelineFromRepo(freshRepo(), () => {}, { pdg: true });
const blockIds = new Set<string>();
result.graph.forEachNode((n) => {
if (n.label === 'BasicBlock') blockIds.add(n.id);
});
const { tainted, sanitizes, reachingDefs } = counts(result);
expect(tainted).toBeGreaterThan(0);
expect(sanitizes).toBeGreaterThanOrEqual(1);
// AE2 (AC2) — sparse persistence. The load-bearing O(findings) gate is
// EXACT equality: one TAINTED row per pure-path finding and one SANITIZES
// row per kill over the same fixture, computed through the shared harness
// so the worker pipeline and the snapshot suite cannot drift apart. Any
// REACHING_DEF-style row multiplication (per-fact, per-block-pair, …)
// breaks the equality immediately.
const expected = fixtureTaintTotals(FIXTURE);
expect(expected.findings).toBeGreaterThan(0);
expect(tainted).toBe(expected.findings);
expect(sanitizes).toBe(expected.kills);
// Ratio sanity vs the dense RD projection on the SAME run. The fixture is
// deliberately finding-DENSE (nearly every function is a vulnerable
// acceptance case), so the honest measured ratio here is ~22% (8 taint
// rows vs 37 RD rows) — the < 0.5 bound still catches any per-fact
// explosion (which would multiply taint rows past RD); the representative
// ≪-RD posture on realistic density is gated by the bench taint scenario's
// absolute boundedness/byte ceilings (bench/cfg).
expect(tainted + sanitizes).toBeLessThan(reachingDefs * 0.5);
let sawVulnFlow = false;
for (const rel of result.graph.iterRelationships()) {
if (rel.type !== 'TAINTED' && rel.type !== 'SANITIZES') continue;
// Both endpoints are persisted BasicBlock nodes (the shared id template).
expect(blockIds.has(rel.sourceId)).toBe(true);
expect(blockIds.has(rel.targetId)).toBe(true);
if (rel.type === 'TAINTED') {
// The reason is the versioned hop encoding — decodable by the SHARED
// codec (U6's explain imports the same module), variables carried.
const decoded = decodeTaintPath(rel.reason);
expect(decoded.ok).toBe(true);
if (decoded.ok) {
expect(decoded.hops.length).toBeGreaterThan(0);
for (const hop of decoded.hops) {
expect(hop.variable.length).toBeGreaterThan(0);
expect(hop.line).toBeGreaterThan(0);
}
if (decoded.hops.some((h) => h.variable === 'cmd')) sawVulnFlow = true;
}
} else {
// SANITIZES carries the killed binding's plain name: `value` from
// vuln.ts sendEncoded, `text` from taint-cases.ts conditionalSanitizer.
expect(['value', 'text']).toContain(rel.reason);
}
}
expect(sawVulnFlow).toBe(true); // the req.body → exec flow, via `cmd`
}, 60000);
it('with --pdg off (default): emits zero BasicBlock nodes and zero CFG edges', async () => {
const result = await runPipelineFromRepo(freshRepo(), () => {});
const { basicBlocks, cfgEdges, reachingDefs } = counts(result);
const { basicBlocks, cfgEdges, reachingDefs, tainted, sanitizes } = counts(result);
expect(basicBlocks).toBe(0);
expect(cfgEdges).toBe(0);
expect(reachingDefs).toBe(0);
expect(tainted).toBe(0);
expect(sanitizes).toBe(0);
}, 60000);
});

View file

@ -0,0 +1,144 @@
import { describe, it, expect } from 'vitest';
import path from 'path';
import {
computeFixtureTaint,
TAINT_FIXTURE_FILES,
type FixtureFunctionTaint,
} from '../../helpers/taint-fixture.js';
// #2083 M3 U7 acceptance: a committed snapshot of the taint findings/kills on
// the pdg-repo fixture battery (vuln.ts + taint-cases.ts, with sample.ts as
// the zero-match control), mirroring reaching-defs-snapshot. The pure path —
// collect → match → computeReachingDefs → computeTaintFlows — is the SAME
// per-function pipeline the in-phase emit driver runs, so any model/matcher/
// propagation behavior change shows as a reviewable snapshot diff, never
// silent drift. The fixture battery covers the plan's six shapes: direct
// source→sink (rule-b AND the reassignment form), multi-hop chain, sanitized
// variant (must-def kill suppresses), conditional-sanitizer variant (finding
// survives), loop-carried taint, and through-call (viaCall hop).
const FIXTURE = path.join(__dirname, 'fixtures', 'pdg-repo');
/**
* Deterministic rendering. Findings: `var@line[*]->…->var@line[*]:kind`
* (source-first hop order; `*` marks a viaCall hop — taint passed through an
* unmodeled call). Kills: `binding@defLine<-sanLine:kind[,kind]`.
*/
function serialize(fn: FixtureFunctionTaint): Record<string, unknown> {
const bindings = fn.cfg.bindings ?? [];
const bName = (idx: number): string => bindings[idx]?.name ?? `#${idx}`;
return {
file: fn.file,
startLine: fn.startLine,
status: fn.status,
findings: (fn.flows?.findings ?? []).map(
(f) =>
f.hops.map((h) => `${h.name}@${h.point.line}${h.viaCall === true ? '*' : ''}`).join('->') +
`:${f.sinkKind}` +
(f.hopsTruncated === true ? ' (truncated)' : ''),
),
kills: (fn.flows?.kills ?? []).map(
(k) =>
`${bName(k.bindingIdx)}@${k.killedDef.line}<-${k.sanitizer.line}:${k.neutralized.join(',')}`,
),
dropped: fn.flows?.droppedFindings ?? 0,
};
}
describe('U7 — taint findings/kills snapshot on the pdg-repo fixture battery', () => {
const results = computeFixtureTaint(FIXTURE);
const blockText = (fn: FixtureFunctionTaint, needle: string): boolean =>
fn.cfg.blocks.some((b) => b.text.includes(needle));
it('matches the committed findings/kills for every fixture function', () => {
// Every fixture file contributes at least one function; the battery shape
// is pinned so a fixture edit that drops a case fails loudly here, not
// silently in the snapshot.
for (const file of TAINT_FIXTURE_FILES) {
expect(results.some((r) => r.file === file)).toBe(true);
}
expect(results.map(serialize)).toMatchSnapshot();
});
it('every matched fixture function computes (no coverage gaps, no unsafe sites)', () => {
for (const fn of results) {
expect(['computed', 'no-match']).toContain(fn.status);
if (fn.flows) expect(fn.flows.droppedFindings).toBe(0);
}
// sample.ts is the zero-match control: no sources/sinks → fast path.
for (const fn of results.filter((r) => r.file === 'sample.ts')) {
expect(fn.status).toBe('no-match');
}
});
it('AE1 — the source→sink flow IS found; the sanitized variant yields no finding and ≥1 kill', () => {
// vuln.ts runUserCommand: req.body → cmd → exec(cmd).
const vulnerable = results.find((r) => r.file === 'vuln.ts' && blockText(r, 'exec(cmd)'))!;
expect(vulnerable).toBeDefined();
expect(vulnerable.status).toBe('computed');
expect(vulnerable.flows!.findings).toHaveLength(1);
expect(vulnerable.flows!.findings[0].sinkKind).toBe('command-injection');
// vuln.ts sendEncoded: the must-def encodeURIComponent kill suppresses
// the xss finding entirely; the kill IS the persisted safety evidence.
const sanitized = results.find(
(r) => r.file === 'vuln.ts' && blockText(r, 'encodeURIComponent'),
)!;
expect(sanitized).toBeDefined();
expect(sanitized.status).toBe('computed');
expect(sanitized.flows!.findings).toHaveLength(0);
expect(sanitized.flows!.kills.length).toBeGreaterThanOrEqual(1);
expect(sanitized.flows!.kills[0].neutralized).toContain('xss');
});
it('AE1 — the conditional-sanitizer variant survives (may-def leg) with the kill recorded', () => {
const conditional = results.find(
(r) => r.file === 'taint-cases.ts' && blockText(r, 'res.send(text)'),
)!;
expect(conditional).toBeDefined();
expect(conditional.flows!.findings).toHaveLength(1);
expect(conditional.flows!.findings[0].sinkKind).toBe('xss');
expect(conditional.flows!.kills.length).toBeGreaterThanOrEqual(1);
});
it('AE3 shape — hops are ordered source-first with a variable on every hop', () => {
// Every finding in the battery carries non-empty variables on all hops.
for (const fn of results) {
for (const f of fn.flows?.findings ?? []) {
expect(f.hops.length).toBeGreaterThan(0);
for (const h of f.hops) {
expect(h.name.length).toBeGreaterThan(0);
expect(h.point.line).toBeGreaterThan(0);
}
}
}
// The multi-hop chain (a → b → c → exec(c)): 3+ hops, source-first order.
const chain = results.find((r) => r.file === 'taint-cases.ts' && blockText(r, 'const c = b'))!;
expect(chain).toBeDefined();
const hops = chain.flows!.findings[0].hops;
expect(hops.length).toBeGreaterThanOrEqual(4);
expect(hops.map((h) => h.name)).toEqual(['a', 'b', 'c', 'c']);
for (let i = 1; i < hops.length; i++) {
expect(hops[i].point.line).toBeGreaterThanOrEqual(hops[i - 1].point.line);
}
});
it('loop-carried taint reaches a fixpoint and the sink (terminates, one finding)', () => {
const loop = results.find(
(r) => r.file === 'taint-cases.ts' && blockText(r, 'cmd = cmd + part'),
)!;
expect(loop).toBeDefined();
expect(loop.status).toBe('computed');
expect(loop.flows!.findings).toHaveLength(1);
expect(loop.flows!.findings[0].sinkKind).toBe('command-injection');
});
it('through-call taint propagates with the viaCall hop mark (KTD5)', () => {
const through = results.find(
(r) => r.file === 'taint-cases.ts' && blockText(r, 'decorate(raw)'),
)!;
expect(through).toBeDefined();
expect(through.flows!.findings).toHaveLength(1);
expect(through.flows!.findings[0].hops.some((h) => h.viaCall === true)).toBe(true);
});
});

View file

@ -1,3 +1,4 @@
import { createHash } from 'crypto';
import { describe, it, expect } from 'vitest';
import Parser from 'tree-sitter';
import TypeScript from 'tree-sitter-typescript';
@ -183,3 +184,89 @@ describe('#2082 M2 — the REACHING_DEF emit cap does NOT perturb the chunk key'
expect(withExtra).toBe(base);
});
});
describe('#2083 M3 U1 — taint sites cross the worker/store boundary intact', () => {
const siteSource = `function handler(req, x) {
const cp = require('child_process');
const b = req.body;
cp.exec(escape(x), b);
sql\`select \${x}\`;
run(...b);
}`;
function siteCfgs() {
const { cfgs } = collectFunctionCfgs(tsRoot(siteSource), tsVisitor(), 'sites.ts');
expect(cfgs).toHaveLength(1);
return cfgs;
}
function allSites(cfgs: readonly { blocks: readonly { statements?: readonly unknown[] }[] }[]) {
return cfgs.flatMap((c) =>
c.blocks.flatMap((b) =>
(b.statements ?? []).flatMap((s) => (s as { sites?: unknown[] }).sites ?? []),
),
);
}
it('sites survive the worker JSON boundary (mapReplacer/mapReviver) byte-equal', () => {
const cfgs = siteCfgs();
expect(allSites(cfgs).length).toBeGreaterThan(0);
const round = JSON.parse(JSON.stringify(cfgs, mapReplacer), mapReviver);
expect(round).toEqual(cfgs);
expect(allSites(round)).toEqual(allSites(cfgs));
});
it('sites survive a frozen re-wrap + the DURABLE store interning reviver (no nodeId-dedup loss)', async () => {
const { makeInterningReviver } = await import('../../../src/storage/parsedfile-store.js');
const cfgs = siteCfgs();
// The pipeline deep-freezes ParsedFiles and re-wraps via spread — the CFG
// payload itself rides by reference and must tolerate being frozen.
const deepFreeze = (o: unknown): unknown => {
if (o && typeof o === 'object') {
for (const v of Object.values(o)) deepFreeze(v);
Object.freeze(o);
}
return o;
};
const frozen = (deepFreeze(cfgs) as typeof cfgs).map((c) => ({ ...c }));
const raw = JSON.stringify(frozen, mapReplacer);
// The durable parsedfile-cache revives with the interning reviver, which
// DEDUPS any object carrying a string `nodeId` field — SiteRecord must
// never trip it (the KTD2 "no field named nodeId" obligation).
const revived = JSON.parse(raw, makeInterningReviver(new Map(), new Map()));
expect(revived).toEqual(frozen);
expect(allSites(revived)).toEqual(allSites(cfgs));
});
});
describe('#2083 M3 U1 — pdg chunk-key namespace version (flag-off keys untouched)', () => {
const entries = [
{ filePath: 'b.ts', contentHash: 'h2' },
{ filePath: 'a.ts', contentHash: 'h1' },
];
it('flag-off chunk keys are BYTE-IDENTICAL across the M3 namespace bump (pinned hash)', () => {
// Independent reconstruction of the pre-namespace key format: pdg-off
// keys are sha256 over the sorted filePath:contentHash lines and NOTHING
// else. This pin fails if the version token ever leaks into non-pdg keys
// (which would force a cold re-parse on every flag-off user).
const expected = createHash('sha256').update(Buffer.from('a.ts:h1\nb.ts:h2')).digest('hex');
expect(computeChunkHash(entries, false)).toBe(expected);
expect(computeChunkHash(entries)).toBe(expected);
});
it('pdg-mode keys CHANGED from the M2-era namespace (v1 chunks invalidate on upgrade)', () => {
// The M2-era pdg namespace was `pdg:1;maxFn=<v>` — an M3 binary must not
// serve a v1 chunk (its cfgSideChannel lacks `sites`, so taint would
// silently no-op on warm caches).
const joined = 'a.ts:h1\nb.ts:h2';
const m2Key = createHash('sha256')
.update(Buffer.from(`pdg:1;maxFn=def\n${joined}`))
.digest('hex');
expect(computeChunkHash(entries, { pdg: true })).not.toBe(m2Key);
// and the v2 key is still deterministic + order-independent
expect(computeChunkHash([...entries].reverse(), { pdg: true })).toBe(
computeChunkHash(entries, { pdg: true }),
);
});
});

View file

@ -0,0 +1,344 @@
/**
* Integration Tests: MCP `explain` tool (#2083 M3 U6)
*
* End-to-end against a REAL LadybugDB: the pdg-repo fixture is indexed by the
* real pipeline with `--pdg` (workers — requires `node scripts/build.js`), the
* resulting BasicBlock nodes + TAINTED/SANITIZES edges and the fixture's
* Function symbols are persisted into the test DB, and `explain` is exercised
* through the full `callTool` dispatch:
*
* - anchorless enumerate (≥1 finding, decoded hops, deterministic order)
* - anchored by file and by symbol (line-span granularity)
* - sanitized-only function → zero TAINTED findings (its safety evidence is
* the SANITIZES edge, not part of explain's response)
* - unknown symbol → context()-style not-found
* - a repo WITHOUT the taint layer → the "no taint layer" note, not an error
*
* Seeding via the real emit output (not hand-written rows) pins the format
* compatibility between U4's write path and U6's read path — id template,
* `;<kind>` reason header, hop encoding.
*/
import { describe, it, expect, beforeAll, vi } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
import { listRegisteredRepos, loadMeta } from '../../src/storage/repo-manager.js';
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/storage/repo-manager.js')>();
return {
...actual,
listRegisteredRepos: vi.fn().mockResolvedValue([]),
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
findSiblingClones: vi.fn().mockResolvedValue([]),
// No meta.json exists for the seeded test DB — explain's meta probe must
// degrade to the TAINTED-row existence probe (the seeded-DB reality).
loadMeta: vi.fn().mockResolvedValue(null),
};
});
const FIXTURE = path.join(__dirname, 'cfg', 'fixtures', 'pdg-repo');
// ─── Block 1: a --pdg index with real taint findings ─────────────────
withTestLbugDB(
'taint-explain',
(handle) => {
describe('explain tool against a --pdg index', () => {
let backend: LocalBackend;
beforeAll(() => {
const ext = handle as typeof handle & { _backend?: LocalBackend };
if (!ext._backend) {
throw new Error(
'LocalBackend not initialized — afterSetup did not attach _backend to handle',
);
}
backend = ext._backend;
});
it('anchorless explain enumerates the persisted findings with decoded hops', async () => {
const result = await backend.callTool('explain', {});
expect(result).not.toHaveProperty('error');
expect(result.totalFindings).toBeGreaterThanOrEqual(1);
expect(result.findings.length).toBeGreaterThanOrEqual(1);
expect(result.truncated).toBeUndefined();
// The vulnerable flow: req.body → cmd → exec(cmd) in vuln.ts.
const vuln = result.findings.find((f: any) => f.file.endsWith('vuln.ts'));
expect(vuln).toBeDefined();
expect(vuln.sinkKind).toBe('command-injection');
expect(vuln.functionLine).toBe(9); // runUserCommand's start line
// Ordered hops with the variable carried on each hop (AC3): seed def
// (cmd @ const line 10) → sink use (cmd @ exec line 11).
expect(vuln.hops.map((h: any) => `${h.variable}@${h.line}`)).toEqual(['cmd@10', 'cmd@11']);
expect(vuln.source).toEqual({ variable: 'cmd', line: 10 });
expect(vuln.sink).toEqual({ line: 11 });
expect(vuln.pathIncomplete).toBeUndefined();
// The intra-procedural contract caveat reaches the consumer.
expect(result.note).toMatch(/intra-procedural/i);
});
it('anchorless enumerate is deterministic across calls', async () => {
const a = await backend.callTool('explain', {});
const b = await backend.callTool('explain', {});
expect(a).toEqual(b);
});
it('anchored by file path returns the finding (suffix match accepted)', async () => {
for (const target of ['vuln.ts']) {
const result = await backend.callTool('explain', { target });
expect(result).not.toHaveProperty('error');
expect(result.anchor).toEqual({ file: target });
expect(result.findings.length).toBeGreaterThanOrEqual(1);
for (const f of result.findings) expect(f.file.endsWith('vuln.ts')).toBe(true);
}
});
it('anchored by an unrelated file returns zero findings (repo HAS the layer — no note about it)', async () => {
const result = await backend.callTool('explain', { target: 'sample.ts' });
expect(result).not.toHaveProperty('error');
expect(result.findings).toEqual([]);
expect(result.totalFindings).toBe(0);
// The repo has TAINTED rows, so the "no taint layer" hint must NOT fire.
expect(result.note ?? '').not.toMatch(/no taint layer/i);
});
it('anchored by the vulnerable function name returns full hop detail', async () => {
const result = await backend.callTool('explain', { target: 'runUserCommand' });
expect(result).not.toHaveProperty('error');
expect(result.anchor.symbol).toBe('runUserCommand');
expect(result.findings).toHaveLength(1);
expect(result.totalFindings).toBe(1);
const f = result.findings[0];
expect(f.sinkKind).toBe('command-injection');
expect(f.hops.map((h: any) => h.variable)).toEqual(['cmd', 'cmd']);
});
it('the sanitized-only function returns no TAINTED finding', async () => {
const result = await backend.callTool('explain', { target: 'sendEncoded' });
expect(result).not.toHaveProperty('error');
expect(result.anchor.symbol).toBe('sendEncoded');
expect(result.findings).toEqual([]);
expect(result.totalFindings).toBe(0);
});
it('an unknown symbol target mirrors context() not-found semantics', async () => {
const result = await backend.callTool('explain', { target: 'nonexistentTaintFn999' });
expect(result).toHaveProperty('error');
expect(result.error).toMatch(/not found/i);
});
it('a dotted symbol name resolves as a symbol, not a silent file miss', async () => {
// Regression: `Class.method` was classified as a file (the `.method`
// extension-like suffix) and returned a silent empty file-anchored
// result. It must now route to symbol resolution — here, not-found.
const result = await backend.callTool('explain', { target: 'UserController.create' });
expect(result).toHaveProperty('error');
expect(result.error).toMatch(/not found/i);
// Must NOT be a silent file-anchored empty result.
expect(result.anchor).toBeUndefined();
});
it('a dotted symbol whose tail looks bare still resolves as a symbol', async () => {
// `runUserCommand` is a real fixture symbol; a dotted lead-in that does
// not match any symbol confirms the symbol branch (not file routing).
const result = await backend.callTool('explain', { target: 'Service.runUserCommand' });
expect(result).toHaveProperty('error');
expect(result.error).toMatch(/not found/i);
});
it('rejects an out-of-bounds limit with a clear error', async () => {
// Includes the non-integer / non-finite / non-numeric cases the
// interpolated `LIMIT ${limit}` depends on the guard rejecting.
for (const limit of [0, -1, 1.5, 10_000, NaN, Infinity, -Infinity, '50']) {
const result = await backend.callTool('explain', { limit });
expect(result).toHaveProperty('error');
expect(result.error).toMatch(/limit/i);
}
});
it('limit pages the enumerate and reports truncation honestly', async () => {
const all = await backend.callTool('explain', {});
const page = await backend.callTool('explain', { limit: 1 });
expect(page.findings).toHaveLength(Math.min(1, all.totalFindings));
expect(page.totalFindings).toBe(all.totalFindings);
if (all.totalFindings > 1) {
expect(page.truncated).toBe(true);
// Deterministic order: the page is a prefix of the full enumerate.
expect(page.findings[0]).toEqual(all.findings[0]);
}
});
});
},
{
poolAdapter: true,
afterSetup: async (handle) => {
// 1. Index the pdg-repo fixture with the REAL pipeline (--pdg on).
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-explain-'));
try {
fs.cpSync(FIXTURE, repoDir, { recursive: true });
const pipelineResult = await runPipelineFromRepo(repoDir, () => {}, { pdg: true });
// 2. Persist the emit output into the test DB: BasicBlock nodes,
// TAINTED/SANITIZES edges, and the Function symbols (for the
// symbol-anchored path through resolveSymbolCandidates).
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
const nodes: Array<{ label: string; props: Record<string, unknown> }> = [];
pipelineResult.graph.forEachNode((n) => {
if (n.label === 'BasicBlock') {
nodes.push({
label: 'BasicBlock',
props: {
id: n.id,
filePath: n.properties.filePath ?? '',
startLine: n.properties.startLine ?? 0,
endLine: n.properties.endLine ?? 0,
text: n.properties.text ?? '',
},
});
} else if (n.label === 'Function') {
nodes.push({
label: 'Function',
props: {
id: n.id,
name: n.properties.name ?? '',
filePath: n.properties.filePath ?? '',
startLine: n.properties.startLine ?? 0,
endLine: n.properties.endLine ?? 0,
},
});
}
});
for (const node of nodes) {
const assignments = Object.keys(node.props)
.map((k) => `${k}: $${k}`)
.join(', ');
await adapter.executePrepared(
`CREATE (n:${node.label} {${assignments}})`,
node.props as Record<string, any>,
);
}
let taintEdges = 0;
for (const rel of pipelineResult.graph.iterRelationships()) {
if (rel.type !== 'TAINTED' && rel.type !== 'SANITIZES') continue;
await adapter.executePrepared(
`MATCH (a:BasicBlock {id: $src}), (b:BasicBlock {id: $dst})
CREATE (a)-[:CodeRelation {type: '${rel.type}', confidence: $confidence, reason: $reason, step: 0}]->(b)`,
{
src: rel.sourceId,
dst: rel.targetId,
confidence: rel.confidence ?? 1.0,
reason: rel.reason ?? '',
},
);
taintEdges++;
}
if (taintEdges === 0) {
throw new Error('fixture produced no TAINTED/SANITIZES edges — taint emit regressed?');
}
} finally {
fs.rmSync(repoDir, { recursive: true, force: true });
}
// 3. Register the test DB and boot the backend (calltool harness shape).
vi.mocked(listRegisteredRepos).mockResolvedValue([
{
name: 'taint-repo',
path: '/taint/repo',
storagePath: handle.tmpHandle.dbPath,
indexedAt: new Date().toISOString(),
lastCommit: 'abc123',
stats: { files: 2, nodes: 4, communities: 0, processes: 0 },
},
]);
const backend = new LocalBackend();
await backend.init();
(handle as any)._backend = backend;
},
},
);
// ─── Block 2: a repo indexed WITHOUT --pdg ───────────────────────────
withTestLbugDB(
'taint-explain-nopdg',
(handle) => {
describe('explain tool without a taint layer', () => {
let backend: LocalBackend;
beforeAll(() => {
const ext = handle as typeof handle & { _backend?: LocalBackend };
if (!ext._backend) {
throw new Error(
'LocalBackend not initialized — afterSetup did not attach _backend to handle',
);
}
backend = ext._backend;
});
it('returns the no-taint-layer note via the row-existence probe (meta unreadable)', async () => {
const result = await backend.callTool('explain', {});
expect(result).not.toHaveProperty('error');
expect(result.findings).toEqual([]);
expect(result.totalFindings).toBe(0);
expect(result.note).toMatch(/no taint layer/i);
expect(result.note).toContain('--pdg');
});
it('an anchored call also reports the missing layer, not a bogus empty result', async () => {
const result = await backend.callTool('explain', { target: 'plain.ts' });
expect(result).not.toHaveProperty('error');
expect(result.findings).toEqual([]);
expect(result.note).toMatch(/no taint layer/i);
});
it('returns the note via the RepoMeta.pdg probe when meta is readable but unstamped', async () => {
// A readable meta WITHOUT a pdg stamp short-circuits before any
// block-space query (the #2099 F1 presence ≡ layer-exists contract).
vi.mocked(loadMeta).mockResolvedValueOnce({} as any);
const result = await backend.callTool('explain', {});
expect(result.findings).toEqual([]);
expect(result.totalFindings).toBe(0);
expect(result.note).toMatch(/no taint layer/i);
});
it('an M1/M2-era pdg stamp (no taintModelVersion) reports the missing taint layer', async () => {
// The pdg stamp exists (BasicBlock/REACHING_DEF were recorded) but
// taint never ran — no taintModelVersion. The taint-layer probe must
// gate on taintModelVersion, not generic pdg presence, so this surfaces
// the actionable "run analyze" hint instead of a bare empty result.
vi.mocked(loadMeta).mockResolvedValueOnce({
pdg: { mode: 'on', maxFunctionLines: 2000 },
} as any);
const result = await backend.callTool('explain', {});
expect(result.findings).toEqual([]);
expect(result.totalFindings).toBe(0);
expect(result.note).toMatch(/no taint layer/i);
});
});
},
{
seed: [
`CREATE (fn:Function {id: 'func:plainFn', name: 'plainFn', filePath: 'src/plain.ts', startLine: 1, endLine: 5, isExported: true, content: 'function plainFn() {}', description: 'no taint layer here'})`,
],
poolAdapter: true,
afterSetup: async (handle) => {
vi.mocked(listRegisteredRepos).mockResolvedValue([
{
name: 'plain-repo',
path: '/plain/repo',
storagePath: handle.tmpHandle.dbPath,
indexedAt: new Date().toISOString(),
lastCommit: 'def456',
stats: { files: 1, nodes: 1, communities: 0, processes: 0 },
},
]);
const backend = new LocalBackend();
await backend.init();
(handle as any)._backend = backend;
},
},
);

View file

@ -1,12 +1,6 @@
import { describe, it, expect } from 'vitest';
import Parser from 'tree-sitter';
import TypeScript from 'tree-sitter-typescript';
import type { SyntaxNode } from '../../../src/core/ingestion/utils/ast-helpers.js';
import {
createTypeScriptCfgVisitor,
TS_FUNCTION_TYPES,
} from '../../../src/core/ingestion/cfg/visitors/typescript.js';
import type { FunctionCfg, StatementFacts } from '../../../src/core/ingestion/cfg/types.js';
import { cfgOf } from '../../helpers/ts-cfg-harness.js';
// U1 (#2082 M2) — per-statement def/use harvesting. The two-phase design
// (declaration pre-scan → resolve during the CFG walk) is what makes the
@ -14,37 +8,6 @@ import type { FunctionCfg, StatementFacts } from '../../../src/core/ingestion/cf
// and do-while-condition-first, so declare-as-you-walk would mis-key common
// code. Each test pins names→binding-index agreement, not just presence.
const visitor = createTypeScriptCfgVisitor();
function parse(code: string): SyntaxNode {
const parser = new Parser();
parser.setLanguage(TypeScript.typescript);
return parser.parse(code).rootNode;
}
function collectFunctions(root: SyntaxNode): SyntaxNode[] {
const out: SyntaxNode[] = [];
const stack = [root];
while (stack.length) {
const n = stack.pop() as SyntaxNode;
if (TS_FUNCTION_TYPES.has(n.type)) out.push(n);
for (let i = n.namedChildCount - 1; i >= 0; i--) {
const c = n.namedChild(i);
if (c) stack.push(c);
}
}
return out;
}
function cfgOf(code: string, index = 0): FunctionCfg {
const fns = collectFunctions(parse(code));
const fn = fns[index];
if (!fn) throw new Error(`no function at index ${index}`);
const cfg = visitor.buildFunctionCfg(fn, 'fixture.ts');
if (!cfg) throw new Error('buildFunctionCfg returned undefined');
return cfg;
}
/** All statement facts of the CFG, flattened in (block, statement) order. */
function allFacts(cfg: FunctionCfg): StatementFacts[] {
return cfg.blocks.flatMap((b) => [...(b.statements ?? [])]);
@ -491,3 +454,259 @@ describe('TS/JS def/use harvest — conditional contexts are MAY-defs (tri-revie
expect(withDef.length).toBeGreaterThanOrEqual(2);
});
});
// ── #2083 M3 U1 — taint-site harvest ────────────────────────────────────────
import type { SiteRecord } from '../../../src/core/ingestion/cfg/types.js';
/** All site records of the CFG, flattened in (block, statement) order. */
function allSites(cfg: FunctionCfg): SiteRecord[] {
return allFacts(cfg).flatMap((f) => [...(f.sites ?? [])]);
}
/** The single statement fact carrying sites (throws when ambiguous). */
function siteFact(cfg: FunctionCfg, line?: number): StatementFacts {
const withSites = allFacts(cfg).filter(
(f) => (f.sites?.length ?? 0) > 0 && (line === undefined || f.line === line),
);
if (withSites.length !== 1)
throw new Error(`expected 1 site-bearing fact, got ${withSites.length}`);
return withSites[0];
}
describe('M3 U1 — taint-site harvest: call sites', () => {
it('exec(a, b) → one call site mapping position 0→[a], 1→[b]', () => {
const cfg = cfgOf(`function f(a, b) { exec(a, b); }`);
const sites = siteFact(cfg, 1).sites!;
expect(sites).toHaveLength(1);
const s = sites[0];
expect(s.kind).toBe('call');
expect(s.callee).toBe('exec');
expect(s.receiver).toBeUndefined();
expect(s.args).toEqual([[bindingIdx(cfg, 'a')], [bindingIdx(cfg, 'b')]]);
expect(s.parent).toBeUndefined();
});
it('child_process.exec(cmd) → dotted callee path + receiver slot', () => {
const cfg = cfgOf(`function f(cmd) { child_process.exec(cmd); }`);
const s = siteFact(cfg, 1).sites![0];
expect(s.callee).toBe('child_process.exec');
expect(s.receiver).toBe(bindingIdx(cfg, 'child_process'));
expect(s.args).toEqual([[bindingIdx(cfg, 'cmd')]]);
// chain-length-1 callee: the access IS the callee — no member-read site
expect(siteFact(cfg, 1).sites).toHaveLength(1);
// and the receiver use is recorded exactly once (no double-record)
expect(
siteFact(cfg, 1).uses.filter((u) => u === bindingIdx(cfg, 'child_process')),
).toHaveLength(1);
});
it('const r = f(x) → resultDefs carries r', () => {
const cfg = cfgOf(`function g(x) { const r = f(x); }`);
const s = siteFact(cfg, 1).sites![0];
expect(s.resultDefs).toEqual([bindingIdx(cfg, 'r')]);
expect(s.args).toEqual([[bindingIdx(cfg, 'x')]]);
});
it('exec(escape(x)) → inner site is first-class with parent link + occurrence tagging', () => {
const cfg = cfgOf(`function f(x) { exec(escape(x)); }`);
const sites = siteFact(cfg, 1).sites!;
expect(sites).toHaveLength(2);
const execIdx = sites.findIndex((s) => s.callee === 'exec');
const escapeIdx = sites.findIndex((s) => s.callee === 'escape');
expect(execIdx).toBeGreaterThanOrEqual(0);
expect(escapeIdx).toBeGreaterThanOrEqual(0);
const x = bindingIdx(cfg, 'x');
// inner escape: plain occurrence, parent link to (exec, arg 0)
expect(sites[escapeIdx].args).toEqual([[x]]);
expect(sites[escapeIdx].parent).toEqual([execIdx, 0]);
// outer exec: x's occurrence is via-tagged through the escape site
expect(sites[execIdx].args).toEqual([[[x, escapeIdx]]]);
expect(sites[execIdx].parent).toBeUndefined();
});
it('a bypass occurrence stays a PLAIN entry next to the via-tagged one (exec(x + escape(x)))', () => {
const cfg = cfgOf(`function f(x) { exec(x + escape(x)); }`);
const sites = siteFact(cfg, 1).sites!;
const exec = sites.find((s) => s.callee === 'exec')!;
const escapeIdx = sites.findIndex((s) => s.callee === 'escape');
const x = bindingIdx(cfg, 'x');
expect(exec.args![0]).toEqual([x, [x, escapeIdx]]);
});
it('new Function(x) → kind "new" site (new_expression case)', () => {
const cfg = cfgOf(`function f(x) { new Function(x); }`);
const s = siteFact(cfg, 1).sites![0];
expect(s.kind).toBe('new');
expect(s.callee).toBe('Function');
expect(s.args).toEqual([[bindingIdx(cfg, 'x')]]);
});
it('exec(...args) → spread index recorded, args binding occurs at the position', () => {
const cfg = cfgOf(`function f(...args) { exec(...args); }`);
const s = siteFact(cfg, 1).sites![0];
expect(s.spread).toBe(0);
expect(s.args).toEqual([[bindingIdx(cfg, 'args')]]);
});
it('const cp = require("child_process") → requireArg literal + cp in resultDefs', () => {
const cfg = cfgOf(`function f() { const cp = require('child_process'); }`);
const s = siteFact(cfg, 1).sites![0];
expect(s.callee).toBe('require');
expect(s.requireArg).toBe('child_process');
expect(s.resultDefs).toEqual([bindingIdx(cfg, 'cp')]);
});
it('per-declarator attribution: const a = t, b = escape(t) → resultDefs [b] only', () => {
const cfg = cfgOf(`function f(t) { const a = t, b = escape(t); }`);
const sites = siteFact(cfg, 1).sites!;
expect(sites).toHaveLength(1);
expect(sites[0].callee).toBe('escape');
expect(sites[0].resultDefs).toEqual([bindingIdx(cfg, 'b')]);
});
it('non-top-level call gets NO resultDefs (const c = cond ? escape(b) : b keeps c taintable)', () => {
const cfg = cfgOf(`function f(cond, b) { const c = cond ? escape(b) : b; }`);
const sites = siteFact(cfg, 1).sites!;
expect(sites).toHaveLength(1);
expect(sites[0].callee).toBe('escape');
expect(sites[0].resultDefs).toBeUndefined();
});
it('value wrappers unwrap for resultDefs: const b = (await escape(t))! still attaches [b]', () => {
const cfg = cfgOf(`async function f(t) { const b = (await escape(t))!; }`);
const s = siteFact(cfg, 1).sites![0];
expect(s.resultDefs).toEqual([bindingIdx(cfg, 'b')]);
});
it('plain assignment x = f(y) attaches resultDefs [x]', () => {
const cfg = cfgOf(`function g(y) { let x; x = f(y); }`);
const s = siteFact(cfg, 1).sites![0];
expect(s.resultDefs).toEqual([bindingIdx(cfg, 'x')]);
});
});
describe('M3 U1 — taint-site harvest: member reads', () => {
it('const b = req.body → member read {object: req, property: body} AND b in defs', () => {
const cfg = cfgOf(`function f(req) { const b = req.body; }`);
const fact = siteFact(cfg, 1);
expect(fact.defs).toContain(bindingIdx(cfg, 'b'));
expect(fact.sites).toEqual([
{ kind: 'member-read', object: bindingIdx(cfg, 'req'), property: 'body' },
]);
});
it('req?.body records identically to req.body (optional-chain normalization)', () => {
const plain = cfgOf(`function f(req) { const b = req.body; }`);
const optional = cfgOf(`function f(req) { const b = req?.body; }`);
expect(siteFact(optional, 1).sites).toEqual(siteFact(plain, 1).sites);
});
it('req["body"] records as a member read; dynamic req[key] records NOTHING', () => {
const literal = cfgOf(`function f(req) { const c = req["body"]; }`);
expect(siteFact(literal, 1).sites).toEqual([
{ kind: 'member-read', object: bindingIdx(literal, 'req'), property: 'body' },
]);
const dynamic = cfgOf(`function f(req, key) { const d = req[key]; }`);
expect(allSites(dynamic)).toHaveLength(0);
// the dynamic index is still a value use
expect(usesOf(dynamic)).toContain(bindingIdx(dynamic, 'key'));
});
it('exec(req.body.toString()) → the mid-callee-chain member read IS recorded', () => {
const cfg = cfgOf(`function f(req) { exec(req.body.toString()); }`);
const sites = siteFact(cfg, 1).sites!;
const read = sites.find((s) => s.kind === 'member-read');
expect(read).toBeDefined();
expect(read!.object).toBe(bindingIdx(cfg, 'req'));
expect(read!.property).toBe('body');
// the toString call site carries the full dotted path + receiver
const ts = sites.find((s) => s.callee === 'req.body.toString');
expect(ts).toBeDefined();
expect(ts!.receiver).toBe(bindingIdx(cfg, 'req'));
// and req's occurrence reaches exec's arg 0 via the toString site
const exec = sites.find((s) => s.callee === 'exec')!;
const tsIdx = sites.indexOf(ts!);
expect(exec.args).toEqual([[[bindingIdx(cfg, 'req'), tsIdx]]]);
});
it('write-position member targets record NO member read (obj.p = q)', () => {
const cfg = cfgOf(`function f(obj, q) { obj.p = q; }`);
expect(allSites(cfg)).toHaveLength(0);
});
it('a mid-chain LOAD inside a write target IS recorded (req.body.x = v)', () => {
const cfg = cfgOf(`function f(req, v) { req.body.x = v; }`);
expect(siteFact(cfg, 1).sites).toEqual([
{ kind: 'member-read', object: bindingIdx(cfg, 'req'), property: 'body' },
]);
});
});
describe('M3 U1 — taint-site harvest: templates, callbacks, statement granularity', () => {
it('template-literal argument: exec(`ls ${dir}`) → dir occurs at position 0, no template flag', () => {
const cfg = cfgOf('function f(dir) { exec(`ls ${dir}`); }');
const s = siteFact(cfg, 1).sites![0];
expect(s.template).toBeUndefined();
expect(s.args).toEqual([[bindingIdx(cfg, 'dir')]]);
});
it('tagged template: sql`…${id}` → call site with template marker, id recorded', () => {
const cfg = cfgOf('function f(id) { sql`select ${id}`; }');
const s = siteFact(cfg, 1).sites![0];
expect(s.kind).toBe('call');
expect(s.callee).toBe('sql');
expect(s.template).toBe(true);
expect(s.args).toEqual([[bindingIdx(cfg, 'id')]]);
});
it('nested callback: arr.forEach(() => exec(y)) → inner call invisible, outer site has receiver arr', () => {
const cfg = cfgOf(`function f(arr, y) { arr.forEach(() => exec(y)); }`);
const sites = siteFact(cfg, 1).sites!;
expect(sites).toHaveLength(1);
expect(sites[0].callee).toBe('arr.forEach');
expect(sites[0].receiver).toBe(bindingIdx(cfg, 'arr'));
// y is invisible (nested-function opacity) — neither a use nor an occurrence
expect(usesOf(cfg)).not.toContain(bindingIdx(cfg, 'y'));
expect(sites[0].args).toBeUndefined();
});
it('two statements on one line → distinct site records on distinct StatementFacts', () => {
const cfg = cfgOf(`function f(a, b) { exec(a); run(b); }`);
const withSites = allFacts(cfg).filter((f) => (f.sites?.length ?? 0) > 0);
expect(withSites).toHaveLength(2);
expect(withSites[0].sites![0].callee).toBe('exec');
expect(withSites[1].sites![0].callee).toBe('run');
// site indices are PER-STATEMENT — both are index 0 of their own record
expect(withSites[0].sites).toHaveLength(1);
expect(withSites[1].sites).toHaveLength(1);
});
it('sites are omitted entirely on statements without calls or member reads', () => {
const cfg = cfgOf(`function f() { let x = 1; x = 2; }`);
for (const fact of allFacts(cfg)) expect(fact.sites).toBeUndefined();
});
it('sites survive a JSON round-trip (worker boundary shape)', () => {
const cfg = cfgOf(`function f(req, x) { const b = req.body; exec(escape(x), b); }`);
const trip = JSON.parse(JSON.stringify(cfg)) as FunctionCfg;
expect(trip).toEqual(cfg);
expect(allSites(trip).length).toBeGreaterThan(0);
});
it('sequence expression: only the final operand flows into the sink argument', () => {
// `exec((log(x), 'safe'))` — the comma operator's value is the last operand
// (`'safe'`), so exec's arg 0 must NOT carry `x` (review fix). `x` is still
// a USE of the statement (the side-effect operand is evaluated).
const cfg = cfgOf(`function f(x) { exec((log(x), 'safe')); }`);
const execSite = allSites(cfg).find((s) => s.callee === 'exec')!;
expect(execSite.args ?? [[]]).toEqual([[]]); // arg 0 has no flowing binding
expect(siteFact(cfg, 1).uses).toContain(bindingIdx(cfg, 'x'));
});
it('sequence expression: a tainted final operand DOES flow into the sink', () => {
const cfg = cfgOf(`function f(x) { exec((log('a'), x)); }`);
const execSite = allSites(cfg).find((s) => s.callee === 'exec')!;
expect(execSite.args).toEqual([[bindingIdx(cfg, 'x')]]);
});
});

View file

@ -15,6 +15,7 @@
import { describe, it, expect } from 'vitest';
import { getStoragePaths, loadMeta, saveMeta } from '../../src/storage/repo-manager.js';
import { taintModelVersion } from '../../src/core/ingestion/taint/typescript-model.js';
import { setupMiniRepo as setupSharedMiniRepo } from '../helpers/mini-repo.js';
const setupMiniRepo = () => setupSharedMiniRepo('gitnexus-pdg-flip-');
@ -60,6 +61,49 @@ describe('pdgModeMismatch — M1→M2 stamp upgrade (#2082 M2, pure)', () => {
});
});
describe('pdgModeMismatch — M2→M3 stamp upgrade (#2083 M3 U5, pure)', () => {
it('an M2-era stamp (no taint keys) mismatches an M3 request — upgrade forces full writeback', async () => {
const { pdgModeMismatch } = await import('../../src/core/run-analyze.js');
// Exactly what an M2 run wrote: the three pre-taint resolved caps, no
// maxTaintFindingsPerFunction / maxTaintHops / taintModelVersion. The
// key-union comparator sees e.g. 200 !== undefined and trips the full
// writeback that populates TAINTED/SANITIZES rows without --force (R7).
const m2Stamp = {
maxFunctionLines: 2000,
maxEdgesPerFunction: 5000,
maxReachingDefEdgesPerFunction: 4000,
};
expect(pdgModeMismatch(m2Stamp, { pdg: true })).toBe(true);
});
it('an identical resolved M3 config compares equal (steady state keeps incremental)', async () => {
const { pdgModeMismatch, resolvePdgConfig } = await import('../../src/core/run-analyze.js');
const stamp = resolvePdgConfig({ pdg: true });
expect(pdgModeMismatch(stamp, { pdg: true })).toBe(false);
});
it('a taint 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, pdgMaxTaintFindingsPerFunction: 10 })).toBe(true);
expect(pdgModeMismatch(stamp, { pdg: true, pdgMaxTaintHops: 4 })).toBe(true);
expect(pdgModeMismatch(stamp, { pdg: true, pdgMaxTaintFindingsPerFunction: 200 })).toBe(
false, // explicit default ≡ default (resolution before comparison)
);
});
it('a model-version change ALONE trips the mismatch (the R7 repopulation guarantee)', async () => {
const { pdgModeMismatch, resolvePdgConfig } = await import('../../src/core/run-analyze.js');
// A stamp written by a hypothetical older binary whose built-in model
// differed — every cap identical, only the digest moved. Persisted
// findings must never outlive the model that produced them.
const stamp = resolvePdgConfig({ pdg: true });
const oldModelStamp = { ...stamp, taintModelVersion: '000000000000' };
expect(stamp?.taintModelVersion).not.toBe('000000000000'); // guard the premise
expect(pdgModeMismatch(oldModelStamp, { pdg: true })).toBe(true);
});
});
describe('detect_changes BasicBlock exclusion (#2082 U7)', () => {
it('the symbol-overlap id-prefix filter excludes exactly the BasicBlock rows', async () => {
const repo = await setupMiniRepo();
@ -135,6 +179,9 @@ describe('runFullAnalysis — pdg-mode flip (#2099 F1)', () => {
maxFunctionLines: 2000,
maxEdgesPerFunction: 5000,
maxReachingDefEdgesPerFunction: 4000,
maxTaintFindingsPerFunction: 200,
maxTaintHops: 32,
taintModelVersion,
});
expect(stamped!.incrementalInProgress).toBeUndefined(); // cleared on success
@ -184,6 +231,9 @@ describe('runFullAnalysis — pdg-mode flip (#2099 F1)', () => {
maxFunctionLines: 2000,
maxEdgesPerFunction: 1,
maxReachingDefEdgesPerFunction: 4000,
maxTaintFindingsPerFunction: 200,
maxTaintHops: 32,
taintModelVersion,
});
// The CFG layer survives a rebuild under a tighter edge cap (blocks are
// never capped, only edges).

View file

@ -8,6 +8,7 @@ import {
DEFAULT_EMBEDDING_NODE_LIMIT,
} from '../../src/core/embedding-mode.js';
import { getStoragePaths, saveMeta, type RepoMeta } from '../../src/storage/repo-manager.js';
import { taintModelVersion } from '../../src/core/ingestion/taint/typescript-model.js';
import { createTempDir } from '../helpers/test-db.js';
describe('run-analyze module', () => {
@ -330,13 +331,20 @@ describe('deriveEmbeddingCap', () => {
});
describe('pdgModeMismatch / resolvePdgConfig (#2099 F1)', () => {
// M2 (#2082) added the resolved REACHING_DEF cap to the stamp; these tests
// model M2 STEADY-STATE equality. The M1-era-stamp (field absent) upgrade
// path is pinned in pdg-mode-flip.test.ts.
// M2 (#2082) added the resolved REACHING_DEF cap to the stamp; M3 (#2083)
// added the two taint caps + the built-in model digest. These tests model
// M3 STEADY-STATE equality — this object is the DELIBERATE pin of the
// resolved-record shape, updated per milestone. The era-stamp (field
// absent) upgrade paths are pinned in pdg-mode-flip.test.ts.
const DEFAULTS = {
maxFunctionLines: 2000,
maxEdgesPerFunction: 5000,
maxReachingDefEdgesPerFunction: 4000,
maxTaintFindingsPerFunction: 200,
maxTaintHops: 32,
// Content digest, not a tunable cap — pinned via the exported constant
// (its VALUE changes whenever the built-in model changes, by design).
taintModelVersion,
};
it('resolvePdgConfig: pdg-off run resolves to undefined (the meta field is omitted)', async () => {
@ -354,8 +362,17 @@ describe('pdgModeMismatch / resolvePdgConfig (#2099 F1)', () => {
pdgMaxFunctionLines: 0,
pdgMaxEdgesPerFunction: 0,
pdgMaxReachingDefEdgesPerFunction: 0,
pdgMaxTaintFindingsPerFunction: 0,
pdgMaxTaintHops: 0,
}),
).toEqual({ maxFunctionLines: 0, maxEdgesPerFunction: 0, maxReachingDefEdgesPerFunction: 0 });
).toEqual({
maxFunctionLines: 0,
maxEdgesPerFunction: 0,
maxReachingDefEdgesPerFunction: 0,
maxTaintFindingsPerFunction: 0,
maxTaintHops: 0,
taintModelVersion, // not a cap — always stamped on a pdg-on run
});
});
it('legacy meta (no recorded stamp) + plain run → no mismatch', async () => {
@ -391,5 +408,11 @@ describe('pdgModeMismatch / resolvePdgConfig (#2099 F1)', () => {
expect(pdgModeMismatch(DEFAULTS, { pdg: true, pdgMaxFunctionLines: 500 })).toBe(true);
// 0 = unlimited differs from the 2000-line default, too.
expect(pdgModeMismatch(DEFAULTS, { pdg: true, pdgMaxFunctionLines: 0 })).toBe(true);
// The M3 taint caps participate identically (#2083).
expect(pdgModeMismatch(DEFAULTS, { pdg: true, pdgMaxTaintFindingsPerFunction: 1 })).toBe(true);
expect(pdgModeMismatch(DEFAULTS, { pdg: true, pdgMaxTaintHops: 1 })).toBe(true);
expect(pdgModeMismatch(DEFAULTS, { pdg: true, pdgMaxTaintFindingsPerFunction: 200 })).toBe(
false, // explicit default ≡ default
);
});
});

View file

@ -47,6 +47,15 @@ describe('VALID_RELATION_TYPES', () => {
expect(VALID_RELATION_TYPES.has('calls')).toBe(false); // case-sensitive
expect(VALID_RELATION_TYPES.has('DROP_TABLE')).toBe(false);
});
it('taint edge types stay OUT of the impact allow-list (#2083 M3 KTD9a)', () => {
// impact's BFS traverses symbol space; TAINTED/SANITIZES live in
// block-space (BasicBlock→BasicBlock) and would be unreachable noise
// there. The `explain` tool is the dedicated taint consumer. Pinned
// explicitly so a future "add all emitted types" sweep can't drag them in.
expect(VALID_RELATION_TYPES.has('TAINTED')).toBe(false);
expect(VALID_RELATION_TYPES.has('SANITIZES')).toBe(false);
});
});
// ─── Valid node labels ───────────────────────────────────────────────

View file

@ -0,0 +1,315 @@
/**
* U2 (#2083 M3) — built-in TS/JS taint model + import-aware site matcher.
*
* Fixtures parse REAL source: CFGs (and therefore SiteRecords) come from the
* worker-side TS CFG visitor (the harvest.test.ts harness pattern), and
* ParsedImports come from the real scope-capture + interpretTsImport path
* (the typescript-imports.test.ts harness pattern) — matches run against the
* exact structures U4 will feed the matcher, never hand-built mocks.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { cfgOf, importsFor } from '../../helpers/ts-cfg-harness.js';
import { hasTaintSafeSites } from '../../../src/core/ingestion/taint/site-safety.js';
import type { SourceSinkSanitizerSpec } from '../../../src/core/ingestion/taint/source-sink-config.js';
import {
TS_JS_TAINT_MODEL,
computeTaintModelVersion,
registerBuiltinTaintModels,
taintModelVersion,
} from '../../../src/core/ingestion/taint/typescript-model.js';
import {
buildTaintImportIndex,
matchFunctionSites,
type FunctionSiteMatches,
type MatchedSanitizerCall,
type MatchedSinkCall,
type MatchedSourceRead,
} from '../../../src/core/ingestion/taint/match.js';
import {
getSourceSinkConfig,
clearSourceSinkRegistry,
registeredTaintLanguages,
} from '../../../src/core/ingestion/taint/source-sink-registry.js';
/** Match function #`fnIndex` of `code` against the built-in model (or `spec`). */
function matchesOf(
code: string,
fnIndex = 0,
spec: SourceSinkSanitizerSpec = TS_JS_TAINT_MODEL,
): FunctionSiteMatches {
const cfg = cfgOf(code, fnIndex);
// The matcher's documented precondition — real harvests must always pass.
expect(hasTaintSafeSites(cfg)).toBe(true);
return matchFunctionSites(cfg, spec, buildTaintImportIndex(importsFor(code)));
}
const allSinks = (m: FunctionSiteMatches): MatchedSinkCall[] =>
m.statements.flatMap((s) => [...s.sinks]);
const allSources = (m: FunctionSiteMatches): MatchedSourceRead[] =>
m.statements.flatMap((s) => [...s.sources]);
const allSanitizers = (m: FunctionSiteMatches): MatchedSanitizerCall[] =>
m.statements.flatMap((s) => [...s.sanitizers]);
describe('sink resolution — ESM import joins', () => {
it('named import: `import { exec } from "child_process"; exec(c)` matches command-injection', () => {
const m = matchesOf(`import { exec } from 'child_process';
function f(c) { exec(c); }`);
const sinks = allSinks(m);
expect(sinks).toHaveLength(1);
expect(sinks[0].entry.kind).toBe('command-injection');
expect(sinks[0].entry.name).toBe('exec');
expect([...sinks[0].argPositions]).toEqual([0]);
expect(m.hasSink).toBe(true);
});
it('alias import: `import { exec as run }` — run(c) resolves to child_process.exec', () => {
const m = matchesOf(`import { exec as run } from 'child_process';
function f(c) { run(c); }`);
const sinks = allSinks(m);
expect(sinks).toHaveLength(1);
expect(sinks[0].entry.name).toBe('exec');
});
it('namespace import: `import * as cp` — cp.exec(c) resolves via the receiver binding', () => {
const m = matchesOf(`import * as cp from 'child_process';
function f(c) { cp.exec(c); }`);
expect(allSinks(m).map((s) => s.entry.name)).toEqual(['exec']);
});
it('node: scheme prefix is normalized — `from "node:child_process"` matches too', () => {
const m = matchesOf(`import { execSync } from 'node:child_process';
function f(c) { execSync(c); }`);
expect(allSinks(m).map((s) => s.entry.name)).toEqual(['execSync']);
});
it('an in-FUNCTION local `exec` shadows the import — no match', () => {
const m = matchesOf(`import { exec } from 'child_process';
function f(c) { function exec(x) { return x; } exec(c); }`);
expect(allSinks(m)).toHaveLength(0);
expect(m.hasSink).toBe(false);
});
it('a module-level local `exec` (no import) does NOT match — synthetic binding, no import entry', () => {
const m = matchesOf(
`function exec(x) { return x; }
function g(c) { exec(c); }`,
1, // g — index 0 is the local exec itself
);
expect(allSinks(m)).toHaveLength(0);
});
});
describe('sink resolution — globals and require joins', () => {
it('eval(x) matches code-injection via the synthetic-global fallback', () => {
const m = matchesOf(`function f(x) { eval(x); }`);
const sinks = allSinks(m);
expect(sinks).toHaveLength(1);
expect(sinks[0].entry.kind).toBe('code-injection');
});
it('`new Function(x)` matches; a bare `Function(x)` CALL does not (newOnly)', () => {
const withNew = matchesOf(`function f(x) { const fn = new Function(x); }`);
expect(allSinks(withNew).map((s) => s.entry.name)).toEqual(['Function']);
const bareCall = matchesOf(`function f(x) { const fn = Function(x); }`);
expect(allSinks(bareCall)).toHaveLength(0);
});
it('require literal join: `const cp = require("child_process"); cp.exec(c)` matches', () => {
const m = matchesOf(`function f(c) { const cp = require('child_process'); cp.exec(c); }`);
const sinks = allSinks(m);
expect(sinks).toHaveLength(1);
expect(sinks[0].entry.name).toBe('exec');
expect(sinks[0].entry.kind).toBe('command-injection');
});
it('a require’d local utility named exec does NOT match (no bare-name fallback for non-globals)', () => {
const m = matchesOf(`function f(c) { const exec = require('./my-utils'); exec(c); }`);
expect(allSinks(m)).toHaveLength(0);
});
it('non-renamed destructured require resolves via the dual interpretation', () => {
const m = matchesOf(`function f(c) { const { exec } = require('child_process'); exec(c); }`);
expect(allSinks(m).map((s) => s.entry.name)).toEqual(['exec']);
});
});
describe('sources — conventional member reads', () => {
it('req.body matches remote-input; request.body matches; myObj.body does not', () => {
const m = matchesOf(`function f(req, request, myObj) {
const a = req.body;
const b = request.body;
const c = myObj.body;
}`);
const sources = allSources(m);
expect(sources).toHaveLength(2);
expect(sources.every((s) => s.entry.kind === 'remote-input')).toBe(true);
expect(m.hasSource).toBe(true);
});
it('all five conventional properties match; an unlisted property does not', () => {
const m = matchesOf(`function f(req) {
const a = req.body, b = req.query, c = req.params, d = req.headers, e = req.cookies;
const z = req.socket;
}`);
expect(allSources(m)).toHaveLength(5);
});
});
describe('sink argument-position discipline', () => {
it('exec(safe, tainted): only the registered position 0 is a sink position', () => {
const m = matchesOf(`import { exec } from 'child_process';
function f(safe, tainted) { exec(safe, tainted); }`);
const [sink] = allSinks(m);
expect([...sink.argPositions]).toEqual([0]); // position 1 carries an occurrence but is not registered
});
it('spread: exec(...args) matches — recorded position ≥ spread index degrades soundly', () => {
const m = matchesOf(`import { exec } from 'child_process';
function f(args) { exec(...args); }`);
const [sink] = allSinks(m);
expect([...sink.argPositions]).toEqual([0]);
});
it('spread precision: a pre-spread position stays exact; post-spread matches any q ≥ spread', () => {
// Custom spec: sink position 1 only. `s2(a, ...rest)` — recorded 0 is
// BEFORE the spread (exact: no match); recorded 1 is the spread (match).
const spec: SourceSinkSanitizerSpec = {
sources: [],
sinks: [{ name: 's2', kind: 'command-injection', args: [1], global: true }],
sanitizers: [],
};
const m = matchesOf(`function f(a, rest) { s2(a, ...rest); }`, 0, spec);
const [sink] = allSinks(m);
expect([...sink.argPositions]).toEqual([1]);
});
it('tagged template: substitutions aggregate at position 0 and match any registered position', () => {
const spec: SourceSinkSanitizerSpec = {
sources: [],
sinks: [{ name: 'sql', kind: 'sql-injection', args: [1], global: true }],
sanitizers: [],
};
const m = matchesOf(`function f(id) { sql\`select \${id}\`; }`, 0, spec);
const [sink] = allSinks(m);
expect([...sink.argPositions]).toEqual([0]);
});
it('a sink whose dangerous positions carry no occurrences is not reported', () => {
const m = matchesOf(`import { exec } from 'child_process';
function f(t) { exec('ls -la', t); }`);
expect(allSinks(m)).toHaveLength(0);
});
});
describe('receiver-conventional sinks', () => {
it('res.send / res.write match xss; out.send does not', () => {
const m = matchesOf(`function f(res, out, x) { res.send(x); res.write(x); out.send(x); }`);
expect(allSinks(m).map((s) => s.entry.name)).toEqual(['send', 'write']);
expect(allSinks(m).every((s) => s.entry.kind === 'xss')).toBe(true);
});
it('.query/.execute match sql-injection on ANY receiver', () => {
const m = matchesOf(`function f(db, pool, x) { db.query(x); pool.execute(x); }`);
expect(allSinks(m).map((s) => s.entry.kind)).toEqual(['sql-injection', 'sql-injection']);
});
});
describe('sanitizers — import-aware only, kind-scoped', () => {
it('path.basename matches and neutralizes path-traversal, NOT command-injection', () => {
const m = matchesOf(`import path from 'path';
function f(p) { const safe = path.basename(p); }`);
const sans = allSanitizers(m);
expect(sans).toHaveLength(1);
expect(sans[0].entry.neutralizes).toContain('path-traversal');
expect(sans[0].entry.neutralizes).not.toContain('command-injection');
// resultDefs carries the kill target (KTD4b)
expect(sans[0].resultDefs).toHaveLength(1);
});
it('validator.escape via named import matches xss', () => {
const m = matchesOf(`import { escape } from 'validator';
function f(x) { const safe = escape(x); }`);
const sans = allSanitizers(m);
expect(sans).toHaveLength(1);
expect([...sans[0].entry.neutralizes]).toEqual(['xss']);
});
it('default-imported escape-html matches via the default pseudo-name', () => {
const m = matchesOf(`import escapeHtml from 'escape-html';
function f(x) { const safe = escapeHtml(x); }`);
expect(allSanitizers(m)).toHaveLength(1);
});
it('encodeURIComponent matches as a true global (xss + path-traversal)', () => {
const m = matchesOf(`function f(x) { const safe = encodeURIComponent(x); }`);
const sans = allSanitizers(m);
expect(sans).toHaveLength(1);
expect([...sans[0].entry.neutralizes]).toEqual(['xss', 'path-traversal']);
});
it('a user-defined in-file `escape` is NEVER a sanitizer (no bare-name resolution)', () => {
const m = matchesOf(
`function escape(s) { return s; }
function f(x) { const safe = escape(x); }`,
1, // f
);
expect(allSanitizers(m)).toHaveLength(0);
});
it('value-position sanitizer (exec(escape(x))) matches with EMPTY resultDefs — interposition substrate', () => {
const m = matchesOf(`import { exec } from 'child_process';
import { escape } from 'validator';
function f(x) { exec(escape(x)); }`);
const sans = allSanitizers(m);
expect(sans).toHaveLength(1);
expect(sans[0].resultDefs).toHaveLength(0);
// The sink still matches — interposition is U3's call, not the matcher's.
expect(allSinks(m)).toHaveLength(1);
});
});
describe('registry + model identity', () => {
beforeEach(() => clearSourceSinkRegistry());
it('registerBuiltinTaintModels registers typescript AND javascript (idempotent); others stay undefined', () => {
registerBuiltinTaintModels();
registerBuiltinTaintModels(); // idempotent — last-write-wins on the same ids
expect(registeredTaintLanguages().sort()).toEqual(['javascript', 'typescript']);
expect(getSourceSinkConfig('typescript')).toBe(TS_JS_TAINT_MODEL);
expect(getSourceSinkConfig('javascript')).toBe(TS_JS_TAINT_MODEL);
expect(getSourceSinkConfig('python')).toBeUndefined();
});
it('taintModelVersion is the digest of the full built-in model', () => {
expect(taintModelVersion).toBe(computeTaintModelVersion(TS_JS_TAINT_MODEL));
expect(taintModelVersion).toMatch(/^[0-9a-f]{12}$/);
});
it('adding an entry changes the version', () => {
const added: SourceSinkSanitizerSpec = {
...TS_JS_TAINT_MODEL,
sinks: [...TS_JS_TAINT_MODEL.sinks, { name: 'load', kind: 'code-injection', module: 'vm' }],
};
expect(computeTaintModelVersion(added)).not.toBe(taintModelVersion);
});
it('changing only a kind label changes the version', () => {
const relabeled: SourceSinkSanitizerSpec = {
...TS_JS_TAINT_MODEL,
sinks: TS_JS_TAINT_MODEL.sinks.map((s) =>
s.name === 'exec' ? { ...s, kind: 'xss' as const } : s,
),
};
expect(computeTaintModelVersion(relabeled)).not.toBe(taintModelVersion);
});
it('the version is content-derived: key order does not matter, entry order does', () => {
const reordered: SourceSinkSanitizerSpec = {
sanitizers: TS_JS_TAINT_MODEL.sanitizers,
sinks: TS_JS_TAINT_MODEL.sinks,
sources: TS_JS_TAINT_MODEL.sources,
};
expect(computeTaintModelVersion(reordered)).toBe(taintModelVersion);
});
});

View file

@ -0,0 +1,305 @@
/**
* U4 (#2083 M3) — the shared taint-path reason codec (plan KTD6).
*
* The wire format must round-trip BYTE-EXACT through the CSV persistence
* layer (`escapeCSVField ∘ sanitizeUTF8`, csv-generator.ts) — that
* composition is exercised here verbatim, including the un-escape a DB load
* performs. Truncation (hop cap, byte cap, unencodable hop) must decode as
* "path incomplete", never as an error; malformed input must produce a typed
* failure, never a throw.
*/
import { describe, it, expect } from 'vitest';
import {
TAINT_PATH_CODEC_VERSION,
TAINT_REASON_MAX_BYTES,
TAINT_PATH_TRUNCATION_MARKER,
encodeTaintPath,
decodeTaintPath,
type TaintPathHopInput,
} from '../../../src/core/ingestion/taint/path-codec.js';
import { escapeCSVField, sanitizeUTF8 } from '../../../src/core/lbug/csv-generator.js';
/** Inverse of escapeCSVField — what a CSV/DB load applies to the stored cell. */
function unescapeCSVField(cell: string): string {
expect(cell.startsWith('"') && cell.endsWith('"')).toBe(true);
return cell.slice(1, -1).replace(/""/g, '"');
}
const roundTrip = (hops: readonly TaintPathHopInput[]) => {
const { reason } = encodeTaintPath(hops);
const decoded = decodeTaintPath(reason);
if (!decoded.ok) throw new Error(`decode failed: ${decoded.error}`);
return { reason, decoded };
};
describe('encodeTaintPath / decodeTaintPath round trip', () => {
it('round-trips an ordered multi-hop path with variables, lines, and viaCall', () => {
const hops: TaintPathHopInput[] = [
{ name: 'req', line: 3 },
{ name: 'cmd', line: 4, viaCall: true },
{ name: 'cmd', line: 7 },
];
const { reason, decoded } = roundTrip(hops);
expect(reason).toBe('1|req:3|cmd:4:c|cmd:7');
expect(decoded.version).toBe(TAINT_PATH_CODEC_VERSION);
expect(decoded.truncated).toBe(false);
expect(decoded.hops).toEqual([
{ variable: 'req', line: 3, viaCall: false },
{ variable: 'cmd', line: 4, viaCall: true },
{ variable: 'cmd', line: 7, viaCall: false },
]);
});
it('round-trips the empty path (version prefix only)', () => {
const { reason, decoded } = roundTrip([]);
expect(reason).toBe('1');
expect(decoded.hops).toEqual([]);
expect(decoded.truncated).toBe(false);
});
it('round-trips identifier-charset names: $, _, #, digits, case', () => {
const names = ['$jq', '_private', '#3', 'CONST_99', 'aB$_#z', 'x'];
const hops = names.map((name, i) => ({ name, line: i + 1, viaCall: i % 2 === 0 }));
const { decoded } = roundTrip(hops);
expect(decoded.hops.map((h) => h.variable)).toEqual(names);
});
it('fuzz-ish sweep: random identifier-charset names of varied length survive', () => {
const CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_$#';
// Deterministic LCG so a failure is reproducible.
let seed = 0xc0ffee;
const next = (): number => {
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
return seed;
};
for (let trial = 0; trial < 200; trial++) {
const hops: TaintPathHopInput[] = [];
const count = (next() % 8) + 1;
for (let i = 0; i < count; i++) {
const len = (next() % 24) + 1;
let name = '';
for (let j = 0; j < len; j++) name += CHARSET[next() % CHARSET.length];
hops.push({ name, line: next() % 100000, viaCall: next() % 2 === 0 });
}
const { reason, decoded } = roundTrip(hops);
expect(decoded.truncated).toBe(false);
expect(decoded.hops).toEqual(
hops.map((h) => ({ variable: h.name, line: h.line, viaCall: h.viaCall === true })),
);
// The whole wire string is printable ASCII (the CSV-survival invariant).
expect(/^[\x20-\x7e]+$/.test(reason)).toBe(true);
}
});
});
describe('kind header (;<kind> — U6, the only persisted channel for sinkKind)', () => {
it('round-trips a kind header with hops', () => {
const { reason } = encodeTaintPath(
[
{ name: 'req', line: 3 },
{ name: 'cmd', line: 4 },
],
{ kind: 'command-injection' },
);
expect(reason).toBe('1;command-injection|req:3|cmd:4');
const decoded = decodeTaintPath(reason);
expect(decoded.ok).toBe(true);
if (decoded.ok) {
expect(decoded.kind).toBe('command-injection');
expect(decoded.hops.map((h) => h.variable)).toEqual(['req', 'cmd']);
}
});
it('round-trips a kind header on the hop-less path', () => {
const { reason } = encodeTaintPath([], { kind: 'xss' });
expect(reason).toBe('1;xss');
const decoded = decodeTaintPath(reason);
expect(decoded.ok).toBe(true);
if (decoded.ok) {
expect(decoded.kind).toBe('xss');
expect(decoded.hops).toEqual([]);
}
});
it('kind + truncation marker coexist; the header is never sacrificed to the byte cap', () => {
const { reason, truncated } = encodeTaintPath([{ name: 'longVariableName', line: 12345 }], {
kind: 'sql-injection',
maxBytes: 8, // far below the header size — floor lifts it, hops drop
});
expect(truncated).toBe(true);
expect(reason).toBe(`1;sql-injection|${TAINT_PATH_TRUNCATION_MARKER}`);
const decoded = decodeTaintPath(reason);
expect(decoded.ok).toBe(true);
if (decoded.ok) {
expect(decoded.kind).toBe('sql-injection');
expect(decoded.truncated).toBe(true);
expect(decoded.hops).toEqual([]);
}
});
it('a kind outside [a-z0-9-] is dropped (header omitted), never corrupted into the wire', () => {
for (const bad of ['Command-Injection', 'a;b', 'k|x', 'café', '']) {
const { reason } = encodeTaintPath([{ name: 'x', line: 1 }], { kind: bad });
expect(reason).toBe('1|x:1');
const decoded = decodeTaintPath(reason);
expect(decoded.ok).toBe(true);
if (decoded.ok) expect(decoded.kind).toBeUndefined();
}
});
it('a header-less version-1 string still decodes (kind undefined)', () => {
const decoded = decodeTaintPath('1|a:1');
expect(decoded.ok).toBe(true);
if (decoded.ok) expect(decoded.kind).toBeUndefined();
});
it('the kind header survives the CSV persistence transform byte-exact', () => {
const { reason } = encodeTaintPath([{ name: 'req', line: 2 }], { kind: 'path-traversal' });
const loaded = unescapeCSVField(escapeCSVField(sanitizeUTF8(reason)));
expect(loaded).toBe(reason);
const decoded = decodeTaintPath(loaded);
expect(decoded.ok).toBe(true);
if (decoded.ok) expect(decoded.kind).toBe('path-traversal');
});
it('fails typed on a malformed kind header', () => {
for (const bad of ['1;|a:1', '1;BAD|a:1', '1;', '1;a;b|x:1']) {
const decoded = decodeTaintPath(bad);
expect(decoded.ok, bad).toBe(false);
}
});
});
describe('CSV persistence composition (escapeCSVField ∘ sanitizeUTF8)', () => {
it('the encoding survives the exact persistence transform byte-exact', () => {
const hops: TaintPathHopInput[] = [
{ name: 'req', line: 12 },
{ name: '$tmp_2', line: 13, viaCall: true },
{ name: '#7', line: 99 },
];
const { reason } = encodeTaintPath(hops);
// csv-generator applies sanitizeUTF8 INSIDE escapeCSVField; compose both
// explicitly anyway so the test pins each layer.
const stored = escapeCSVField(sanitizeUTF8(reason));
const loaded = unescapeCSVField(stored);
expect(loaded).toBe(reason); // byte-exact
const decoded = decodeTaintPath(loaded);
expect(decoded.ok).toBe(true);
});
it('a truncated encoding also survives the persistence transform', () => {
const { reason, truncated } = encodeTaintPath([{ name: 'x', line: 1 }], { truncated: true });
expect(truncated).toBe(true);
const loaded = unescapeCSVField(escapeCSVField(sanitizeUTF8(reason)));
expect(loaded).toBe(reason);
});
});
describe('truncation', () => {
it('caller-flagged truncation (hop cap upstream) emits the marker; decode reports path-incomplete', () => {
const { reason, truncated } = encodeTaintPath([{ name: 'a', line: 1 }], { truncated: true });
expect(truncated).toBe(true);
expect(reason).toBe(`1|a:1|${TAINT_PATH_TRUNCATION_MARKER}`);
const decoded = decodeTaintPath(reason);
expect(decoded.ok).toBe(true);
if (decoded.ok) {
expect(decoded.truncated).toBe(true); // informational — NOT an error
expect(decoded.hops).toEqual([{ variable: 'a', line: 1, viaCall: false }]);
}
});
it('byte-cap overflow drops TRAILING hops (source-side prefix kept) and sets the marker', () => {
const hops: TaintPathHopInput[] = [];
for (let i = 0; i < 1000; i++) hops.push({ name: `variable_${i}`, line: i });
const { reason, truncated } = encodeTaintPath(hops);
expect(truncated).toBe(true);
expect(reason.length).toBeLessThanOrEqual(TAINT_REASON_MAX_BYTES);
const decoded = decodeTaintPath(reason);
expect(decoded.ok).toBe(true);
if (decoded.ok) {
expect(decoded.truncated).toBe(true);
expect(decoded.hops.length).toBeGreaterThan(0);
expect(decoded.hops.length).toBeLessThan(hops.length);
// Prefix discipline: hop k decodes hop k of the input, in order.
decoded.hops.forEach((h, i) => {
expect(h.variable).toBe(`variable_${i}`);
expect(h.line).toBe(i);
});
}
});
it('a tiny maxBytes still yields a well-formed (possibly hop-less) truncated path', () => {
const { reason, truncated } = encodeTaintPath([{ name: 'longVariableName', line: 123 }], {
maxBytes: 8,
});
expect(truncated).toBe(true);
expect(reason).toBe(`1|${TAINT_PATH_TRUNCATION_MARKER}`);
const decoded = decodeTaintPath(reason);
expect(decoded.ok).toBe(true);
if (decoded.ok) {
expect(decoded.hops).toEqual([]);
expect(decoded.truncated).toBe(true);
}
});
it('an unencodable hop name stops encoding at that hop and marks truncation (defend, never corrupt)', () => {
const cases = ['a|b', 'a:b', 'café', 'name~x', '', 'a b'];
for (const bad of cases) {
const { reason, truncated } = encodeTaintPath([
{ name: 'ok1', line: 1 },
{ name: bad, line: 2 },
{ name: 'ok2', line: 3 }, // dropped too — prefix discipline
]);
expect(truncated).toBe(true);
const decoded = decodeTaintPath(reason);
expect(decoded.ok).toBe(true);
if (decoded.ok) {
expect(decoded.truncated).toBe(true);
expect(decoded.hops).toEqual([{ variable: 'ok1', line: 1, viaCall: false }]);
}
}
});
it('a non-integer or negative line is unencodable the same way', () => {
for (const line of [1.5, -1, NaN, Infinity]) {
const { truncated, reason } = encodeTaintPath([{ name: 'x', line }]);
expect(truncated).toBe(true);
const decoded = decodeTaintPath(reason);
expect(decoded.ok).toBe(true);
if (decoded.ok) expect(decoded.hops).toEqual([]);
}
});
});
describe('typed parse failures (never a throw)', () => {
const failing: Array<[string, unknown]> = [
['empty string', ''],
['non-string', 42],
['undefined', undefined],
['unknown version', '2|a:1'],
['missing separator after version', '1a:1'],
['hop with no line', '1|a'],
['hop with too many fields', '1|a:1:c:d'],
['non-numeric line', '1|a:x'],
['negative line', '1|a:-1'],
['invalid variable charset', '1|a b:1'],
['empty variable', '1|:1'],
['uppercase flag (reserved charset is lowercase)', '1|a:1:C'],
['marker not trailing', `1|${TAINT_PATH_TRUNCATION_MARKER}|a:1`],
['empty hop segment', '1|a:1||b:2'],
];
for (const [label, input] of failing) {
it(`fails typed on ${label}`, () => {
const decoded = decodeTaintPath(input);
expect(decoded.ok).toBe(false);
if (!decoded.ok) expect(decoded.error.length).toBeGreaterThan(0);
});
}
it('accepts unknown RESERVED lowercase flag letters (forward compatibility)', () => {
const decoded = decodeTaintPath('1|a:1:cz');
expect(decoded.ok).toBe(true);
if (decoded.ok) expect(decoded.hops[0]).toEqual({ variable: 'a', line: 1, viaCall: true });
});
});

View file

@ -0,0 +1,686 @@
/**
* U3 (#2083 M3) — pure taint propagation engine.
*
* Fixtures parse REAL source through the harvest + match harness (the
* model-match.test.ts pattern): CFGs and SiteRecords come from the worker-side
* TS CFG visitor, def→use facts from the real reaching-defs solver, and
* matches from the real import-aware matcher — `computeTaintFlows` consumes
* the exact structures U4 will feed it, never hand-built mocks.
*
* Sanitizer semantics under test (the KIND-SET exclusion model, which
* subsumes the plan's binary kill — see propagate.ts module doc): a def
* produced through a sanitizer is tainted-with-exclusions; a sink fires
* unless its kind is in the taint's accumulated neutralized set. Mechanics
* tests therefore use a custom spec whose `escape` neutralizes the test
* sink's own kind (so "killed" scenarios read like the plan's binary
* scenarios); the kind-set tests at the bottom exercise the real built-in
* model where neutralization is deliberately kind-scoped.
*/
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,
type FunctionDefUse,
type ReachingDefsLimits,
} from '../../../src/core/ingestion/cfg/reaching-defs.js';
import { hasTaintSafeSites } from '../../../src/core/ingestion/taint/site-safety.js';
import type { SourceSinkSanitizerSpec } from '../../../src/core/ingestion/taint/source-sink-config.js';
import { TS_JS_TAINT_MODEL } from '../../../src/core/ingestion/taint/typescript-model.js';
import {
buildTaintImportIndex,
matchFunctionSites,
} from '../../../src/core/ingestion/taint/match.js';
import {
computeTaintFlows,
type FunctionTaintResult,
type TaintLimits,
} from '../../../src/core/ingestion/taint/propagate.js';
/** The single binding index for `name` (throws when shadowed/ambiguous). */
function bindingIdx(cfg: FunctionCfg, name: string): number {
const idxs = (cfg.bindings ?? []).map((b, i) => (b.name === name ? i : -1)).filter((i) => i >= 0);
if (idxs.length !== 1) throw new Error(`expected 1 binding for ${name}, got ${idxs.length}`);
return idxs[0];
}
/**
* Mechanics spec: a global `exec` sink and a global `escape` sanitizer that
* neutralizes the SAME kind — so interposition/kill mechanics behave like the
* plan's binary-kill scenarios while still flowing through the kind-set model.
*/
const MECH: SourceSinkSanitizerSpec = {
sources: [
{
kind: 'remote-input',
objects: ['req'],
properties: ['body', 'query', 'params', 'headers'],
},
],
sinks: [{ name: 'exec', kind: 'command-injection', args: [0], global: true }],
sanitizers: [{ name: 'escape', neutralizes: ['command-injection'], global: true }],
};
interface AnalyzeOptions {
fnIndex?: number;
spec?: SourceSinkSanitizerSpec;
limits?: TaintLimits;
factLimits?: ReachingDefsLimits;
}
function analyze(code: string, opts: AnalyzeOptions = {}): FunctionTaintResult {
const cfg = cfgOf(code, opts.fnIndex ?? 0);
expect(hasTaintSafeSites(cfg)).toBe(true);
const defUse = computeReachingDefs(cfg, opts.factLimits);
const matches = matchFunctionSites(
cfg,
opts.spec ?? MECH,
buildTaintImportIndex(importsFor(code)),
);
return computeTaintFlows(cfg, defUse, matches, opts.limits);
}
/** Hop summaries `name@line` (`name@line*` when viaCall) for readable asserts. */
function hopSummary(r: FunctionTaintResult, findingIdx = 0): string[] {
const f = r.findings[findingIdx];
if (!f) throw new Error(`no finding at index ${findingIdx}`);
return f.hops.map((h) => `${h.name}@${h.point.line}${h.viaCall ? '*' : ''}`);
}
// ── statuses and the empty case ──────────────────────────────────────────────
describe('coverage-gap statuses (R4)', () => {
it('a truncated FunctionDefUse yields a coverage-gap result with zero findings', () => {
const r = analyze(
`function f(req) {
const b = req.body;
const c = b;
exec(c);
}`,
{ factLimits: { maxFacts: 1 } },
);
expect(r.status).toBe('coverage-gap');
expect(r.gapReason).toBe('truncated');
expect(r.findings).toHaveLength(0);
expect(r.kills).toHaveLength(0);
});
it('a no-facts FunctionDefUse (no binding table) yields a coverage-gap result', () => {
const cfg = cfgOf(`function f(req) { exec(req.body); }`);
const { bindings: _bindings, ...noBindings } = cfg;
const defUse = computeReachingDefs(noBindings as FunctionCfg);
expect(defUse.status).toBe('no-facts');
const matches = matchFunctionSites(cfg, MECH, buildTaintImportIndex([]));
const r = computeTaintFlows(cfg, defUse, matches);
expect(r.status).toBe('coverage-gap');
expect(r.gapReason).toBe('no-facts');
expect(r.findings).toHaveLength(0);
});
it('an overflow FunctionDefUse yields a coverage-gap result (contract input shape)', () => {
const cfg = cfgOf(`function f(req) { exec(req.body); }`);
const overflow: FunctionDefUse = {
status: 'overflow',
bindings: cfg.bindings ?? [],
facts: [],
defCount: 0,
useCount: 0,
};
const matches = matchFunctionSites(cfg, MECH, buildTaintImportIndex([]));
const r = computeTaintFlows(cfg, overflow, matches);
expect(r.status).toBe('coverage-gap');
expect(r.gapReason).toBe('overflow');
});
it('no sources and no sinks → computed, empty result', () => {
const r = analyze(`function f(x) { const y = x; return y; }`);
expect(r.status).toBe('computed');
expect(r.findings).toHaveLength(0);
expect(r.kills).toHaveLength(0);
expect(r.droppedFindings).toBe(0);
});
});
// ── rule (b): statement-local source→sink ────────────────────────────────────
describe('rule (b) — statement-local findings', () => {
it('exec(req.body) → one finding with a single hop', () => {
const r = analyze(`function f(req) {
exec(req.body);
}`);
expect(r.status).toBe('computed');
expect(r.findings).toHaveLength(1);
const f = r.findings[0];
expect(f.sinkKind).toBe('command-injection');
expect(f.source.property).toBe('body');
expect(f.source.point.line).toBe(2);
expect(f.sink.point.line).toBe(2);
expect(f.sink.argIndex).toBe(0);
expect(f.hops).toHaveLength(1);
expect(f.hops[0].name).toBe('req');
});
it('exec(req.body, req.query) → TWO findings distinguished by occurrence (KTD6 identity)', () => {
const spec: SourceSinkSanitizerSpec = {
...MECH,
sinks: [{ name: 'exec', kind: 'command-injection', args: [0, 1], global: true }],
};
const r = analyze(`function f(req) { exec(req.body, req.query); }`, { spec });
expect(r.findings).toHaveLength(2);
const ids = r.findings.map((f) => `${f.source.property}@arg${f.sink.argIndex}`);
expect(ids).toEqual(['body@arg0', 'query@arg1']);
});
it('exec(req.body.toString()) → finding via the statement-local rule', () => {
const r = analyze(`function f(req) { exec(req.body.toString()); }`);
expect(r.findings).toHaveLength(1);
expect(r.findings[0].source.property).toBe('body');
});
it('a source read at an UNREGISTERED sink position produces no finding', () => {
const r = analyze(`function f(req) { exec('ls', req.body); }`);
expect(r.findings).toHaveLength(0);
});
});
// ── rule (a): happy path and chains ──────────────────────────────────────────
describe('rule (a) — worklist over def→use facts', () => {
it('same-block flow: const b = req.body; exec(b) → finding with hop chain', () => {
const r = analyze(`function f(req) {
const b = req.body;
exec(b);
}`);
expect(r.findings).toHaveLength(1);
expect(hopSummary(r)).toEqual(['b@2', 'b@3']);
expect(r.findings[0].source.property).toBe('body');
expect(r.findings[0].sink.point.line).toBe(3);
});
it('reassignment chain carries variables per hop: b@L2 → c@L3 → sink@L4', () => {
const r = analyze(`function f(req) {
const b = req.body;
const c = b;
exec(c);
}`);
expect(r.findings).toHaveLength(1);
expect(hopSummary(r)).toEqual(['b@2', 'c@3', 'c@4']);
});
it('cross-block flow through a branch reaches the sink', () => {
const r = analyze(`function f(req, cond) {
const b = req.body;
let c = '';
if (cond) {
c = b;
}
exec(c);
}`);
expect(r.findings).toHaveLength(1);
expect(hopSummary(r)).toEqual(['b@2', 'c@5', 'c@7']);
});
});
// ── sanitizer interposition and kills (KTD4 both clauses) ───────────────────
describe('sanitizers — interposition, kill locality, kind sets (mechanics spec)', () => {
it('seed interposition: const b = escape(req.body) → no finding, SANITIZES kill on b', () => {
const r = analyze(`function f(req) {
const b = escape(req.body);
exec(b);
}`);
expect(r.findings).toHaveLength(0);
expect(r.kills).toHaveLength(1);
const cfg = cfgOf(`function f(req) {
const b = escape(req.body);
exec(b);
}`);
expect(r.kills[0].bindingIdx).toBe(bindingIdx(cfg, 'b'));
expect(r.kills[0].sanitizer.line).toBe(2);
expect([...r.kills[0].neutralized]).toEqual(['command-injection']);
});
it('sink interposition: exec(escape(x)) with x tainted → no finding, kill recorded', () => {
const r = analyze(`function f(req) {
const x = req.body;
exec(escape(x));
}`);
expect(r.findings).toHaveLength(0);
expect(r.kills).toHaveLength(1);
expect(r.kills[0].sanitizer.line).toBe(3);
expect([...r.kills[0].neutralized]).toEqual(['command-injection']);
});
it('bypass occurrence: const c = cond ? escape(x) : x → finding (direct path bypasses)', () => {
// The ternary's escape call deliberately gets NO resultDefs (U1) — c is
// floor-tainted with the EMPTY exclusion set (intersection over paths:
// the direct `x` arm contributes ∅, so ∅ ∩ {command-injection} = ∅).
const r = analyze(`function f(req, cond) {
const x = req.body;
const c = cond ? escape(x) : x;
exec(c);
}`);
expect(r.findings).toHaveLength(1);
expect(r.kills).toHaveLength(0);
});
it('intra-statement bypass at the sink: exec(x + escape(x)) → finding (plain occurrence wins)', () => {
const r = analyze(`function f(req) {
const x = req.body;
exec(x + escape(x));
}`);
expect(r.findings).toHaveLength(1);
// a BYPASSED sanitizer killed nothing — no SANITIZES record
expect(r.kills).toHaveLength(0);
});
it('kill locality (KTD4b): const c = escape(b); exec(b) → finding on b AND a kill on c', () => {
const code = `function f(req) {
const b = req.body;
const c = escape(b);
exec(b);
}`;
const r = analyze(code);
expect(r.findings).toHaveLength(1);
expect(hopSummary(r)).toEqual(['b@2', 'b@4']);
expect(r.kills).toHaveLength(1);
expect(r.kills[0].bindingIdx).toBe(bindingIdx(cfgOf(code), 'c'));
});
it('sanitizer self-assign: b = escape(b); exec(b) → no finding, one kill', () => {
const r = analyze(`function f(req) {
let b = req.body;
b = escape(b);
exec(b);
}`);
expect(r.findings).toHaveLength(0);
expect(r.kills).toHaveLength(1);
expect(r.kills[0].sanitizer.line).toBe(3);
});
it('a sanitizer reached only through a SPREAD argument does not neutralize (position unprovable)', () => {
// `escape(...arr)` — the runtime argument positions are unknowable, so
// claiming the sanitized position received the taint would risk a false
// kill. Sound direction: taint flows through un-neutralized.
const r = analyze(`function f(req) {
const arr = [req.body];
const b = escape(...arr);
exec(b);
}`);
expect(r.findings).toHaveLength(1);
expect(r.kills).toHaveLength(0);
});
it('conditional sanitizer does NOT suppress: if (cond) { b = escape(b) } exec(b) → finding', () => {
const r = analyze(`function f(req, cond) {
let b = req.body;
if (cond) {
b = escape(b);
}
exec(b);
}`);
expect(r.findings).toHaveLength(1);
// the seed def's flow survives the may-path; the sanitized def is killed
expect(hopSummary(r)).toEqual(['b@2', 'b@6']);
expect(r.kills).toHaveLength(1);
});
});
// ── loop semantics: zero-iteration pair, fixpoint termination ───────────────
describe('loops — kill keyed on the def point, monotone termination (R3)', () => {
it('zero-iteration while: the cond-false exit carries the seed def → finding SURVIVES', () => {
const r = analyze(`function f(req, c) {
let x = req.body;
while (c) {
x = escape(x);
}
exec(x);
}`);
expect(r.findings).toHaveLength(1);
expect(hopSummary(r)).toEqual(['x@2', 'x@6']);
expect(r.kills).toHaveLength(1);
});
it('do-while: the body always runs → no finding (and escape-in-loop does not re-taint)', () => {
const r = analyze(`function f(req, c) {
let x = req.body;
do {
x = escape(x);
} while (c);
exec(x);
}`);
expect(r.findings).toHaveLength(0);
expect(r.kills).toHaveLength(1);
});
it('loop self-taint terminates: x = x + t in a for loop → finding, bounded', () => {
const r = analyze(`function f(req) {
const t = req.body;
let x = '';
for (let i = 0; i < 3; i++) {
x = x + t;
}
exec(x);
}`);
expect(r.status).toBe('computed');
expect(r.findings).toHaveLength(1);
});
it('assign-and-test: if ((m = re.exec(s)) && m) exec(m) → finding (self-fact handled)', () => {
const r = analyze(`function f(req, re) {
const s = req.body;
let m;
if ((m = re.exec(s)) && m) {
exec(m);
}
}`);
expect(r.findings).toHaveLength(1);
});
});
// ── propagate-through unmodeled calls (KTD5) ─────────────────────────────────
describe('propagate-through — unmodeled calls and receivers, viaCall marks', () => {
it('const y = helper(t); exec(y) → finding with a viaCall hop', () => {
const r = analyze(`function f(req) {
const t = req.body;
const y = helper(t);
exec(y);
}`);
expect(r.findings).toHaveLength(1);
expect(hopSummary(r)).toEqual(['t@2', 'y@3*', 'y@4']);
});
it('receiver propagation: const cmd = t.trim(); exec(cmd) → finding (TITO)', () => {
const r = analyze(`function f(req) {
const t = req.body;
const cmd = t.trim();
exec(cmd);
}`);
expect(r.findings).toHaveLength(1);
expect(hopSummary(r)).toEqual(['t@2', 'cmd@3*', 'cmd@4']);
});
it('tainted occurrence nested in an unmodeled call AT the sink fires: exec(helper(t))', () => {
const r = analyze(`function f(req) {
const t = req.body;
exec(helper(t));
}`);
expect(r.findings).toHaveLength(1);
// the sink hop records that the occurrence flowed through a call
const sinkHop = r.findings[0].hops.at(-1);
expect(sinkHop?.viaCall).toBe(true);
});
it('sanitized through-call: const y = helper(escape(x)); exec(y) → NO finding', () => {
// Deliberate precision choice over flat-conservative (plan KTD5): the only
// occurrence path into the unmodeled call traverses the sanitizer, so the
// neutralization rides through the call into y's exclusion set.
const r = analyze(`function f(req) {
const x = req.body;
const y = helper(escape(x));
exec(y);
}`);
expect(r.findings).toHaveLength(0);
});
});
// ── statement-coalescing precision floor ─────────────────────────────────────
describe('precision floor — multi-declarator conflation (documented FP)', () => {
it('const a = clean(z), b = g(t); exec(a) → finding EXISTS (expected false positive)', () => {
// PINNED FP (plan risk table): statement facts conflate declarators — `t`
// is used by the statement and `a` is def'd by it, so `a` is floor-tainted
// even though `t` flows only into `g(...)`. The per-declarator resultDefs
// precision powers KILLS only (U1 note); widening it to taint attribution
// would be an unsound narrowing of the substrate's statement granularity.
const r = analyze(`function f(req, z) {
const t = req.body;
const a = clean(z), b = g(t);
exec(a);
}`);
expect(r.findings).toHaveLength(1);
});
it('the floor never KILLS: a def in a sanitizer resultDefs with no taint inflow is floor-tainted', () => {
// `a = escape(z)` — the tainted `t` never flows into escape, so no kill is
// recorded for it and `a` is floor-tainted with NO exclusions (sound: a
// kill requires evidence of flow through the sanitizer).
const r = analyze(`function f(req, z) {
const t = req.body;
const a = escape(z), b = g(t);
exec(a);
}`);
expect(r.findings).toHaveLength(1);
expect(r.kills).toHaveLength(0);
});
});
// ── sequence-expression value semantics (review fix) ────────────────────────
describe('sequence expressions — only the final operand carries taint', () => {
it('exec((log(x), "safe")) with tainted x → NO finding (safe operand flows)', () => {
const r = analyze(`function f(req) { const x = req.body; exec((log(x), 'safe')); }`);
expect(r.findings).toHaveLength(0);
});
it('exec((log("a"), x)) with tainted x → finding (tainted final operand)', () => {
const r = analyze(`function f(req) { const x = req.body; exec((log('a'), x)); }`);
expect(r.findings).toHaveLength(1);
});
});
// ── source-discriminated taint state (review fix: multi-source merge) ───────
describe('multi-source identity — distinct sources do not merge at one def', () => {
// A spec with two source properties and a sql sanitizer, so the same-source
// ∅-intersection case has a kind to neutralize.
const MULTI: SourceSinkSanitizerSpec = {
sources: [{ kind: 'remote-input', objects: ['req'], properties: ['body', 'query'] }],
sinks: [
{ name: 'exec', kind: 'command-injection', args: [0], global: true },
{ name: 'query', kind: 'sql-injection', args: [0], anyReceiver: true },
],
sanitizers: [{ name: 'escape', neutralizes: ['sql-injection'], global: true }],
};
it('cond ? req.body : req.query into one var → TWO findings (one per source)', () => {
const r = analyze(`function f(req, cond) { const x = cond ? req.body : req.query; exec(x); }`, {
spec: MULTI,
});
expect(r.findings).toHaveLength(2);
const props = r.findings.map((f) => f.source.property).sort();
expect(props).toEqual(['body', 'query']);
});
it('req.body + req.query into one var → TWO findings', () => {
const r = analyze(`function f(req) { const x = req.body + req.query; exec(x); }`, {
spec: MULTI,
});
expect(r.findings).toHaveLength(2);
});
it('same-source two-path flow → ONE finding (one root source occurrence)', () => {
// `req.body` is read ONCE (one source occurrence → one root identity); the
// single tainted `x` then flows two ways into `c`. Both paths share the
// same source discriminator, so they converge on one state for `c` and
// produce exactly one finding — the source dimension must not split a
// single source occurrence by downstream path.
const r = analyze(
`function f(req, cond) { const x = req.body; const c = cond ? id(x) : x; db.query(c); }`,
{ spec: MULTI },
);
expect(r.findings).toHaveLength(1);
expect(r.findings[0].sinkKind).toBe('sql-injection');
});
it('one source re-derived into one sink var → ONE finding, no doubling', () => {
// A single source reaching a single sink binding via a re-derived path
// dedups to one finding (findingsByIdentity); the source dimension in the
// state key must not split this same-source flow.
const r = analyze(`function f(req, cond) { let x = req.body; if (cond) { x = x; } exec(x); }`, {
spec: MULTI,
});
expect(r.findings).toHaveLength(1);
});
it('two sources through a loop back-edge terminate with TWO findings', () => {
const r = analyze(
`function f(req, n) { let x = req.body; for (let i = 0; i < n; i++) { x = x + req.query; } exec(x); }`,
{ spec: MULTI },
);
expect(r.status).toBe('computed');
expect(r.findings).toHaveLength(2);
});
});
// ── kind-set exclusion model (real built-in model) ──────────────────────────
describe('kind-set exclusions — sanitizers neutralize their kinds only', () => {
it('escape(req.body) → res.send(b) suppressed (xss neutralized) BUT db.query(b) fires (sql not)', () => {
const r = analyze(
`import { escape } from 'validator';
function f(req, db, res) {
const b = escape(req.body);
db.query(b);
res.send(b);
}`,
{ spec: TS_JS_TAINT_MODEL },
);
expect(r.findings).toHaveLength(1);
expect(r.findings[0].sinkKind).toBe('sql-injection');
expect(r.findings[0].sink.point.line).toBe(4);
// the seed kill is still recorded with the kinds escape neutralizes
expect(r.kills).toHaveLength(1);
expect([...r.kills[0].neutralized]).toEqual(['xss']);
});
it('kind-incompatible sink interposition: exec(path.basename(t)) → finding SURVIVES', () => {
// basename strips directories, not shell metacharacters — a kind-blind
// kill here would be a suppressed live command injection (the forbidden
// false-negative direction).
const r = analyze(
`import { exec } from 'child_process';
import path from 'path';
function f(req) {
const t = req.body;
exec(path.basename(t));
}`,
{ spec: TS_JS_TAINT_MODEL },
);
expect(r.findings).toHaveLength(1);
expect(r.findings[0].sinkKind).toBe('command-injection');
});
it('kind-compatible def interposition: const safe = path.basename(t); readFileSync(safe) → suppressed', () => {
const r = analyze(
`import path from 'path';
import { readFileSync } from 'fs';
function f(req) {
const t = req.body;
const safe = path.basename(t);
readFileSync(safe);
}`,
{ spec: TS_JS_TAINT_MODEL },
);
expect(r.findings).toHaveLength(0);
expect(r.kills).toHaveLength(1);
expect([...r.kills[0].neutralized]).toEqual(['path-traversal']);
});
it('the same basename-cleaned def still fires a command-injection sink', () => {
const r = analyze(
`import { exec } from 'child_process';
import path from 'path';
function f(req) {
const t = req.body;
const safe = path.basename(t);
exec(safe);
}`,
{ spec: TS_JS_TAINT_MODEL },
);
expect(r.findings).toHaveLength(1);
expect(r.findings[0].sinkKind).toBe('command-injection');
});
it('exclusions intersect over derivations: a less-neutralized re-derivation re-opens the sink', () => {
// y is first derived through escape ({xss} excluded), then re-derived
// through the direct assignment (∅) — the intersection is ∅, so the
// xss sink must fire. Guards the monotone shrink/re-enqueue discipline.
const r = analyze(
`import { escape } from 'validator';
function f(req, cond, res) {
const b = req.body;
let y = escape(b);
if (cond) {
y = b;
}
res.send(y);
}`,
{ spec: TS_JS_TAINT_MODEL },
);
// two defs of y reach the sink: the escaped one (suppressed for xss) and
// the raw one (fires) — one finding from the raw def's flow
expect(r.findings).toHaveLength(1);
expect(r.findings[0].sinkKind).toBe('xss');
});
});
// ── caps and determinism ─────────────────────────────────────────────────────
describe('caps — deterministic truncation (R6 substrate)', () => {
const manyFindings = `function f(req) {
exec(req.body);
exec(req.query);
exec(req.params);
exec(req.headers);
}`;
it('maxFindingsPerFunction truncates deterministically and counts the drop', () => {
const r = analyze(manyFindings, { limits: { maxFindingsPerFunction: 2 } });
expect(r.findings).toHaveLength(2);
expect(r.droppedFindings).toBe(2);
// deterministic prefix of the sorted order: statement order
expect(r.findings.map((f) => f.source.property)).toEqual(['body', 'query']);
});
it('without a cap all findings emit and droppedFindings is 0', () => {
const r = analyze(manyFindings);
expect(r.findings).toHaveLength(4);
expect(r.droppedFindings).toBe(0);
});
it('maxHops truncates the hop chain and flags it', () => {
const r = analyze(
`function f(req) {
const a = req.body;
const b = a;
const c = b;
exec(c);
}`,
{ limits: { maxHops: 2 } },
);
expect(r.findings).toHaveLength(1);
expect(r.findings[0].hops).toHaveLength(2);
expect(r.findings[0].hopsTruncated).toBe(true);
expect(hopSummary(r)).toEqual(['a@2', 'b@3']);
});
it('results are deterministic across repeated runs', () => {
const code = `function f(req, cond) {
let x = req.body;
const y = helper(x);
if (cond) {
x = escape(x);
}
exec(x);
exec(y);
exec(req.query);
}`;
const a = analyze(code);
const b = analyze(code);
expect(JSON.parse(JSON.stringify(a))).toEqual(JSON.parse(JSON.stringify(b)));
});
});

View file

@ -0,0 +1,172 @@
import { describe, it, expect } from 'vitest';
import Parser from 'tree-sitter';
import TypeScript from 'tree-sitter-typescript';
import {
createTypeScriptCfgVisitor,
TS_FUNCTION_TYPES,
} from '../../../src/core/ingestion/cfg/visitors/typescript.js';
import { hasTaintSafeSites } from '../../../src/core/ingestion/taint/site-safety.js';
import { isEmitSafeCfg, hasEmitSafeFacts } from '../../../src/core/ingestion/cfg/emit.js';
import type {
FunctionCfg,
SiteRecord,
StatementFacts,
} from '../../../src/core/ingestion/cfg/types.js';
import type { SyntaxNode } from '../../../src/core/ingestion/utils/ast-helpers.js';
// #2083 M3 U1 — `hasTaintSafeSites` mirrors `hasEmitSafeFacts`'s contract:
// out-of-range indices from a corrupted durable store must degrade to
// "skip taint for this function", never crash or fabricate matches. These
// tests build a REAL harvested CFG, then surgically corrupt the `sites`
// payload field-by-field — and pin that corrupt sites do NOT trip the
// CFG/REACHING_DEF guards (the degradation is taint-local).
const visitor = createTypeScriptCfgVisitor();
function cfgOf(code: string): FunctionCfg {
const parser = new Parser();
parser.setLanguage(TypeScript.typescript);
const root = parser.parse(code).rootNode as SyntaxNode;
const stack = [root];
while (stack.length) {
const n = stack.pop() as SyntaxNode;
if (TS_FUNCTION_TYPES.has(n.type)) {
const cfg = visitor.buildFunctionCfg(n, 'fixture.ts');
if (cfg) return cfg;
}
for (let i = n.namedChildCount - 1; i >= 0; i--) {
const c = n.namedChild(i);
if (c) stack.push(c);
}
}
throw new Error('no function found');
}
const BASE = cfgOf(`function f(req, x) { const b = req.body; exec(escape(x), b); }`);
/** Deep-copy and rewrite the FIRST site of the FIRST site-bearing statement. */
function mutateFirstSite(patch: (site: Record<string, unknown>) => void): FunctionCfg {
const copy = JSON.parse(JSON.stringify(BASE)) as FunctionCfg;
for (const block of copy.blocks) {
for (const s of block.statements ?? []) {
if (s.sites && s.sites.length > 0) {
patch(s.sites[0] as unknown as Record<string, unknown>);
return copy;
}
}
}
throw new Error('no site-bearing statement in fixture');
}
describe('hasTaintSafeSites — valid shapes pass', () => {
it('a real harvested CFG passes', () => {
expect(hasTaintSafeSites(BASE)).toBe(true);
});
it('a CFG with facts but no sites passes (absence is the well-formed empty case)', () => {
const cfg = cfgOf(`function f() { let a = 1; a = 2; }`);
expect(hasTaintSafeSites(cfg)).toBe(true);
});
it('a pre-M2 CFG with no statements at all passes', () => {
const copy = JSON.parse(JSON.stringify(BASE)) as FunctionCfg;
const stripped = {
...copy,
bindings: undefined,
blocks: copy.blocks.map((b) => ({ ...b, statements: undefined })),
} as FunctionCfg;
expect(hasTaintSafeSites(stripped)).toBe(true);
});
it('the full M3 surface validates on a JSON round-trip (durable-store shape)', () => {
const cfg = cfgOf(
'function f(req, dir, t) { const cp = require("child_process"); ' +
'cp.exec(`ls ${dir}`); sql`q ${t}`; new Function(t); exec(...dir); }',
);
expect(hasTaintSafeSites(JSON.parse(JSON.stringify(cfg)) as FunctionCfg)).toBe(true);
});
});
describe('hasTaintSafeSites — malformed indices reject', () => {
it('out-of-range receiver', () => {
expect(hasTaintSafeSites(mutateFirstSite((s) => (s.receiver = 999)))).toBe(false);
expect(hasTaintSafeSites(mutateFirstSite((s) => (s.receiver = -1)))).toBe(false);
expect(hasTaintSafeSites(mutateFirstSite((s) => (s.receiver = 1.5)))).toBe(false);
});
it('out-of-range member-read object / missing property', () => {
const cfg = cfgOf(`function f(req) { const b = req.body; }`);
const corrupt = JSON.parse(JSON.stringify(cfg)) as FunctionCfg;
const site = corrupt.blocks.flatMap((b) => [...(b.statements ?? [])]).find((s) => s.sites)!
.sites![0] as unknown as Record<string, unknown>;
site.object = 999;
expect(hasTaintSafeSites(corrupt)).toBe(false);
site.object = 0;
delete site.property;
expect(hasTaintSafeSites(corrupt)).toBe(false);
});
it('out-of-range arg binding entry and via-site tag', () => {
expect(hasTaintSafeSites(mutateFirstSite((s) => (s.args = [[999]])))).toBe(false);
// via-tag site index must be in range of the SAME statement's sites array
expect(hasTaintSafeSites(mutateFirstSite((s) => (s.args = [[[0, 999]]])))).toBe(false);
expect(hasTaintSafeSites(mutateFirstSite((s) => (s.args = [[[0, -1]]])))).toBe(false);
// tuple arity is exact
expect(hasTaintSafeSites(mutateFirstSite((s) => (s.args = [[[0, 1, 2]]])))).toBe(false);
expect(hasTaintSafeSites(mutateFirstSite((s) => (s.args = [['x']])))).toBe(false);
expect(hasTaintSafeSites(mutateFirstSite((s) => (s.args = [0])))).toBe(false);
});
it('out-of-range resultDefs / parent / spread / kind / callee', () => {
expect(hasTaintSafeSites(mutateFirstSite((s) => (s.resultDefs = [999])))).toBe(false);
expect(hasTaintSafeSites(mutateFirstSite((s) => (s.parent = [999, 0])))).toBe(false);
expect(hasTaintSafeSites(mutateFirstSite((s) => (s.parent = [0, -1])))).toBe(false);
expect(hasTaintSafeSites(mutateFirstSite((s) => (s.parent = [0])))).toBe(false);
expect(hasTaintSafeSites(mutateFirstSite((s) => (s.spread = -1)))).toBe(false);
expect(hasTaintSafeSites(mutateFirstSite((s) => (s.kind = 'evil')))).toBe(false);
expect(hasTaintSafeSites(mutateFirstSite((s) => (s.callee = 42)))).toBe(false);
expect(hasTaintSafeSites(mutateFirstSite((s) => (s.requireArg = 42)))).toBe(false);
});
it('sites without a binding table reject (nothing to range-check against)', () => {
const copy = JSON.parse(JSON.stringify(BASE)) as { bindings?: unknown };
delete copy.bindings;
expect(hasTaintSafeSites(copy as FunctionCfg)).toBe(false);
});
it('non-array sites and null site entries reject', () => {
const corrupt = JSON.parse(JSON.stringify(BASE)) as FunctionCfg;
const stmt = corrupt.blocks.flatMap((b) => [...(b.statements ?? [])]).find((s) => s.sites) as {
sites: unknown;
};
stmt.sites = { not: 'an array' };
expect(hasTaintSafeSites(corrupt)).toBe(false);
stmt.sites = [null];
expect(hasTaintSafeSites(corrupt)).toBe(false);
});
});
describe('hasTaintSafeSites — degradation is taint-local (KTD2)', () => {
it('corrupt sites do NOT trip the CFG or REACHING_DEF guards', () => {
const corrupt = mutateFirstSite((s) => (s.receiver = 999));
expect(hasTaintSafeSites(corrupt)).toBe(false);
// The CFG layer and the facts layer keep their own guards green — the
// function degrades to "no taint", never to "no CFG"/"no REACHING_DEF".
expect(isEmitSafeCfg(corrupt)).toBe(true);
expect(hasEmitSafeFacts(corrupt)).toBe(true);
});
it('and the inverse: corrupt FACTS are not a sites problem (separate guards)', () => {
const corrupt = JSON.parse(JSON.stringify(BASE)) as FunctionCfg;
const stmt = corrupt.blocks.flatMap((b) => [...(b.statements ?? [])])[1] as StatementFacts & {
defs: number[];
};
stmt.defs.push(999);
expect(hasEmitSafeFacts(corrupt)).toBe(false);
const sites: readonly SiteRecord[] | undefined = corrupt.blocks
.flatMap((b) => [...(b.statements ?? [])])
.find((s) => s.sites)?.sites;
expect(sites).toBeDefined();
expect(hasTaintSafeSites(corrupt)).toBe(true);
});
});

View file

@ -23,7 +23,12 @@ describe('source/sink/sanitizer registry seam (#2080)', () => {
});
it('register then get round-trips the spec', () => {
const ts = spec({ sinks: [{ name: 'eval' }], sources: [{ name: 'req.body', args: [0] }] });
// U2 (#2083) extended the M0 entry shapes: sinks carry a `kind` category
// and sources are member-read entries — this test was updated deliberately.
const ts = spec({
sinks: [{ name: 'eval', kind: 'code-injection', global: true }],
sources: [{ kind: 'remote-input', objects: ['req'], properties: ['body'] }],
});
registerSourceSinkConfig('typescript', ts);
expect(getSourceSinkConfig('typescript')).toBe(ts);
expect(registeredTaintLanguages()).toEqual(['typescript']);
@ -35,8 +40,10 @@ describe('source/sink/sanitizer registry seam (#2080)', () => {
});
it('re-registering the same language id overwrites (last-write-wins)', () => {
const first = spec({ sinks: [{ name: 'eval' }] });
const second = spec({ sinks: [{ name: 'exec' }] });
const first = spec({ sinks: [{ name: 'eval', kind: 'code-injection', global: true }] });
const second = spec({
sinks: [{ name: 'exec', kind: 'command-injection', module: 'child_process' }],
});
registerSourceSinkConfig('typescript', first);
registerSourceSinkConfig('typescript', second);
expect(getSourceSinkConfig('typescript')).toBe(second);

View file

@ -0,0 +1,338 @@
/**
* U4 (#2083 M3) — `emitFileTaint` over REAL harvested CFGs.
*
* Fixtures parse real source through the worker-side TS CFG visitor (the
* propagate.test.ts harness): CFGs and sites come from the harvest, imports
* from the real TS capture+interpret path — the emit driver consumes exactly
* the structures the run.ts pdg window feeds it, never hand-built mocks
* (except the deliberate corrupted-store mutation below).
*
* Pinned here: KTD6 statement-level finding identity (occurrence-distinct
* rows for `exec(req.body, req.query)`; variable-distinct rows on a shared
* block pair), dedup-before-budget with truncate-and-warn, the zero-match
* fast path (no solver call — asserted via the result counters), the
* unsafe-sites skip-taint-keep-RD degradation, kills-without-findings, and
* the decodability of every persisted `reason` via the shared path codec.
*/
import { describe, it, expect } from 'vitest';
import { cfgsOf, importsFor } from '../../helpers/ts-cfg-harness.js';
import { emitFileCfgs } from '../../../src/core/ingestion/cfg/emit.js';
import type { SourceSinkSanitizerSpec } from '../../../src/core/ingestion/taint/source-sink-config.js';
import {
emitFileTaint,
type TaintEmitLimits,
type TaintEmitResult,
} from '../../../src/core/ingestion/taint/emit.js';
import { decodeTaintPath } from '../../../src/core/ingestion/taint/path-codec.js';
import { createKnowledgeGraph } from '../../../src/core/graph/graph.js';
import type { KnowledgeGraph } from '../../../src/core/graph/types.js';
import type { GraphRelationship } from 'gitnexus-shared';
/** Mechanics spec (propagate.test.ts MECH): global exec sink, global escape sanitizer. */
const MECH: SourceSinkSanitizerSpec = {
sources: [
{ kind: 'remote-input', objects: ['req'], properties: ['body', 'query', 'params', 'headers'] },
],
sinks: [{ name: 'exec', kind: 'command-injection', args: [0], global: true }],
sanitizers: [{ name: 'escape', neutralizes: ['command-injection'], global: true }],
};
/** MECH with the sink dangerous at EVERY position (`args` omitted). */
const MECH_ALL_ARGS: SourceSinkSanitizerSpec = {
...MECH,
sinks: [{ name: 'exec', kind: 'command-injection', global: true }],
};
interface RunResult {
graph: KnowledgeGraph;
result: TaintEmitResult;
tainted: GraphRelationship[];
sanitizes: GraphRelationship[];
warns: string[];
}
function run(
code: string,
opts: { spec?: SourceSinkSanitizerSpec; limits?: TaintEmitLimits; cfgs?: FunctionCfg[] } = {},
): RunResult {
const graph = createKnowledgeGraph();
const cfgs = opts.cfgs ?? cfgsOf(code);
// Emit the M1 layer first so taint endpoints can be checked against REAL
// persisted BasicBlock nodes (run.ts ordering).
emitFileCfgs(graph, cfgs);
const warns: string[] = [];
const result = emitFileTaint(graph, cfgs, importsFor(code), opts.spec ?? MECH, opts.limits, (m) =>
warns.push(m),
);
const tainted: GraphRelationship[] = [];
const sanitizes: GraphRelationship[] = [];
for (const rel of graph.iterRelationships()) {
if (rel.type === 'TAINTED') tainted.push(rel);
if (rel.type === 'SANITIZES') sanitizes.push(rel);
}
return { graph, result, tainted, sanitizes, warns };
}
function blockIds(graph: KnowledgeGraph): Set<string> {
const ids = new Set<string>();
graph.forEachNode((n) => {
if (n.label === 'BasicBlock') ids.add(n.id);
});
return ids;
}
describe('emitFileTaint — happy path', () => {
const CODE = `
function handler(req: { body: string }) {
const cmd = req.body;
exec(cmd);
}`;
it('persists one TAINTED edge whose endpoints are real BasicBlock nodes', () => {
const { graph, result, tainted } = run(CODE);
expect(result.functionsAnalyzed).toBe(1);
expect(result.findingsEmitted).toBe(1);
expect(tainted).toHaveLength(1);
const ids = blockIds(graph);
expect(ids.has(tainted[0].sourceId)).toBe(true);
expect(ids.has(tainted[0].targetId)).toBe(true);
expect(tainted[0].id.startsWith('TAINTED:fixture.ts:')).toBe(true);
});
it('the persisted reason decodes via the shared codec with ordered hops + variables', () => {
const { tainted } = run(CODE);
const decoded = decodeTaintPath(tainted[0].reason);
expect(decoded.ok).toBe(true);
if (decoded.ok) {
expect(decoded.truncated).toBe(false);
// The finding's sinkKind rides the `;<kind>` header (the only persisted
// channel — the edge id embedding it is not a stored column; U6 reads it).
expect(decoded.kind).toBe('command-injection');
// seed def (cmd @ const line) → sink use (cmd @ exec line)
expect(decoded.hops.map((h) => `${h.variable}@${h.line}`)).toEqual(['cmd@3', 'cmd@4']);
}
});
});
describe('KTD6 statement-level finding identity', () => {
it('exec(req.body, req.query) → TWO rows distinguished by occurrence', () => {
const { result, tainted } = run(
`
function handler(req: { body: string; query: string }) {
exec(req.body, req.query);
}`,
{ spec: MECH_ALL_ARGS },
);
expect(result.findingsEmitted).toBe(2);
expect(tainted).toHaveLength(2);
// Same block pair — the identity (and the id) differ ONLY by occurrence.
expect(tainted[0].sourceId).toBe(tainted[1].sourceId);
expect(tainted[0].targetId).toBe(tainted[1].targetId);
expect(tainted[0].id).not.toBe(tainted[1].id);
});
it('two findings on the same block pair with different variables → two rows', () => {
const { result, tainted } = run(`
function handler(req: { body: string; query: string }) {
const a = req.body;
const b = req.query;
exec(a);
exec(b);
}`);
expect(result.findingsEmitted).toBe(2);
expect(tainted).toHaveLength(2);
expect(new Set(tainted.map((t) => t.id)).size).toBe(2);
const variables = tainted.map((t) => {
const d = decodeTaintPath(t.reason);
if (!d.ok) throw new Error(d.error);
return d.hops[d.hops.length - 1].variable;
});
expect(variables.sort()).toEqual(['a', 'b']);
});
});
describe('dedup-before-budget and the findings cap', () => {
const FOUR_FINDINGS = `
function handler(req: { body: string; query: string }) {
const a = req.body;
const b = req.query;
exec(a);
exec(b);
exec(a);
exec(b);
}`;
it('uncapped: identical (deduped) flows collapse; distinct ones all emit', () => {
const { result, tainted } = run(FOUR_FINDINGS);
// 4 sink statements × 1 variable each — all distinct sink points → 4 rows.
expect(result.findingsEmitted).toBe(4);
expect(result.findingsDropped).toBe(0);
expect(new Set(tainted.map((t) => t.id)).size).toBe(4);
});
it('capped: truncates deterministically and warns naming the drop count', () => {
const { result, tainted, warns } = run(FOUR_FINDINGS, {
limits: { maxFindingsPerFunction: 1 },
});
expect(result.findingsEmitted).toBe(1);
expect(result.findingsDropped).toBe(3);
expect(tainted).toHaveLength(1);
const capWarn = warns.find((w) => w.includes('findings cap'));
expect(capWarn).toBeDefined();
expect(capWarn).toContain('dropped 3 of 4');
expect(result.droppedExamples).toEqual(['fixture.ts:2']);
});
});
describe('zero-match fast path', () => {
it('no source AND no sink: function skipped without a solver call', () => {
const { result, tainted, sanitizes } = run(`
function pure(x: number) {
const y = x + 1;
return y;
}`);
expect(result.functionsSkippedNoMatch).toBe(1);
expect(result.functionsAnalyzed).toBe(0);
expect(tainted).toHaveLength(0);
expect(sanitizes).toHaveLength(0);
});
it('sink without source (and vice versa) also short-circuits', () => {
const { result } = run(`
function sinkOnly(cmd: string) {
exec(cmd);
}
function sourceOnly(req: { body: string }) {
return req.body;
}`);
expect(result.functionsSkippedNoMatch).toBe(2);
expect(result.functionsAnalyzed).toBe(0);
expect(result.findingsEmitted).toBe(0);
});
});
describe('unsafe-sites degradation (skip-taint-keep-RD)', () => {
const TWO_FNS = `
function corrupted(req: { body: string }) {
exec(req.body);
}
function stillVulnerable(req: { body: string }) {
exec(req.body);
}`;
it('a corrupted-store site skips ONLY that function; siblings still emit', () => {
const cfgs = JSON.parse(JSON.stringify(cfgsOf(TWO_FNS))) as FunctionCfg[];
// Corrupt the first function's first site: out-of-range binding index.
let mutated = false;
outer: for (const block of cfgs[0].blocks) {
for (const stmt of block.statements ?? []) {
if (stmt.sites !== undefined && stmt.sites.length > 0) {
(stmt.sites[0] as { object?: number }).object = 9999;
mutated = true;
break outer;
}
}
}
expect(mutated).toBe(true);
const { result, tainted, warns } = run(TWO_FNS, { cfgs });
expect(result.functionsSkippedUnsafeSites).toBe(1);
expect(result.functionsAnalyzed).toBe(1);
expect(result.findingsEmitted).toBe(1); // the sibling's finding survives
expect(tainted).toHaveLength(1);
expect(tainted[0].id).toContain('fixture.ts:5'); // the SECOND function
expect(warns.some((w) => w.includes('malformed site annotations'))).toBe(true);
expect(result.coverageGapExamples).toEqual(['fixture.ts:2']);
});
});
describe('kills without findings', () => {
it('a fully-sanitized flow emits SANITIZES and zero TAINTED', () => {
const { graph, result, tainted, sanitizes } = run(`
function safe(req: { body: string }) {
const b = escape(req.body);
exec(b);
}`);
expect(result.functionsAnalyzed).toBe(1);
expect(result.findingsEmitted).toBe(0);
expect(tainted).toHaveLength(0);
expect(result.killsEmitted).toBe(1);
expect(sanitizes).toHaveLength(1);
// reason = the killed binding's plain name; endpoints are real blocks.
expect(sanitizes[0].reason).toBe('b');
expect(sanitizes[0].id.startsWith('SANITIZES:fixture.ts:')).toBe(true);
const ids = blockIds(graph);
expect(ids.has(sanitizes[0].sourceId)).toBe(true);
expect(ids.has(sanitizes[0].targetId)).toBe(true);
});
it('a kill alongside a finding emits both edge kinds', () => {
const { result } = run(`
function mixed(req: { body: string; query: string }) {
const safe = escape(req.body);
exec(safe);
exec(req.query);
}`);
expect(result.killsEmitted).toBe(1);
expect(result.findingsEmitted).toBe(1);
});
});
describe('hop truncation accounting', () => {
it('maxHops=1 truncates the persisted path and counts the finding', () => {
const { result, tainted } = run(
`
function handler(req: { body: string }) {
const a = req.body;
const b = a;
exec(b);
}`,
{ limits: { maxHops: 1 } },
);
expect(result.findingsEmitted).toBe(1);
expect(result.hopsTruncatedFindings).toBe(1);
const decoded = decodeTaintPath(tainted[0].reason);
expect(decoded.ok).toBe(true);
if (decoded.ok) {
expect(decoded.truncated).toBe(true); // path-incomplete, not an error
expect(decoded.hops).toHaveLength(1); // source-side prefix
expect(decoded.hops[0].variable).toBe('a');
}
});
});
describe('telemetry completeness', () => {
it('every counter field is present (nothing dropped on the floor — the M2 lesson)', () => {
const { result } = run(`function noop() { return 1; }`);
expect(result).toEqual({
functionsAnalyzed: 0,
functionsSkippedNoMatch: 1,
functionsSkippedUnsafeSites: 0,
functionsCoverageGap: { truncated: 0, overflow: 0, 'no-facts': 0 },
findingsEmitted: 0,
killsEmitted: 0,
findingsDropped: 0,
hopsTruncatedFindings: 0,
coverageGapExamples: [],
droppedExamples: [],
});
});
it('a solver coverage gap (fact limit) is counted by reason, with an example anchor', () => {
const { result, tainted } = run(
`
function gap(req: { body: string }) {
const a = req.body;
const b = a;
const c = b;
exec(c);
}`,
{ limits: { maxFacts: 1 } },
);
expect(result.functionsCoverageGap.truncated).toBe(1);
expect(result.functionsAnalyzed).toBe(0);
expect(tainted).toHaveLength(0); // R4: never partially analyzed
expect(result.coverageGapExamples).toEqual(['fixture.ts:2']);
});
});

View file

@ -21,8 +21,8 @@ const MUTATING_TOOLS = new Set(['rename', 'group_sync']);
const OPEN_WORLD_READ_ONLY_TOOLS = new Set(['query']);
describe('GITNEXUS_TOOLS', () => {
it('exports all tools (8 base + 3 route/tool/shape + 1 api_impact + 2 group)', () => {
expect(GITNEXUS_TOOLS).toHaveLength(14);
it('exports all tools (8 base + 1 explain + 3 route/tool/shape + 1 api_impact + 2 group)', () => {
expect(GITNEXUS_TOOLS).toHaveLength(15);
});
it('contains all expected tool names', () => {
@ -37,6 +37,7 @@ describe('GITNEXUS_TOOLS', () => {
'check',
'rename',
'impact',
'explain',
'api_impact',
]),
);
@ -218,6 +219,38 @@ describe('GITNEXUS_TOOLS', () => {
expect(scopeProp.enum).toEqual(['unstaged', 'staged', 'all', 'compare']);
});
// ─── explain (#2083 M3 U6) ─────────────────────────────────────────
it('explain tool is anchorless-optional with a bounded limit and a branch scope', () => {
const explainTool = GITNEXUS_TOOLS.find((t) => t.name === 'explain')!;
expect(explainTool).toBeDefined();
// Anchorless calls (enumerate all findings) must be valid.
expect(explainTool.inputSchema.required).toEqual([]);
expect(explainTool.inputSchema.properties.target).toBeDefined();
expect(explainTool.inputSchema.properties.target.type).toBe('string');
const limit = explainTool.inputSchema.properties.limit;
expect(limit).toBeDefined();
expect(limit.type).toBe('integer');
expect(limit.minimum).toBe(1);
expect(limit.maximum).toBeGreaterThan(0);
// Branch-scoped per #2106 (injected via BRANCH_SCOPED_TOOLS).
expect(explainTool.inputSchema.properties.branch).toBeDefined();
});
it('explain description names the --pdg requirement and the KTD10 contract caveats', () => {
const explainTool = GITNEXUS_TOOLS.find((t) => t.name === 'explain')!;
const d = explainTool.description;
expect(d).toContain('--pdg');
expect(d).toContain('intra-procedural');
// The named blind-spot classes (plan KTD10) must reach the consumer.
expect(d.toLowerCase()).toContain('closure/callback');
expect(d.toLowerCase()).toContain('property/field');
expect(d.toLowerCase()).toContain('guard-style');
expect(d.toLowerCase()).toContain('cross-function');
expect(d.toLowerCase()).toContain('commonjs');
expect(d.toLowerCase()).toContain('exception');
});
it('api_impact tool has no required parameters', () => {
const apiImpactTool = GITNEXUS_TOOLS.find((t) => t.name === 'api_impact')!;
expect(apiImpactTool).toBeDefined();