diff --git a/.claude/skills/gitnexus/gitnexus-guide/SKILL.md b/.claude/skills/gitnexus/gitnexus-guide/SKILL.md index 2a1e76b02a..a702792222 100644 --- a/.claude/skills/gitnexus/gitnexus-guide/SKILL.md +++ b/.claude/skills/gitnexus/gitnexus-guide/SKILL.md @@ -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: diff --git a/gitnexus-claude-plugin/skills/gitnexus-guide/SKILL.md b/gitnexus-claude-plugin/skills/gitnexus-guide/SKILL.md index cacc4e8865..a71429f321 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-guide/SKILL.md +++ b/gitnexus-claude-plugin/skills/gitnexus-guide/SKILL.md @@ -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: diff --git a/gitnexus/bench/cfg/baselines.json b/gitnexus/bench/cfg/baselines.json index 7e3d718d83..0917b6bdd9 100644 --- a/gitnexus/bench/cfg/baselines.json +++ b/gitnexus/bench/cfg/baselines.json @@ -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." } } diff --git a/gitnexus/bench/cfg/measure.mjs b/gitnexus/bench/cfg/measure.mjs index 2451b5d62f..a4a2c9eaa2 100644 --- a/gitnexus/bench/cfg/measure.mjs +++ b/gitnexus/bench/cfg/measure.mjs @@ -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 && diff --git a/gitnexus/skills/gitnexus-guide.md b/gitnexus/skills/gitnexus-guide.md index a54337879b..a71429f321 100644 --- a/gitnexus/skills/gitnexus-guide.md +++ b/gitnexus/skills/gitnexus-guide.md @@ -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: diff --git a/gitnexus/src/cli/ai-context.ts b/gitnexus/src/cli/ai-context.ts index 641e7ee94b..4bf1179748 100644 --- a/gitnexus/src/cli/ai-context.ts +++ b/gitnexus/src/cli/ai-context.ts @@ -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 diff --git a/gitnexus/src/cli/skill-gen.ts b/gitnexus/src/cli/skill-gen.ts index f2fb42838a..61569e6a4a 100644 --- a/gitnexus/src/cli/skill-gen.ts +++ b/gitnexus/src/cli/skill-gen.ts @@ -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: ""})` — persisted taint findings (source→sink data flows), when indexed with `--pdg`', + ); lines.push(''); return lines.join('\n'); diff --git a/gitnexus/src/core/ingestion/cfg/emit.ts b/gitnexus/src/core/ingestion/cfg/emit.ts index 3b1a1e50d0..dc682d5b00 100644 --- a/gitnexus/src/core/ingestion/cfg/emit.ts +++ b/gitnexus/src/core/ingestion/cfg/emit.ts @@ -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}`; /** diff --git a/gitnexus/src/core/ingestion/cfg/reaching-defs.ts b/gitnexus/src/core/ingestion/cfg/reaching-defs.ts index 14c7b3745e..7b7f7233c6 100644 --- a/gitnexus/src/core/ingestion/cfg/reaching-defs.ts +++ b/gitnexus/src/core/ingestion/cfg/reaching-defs.ts @@ -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}. */ diff --git a/gitnexus/src/core/ingestion/cfg/types.ts b/gitnexus/src/core/ingestion/cfg/types.ts index ed789ec30c..53988e687f 100644 --- a/gitnexus/src/core/ingestion/cfg/types.ts +++ b/gitnexus/src/core/ingestion/cfg/types.ts @@ -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; + /** + * 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. */ diff --git a/gitnexus/src/core/ingestion/cfg/visitors/typescript-harvest.ts b/gitnexus/src/core/ingestion/cfg/visitors/typescript-harvest.ts index 81823c97a9..f1ea3abc06 100644 --- a/gitnexus/src/core/ingestion/cfg/visitors/typescript-harvest.ts +++ b/gitnexus/src/core/ingestion/cfg/visitors/typescript-harvest.ts @@ -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(); 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(); private readonly useSeen = new Set(); private readonly mayDefSeen = new Set(); + /** 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(); + /** 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; +}; diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index f1f8326321..d8b9fa16b4 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -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 diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/phase.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/phase.ts index 7d0fd811e0..dc0339c42a 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/phase.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/phase.ts @@ -354,6 +354,8 @@ export const scopeResolutionPhase: PipelinePhase = { 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); }, diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index e8d5fe6b57..2398e5ae6c 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -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)`, ); diff --git a/gitnexus/src/core/ingestion/taint/emit.ts b/gitnexus/src/core/ingestion/taint/emit.ts new file mode 100644 index 0000000000..d11694b282 --- /dev/null +++ b/gitnexus/src/core/ingestion/taint/emit.ts @@ -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(); + + 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 `;` 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; +} diff --git a/gitnexus/src/core/ingestion/taint/match.ts b/gitnexus/src/core/ingestion/taint/match.ts new file mode 100644 index 0000000000..50c513182f --- /dev/null +++ b/gitnexus/src/core/ingestion/taint/match.ts @@ -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, `.default` (the + * module/default export invoked directly) and `.` + * (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; + +/** 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(); + 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(); + 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(); + const conflicted = new Set(); + 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 }; +} diff --git a/gitnexus/src/core/ingestion/taint/path-codec.ts b/gitnexus/src/core/ingestion/taint/path-codec.ts new file mode 100644 index 0000000000..a7ceee6f7b --- /dev/null +++ b/gitnexus/src/core/ingestion/taint/path-codec.ts @@ -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[;]|:[:]|:[:]|…[|~] + * ``` + * + * - One-character version prefix (`TAINT_PATH_CODEC_VERSION`), then an + * OPTIONAL `;` 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 `#`.) + * + * 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 `#` 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 `;` 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 `;` 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 `;` 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 }; +} diff --git a/gitnexus/src/core/ingestion/taint/propagate.ts b/gitnexus/src/core/ingestion/taint/propagate.ts new file mode 100644 index 0000000000..0330f2d32a --- /dev/null +++ b/gitnexus/src/core/ingestion/taint/propagate.ts @@ -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(KIND_ORDER.map((k, i) => [k, i])); +const sortKinds = (kinds: Iterable): SinkKind[] => + [...new Set(kinds)].sort((a, b) => (kindRank.get(a) ?? 99) - (kindRank.get(b) ?? 99)); + +const EMPTY_KINDS: ReadonlySet = new Set(); + +/** One intra-statement occurrence path (interposition evidence). */ +interface OccPath { + /** Kinds neutralized along the path (union of traversed sanitizer hops). */ + readonly kinds: ReadonlySet; + /** 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; + readonly sanitizersBySite: ReadonlyMap; + /** binding → site indices whose `resultDefs` contain it (kill targets). */ + readonly resultDefSites: ReadonlyMap; +} + +/** 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; + /** 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(); + for (const sm of matches.statements) { + matchByPoint.set(`${sm.blockIndex}:${sm.statementIndex}`, sm); + } + const ctxCache = new Map(); + 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(); + const sanitizersBySite = new Map(); + 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(); + 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, + ): 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([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(); + const killsByIdentity = new Map< + string, + { kill: Omit; kinds: Set } + >(); + + 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, + ): 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(); + 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, + 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(); + 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(); + 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; viaCall: boolean } => { + let kinds: ReadonlySet | undefined; + let viaCall = false; + for (const p of paths) { + viaCall ||= p.viaCall; + if (kinds === undefined) { + kinds = p.kinds; + } else { + const inter = new Set(); + 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, + 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(); + for (const d of defs) { + if (seen.has(d)) continue; + seen.add(d); + const rdSites = ctx.resultDefSites.get(d); + let addKinds: ReadonlySet = 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(); + 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, + }; +} diff --git a/gitnexus/src/core/ingestion/taint/site-safety.ts b/gitnexus/src/core/ingestion/taint/site-safety.ts new file mode 100644 index 0000000000..f2f1777f96 --- /dev/null +++ b/gitnexus/src/core/ingestion/taint/site-safety.ts @@ -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(['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 | 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; +}; diff --git a/gitnexus/src/core/ingestion/taint/source-sink-config.ts b/gitnexus/src/core/ingestion/taint/source-sink-config.ts index 2b03bf2895..5909dcb3ed 100644 --- a/gitnexus/src/core/ingestion/taint/source-sink-config.ts +++ b/gitnexus/src/core/ingestion/taint/source-sink-config.ts @@ -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 { readonly name: string; readonly args?: readonly number[]; + /** Category label — drives finding classification and sanitizer kind-compat. */ + readonly kind: K; +} + +/** + * 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('')` 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 `.`, name-based. + */ +export interface TaintSinkEntry extends TaintCallableMatcher { + readonly module?: string; + readonly global?: boolean; + /** Only meaningful with `global`: match `new (…)` 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 `.` 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 callables introduce - * taint (sources), which are dangerous to reach with tainted input (sinks), - * and which clear taint (sanitizers). + * 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[]; } diff --git a/gitnexus/src/core/ingestion/taint/source-sink-registry.ts b/gitnexus/src/core/ingestion/taint/source-sink-registry.ts index 7fe1f12293..84785615b3 100644 --- a/gitnexus/src/core/ingestion/taint/source-sink-registry.ts +++ b/gitnexus/src/core/ingestion/taint/source-sink-registry.ts @@ -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. diff --git a/gitnexus/src/core/ingestion/taint/typescript-model.ts b/gitnexus/src/core/ingestion/taint/typescript-model.ts new file mode 100644 index 0000000000..9e08f5ce7e --- /dev/null +++ b/gitnexus/src/core/ingestion/taint/typescript-model.ts @@ -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) + .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); +} diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index 66300b8b31..542326a633 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -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, }, ); diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 025f11bb29..83e46cfeb1 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -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 { + 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::` — 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 — `;` header + ordered `variable:line` hops). + */ + private async _explainImpl( + repo: RepoHandle, + params: { target?: string; limit?: number }, + ): Promise { + 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 = {}; + 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 | 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::::` — + // 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. diff --git a/gitnexus/src/mcp/resources.ts b/gitnexus/src/mcp/resources.ts index 707a5e047c..bb900d924d 100644 --- a/gitnexus/src/mcp/resources.ts +++ b/gitnexus/src/mcp/resources.ts @@ -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'); diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index 065fa37b3e..dafdf7caf3 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -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('') 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', diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index 837144c5cd..5061e635ae 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -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}`); }; diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index 5e9fd972f8..a9bfd88a99 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -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; }; } diff --git a/gitnexus/test/helpers/taint-fixture.ts b/gitnexus/test/helpers/taint-fixture.ts new file mode 100644 index 0000000000..38337798ff --- /dev/null +++ b/gitnexus/test/helpers/taint-fixture.ts @@ -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 = { + '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 }; +} diff --git a/gitnexus/test/helpers/ts-cfg-harness.ts b/gitnexus/test/helpers/ts-cfg-harness.ts new file mode 100644 index 0000000000..12f8509c62 --- /dev/null +++ b/gitnexus/test/helpers/ts-cfg-harness.ts @@ -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); +} diff --git a/gitnexus/test/integration/cfg/__snapshots__/taint-snapshot.test.ts.snap b/gitnexus/test/integration/cfg/__snapshots__/taint-snapshot.test.ts.snap new file mode 100644 index 0000000000..0a9c9bff07 --- /dev/null +++ b/gitnexus/test/integration/cfg/__snapshots__/taint-snapshot.test.ts.snap @@ -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", + }, +] +`; diff --git a/gitnexus/test/integration/cfg/fixtures/pdg-repo/taint-cases.ts b/gitnexus/test/integration/cfg/fixtures/pdg-repo/taint-cases.ts new file mode 100644 index 0000000000..71f5f1e563 --- /dev/null +++ b/gitnexus/test/integration/cfg/fixtures/pdg-repo/taint-cases.ts @@ -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; +} diff --git a/gitnexus/test/integration/cfg/fixtures/pdg-repo/vuln.ts b/gitnexus/test/integration/cfg/fixtures/pdg-repo/vuln.ts new file mode 100644 index 0000000000..50ffa1aa46 --- /dev/null +++ b/gitnexus/test/integration/cfg/fixtures/pdg-repo/vuln.ts @@ -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); +} diff --git a/gitnexus/test/integration/cfg/pipeline-pdg.test.ts b/gitnexus/test/integration/cfg/pipeline-pdg.test.ts index 69ff6677f1..d5d8415b37 100644 --- a/gitnexus/test/integration/cfg/pipeline-pdg.test.ts +++ b/gitnexus/test/integration/cfg/pipeline-pdg.test.ts @@ -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(); + 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); }); diff --git a/gitnexus/test/integration/cfg/taint-snapshot.test.ts b/gitnexus/test/integration/cfg/taint-snapshot.test.ts new file mode 100644 index 0000000000..adeb725867 --- /dev/null +++ b/gitnexus/test/integration/cfg/taint-snapshot.test.ts @@ -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 { + 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); + }); +}); diff --git a/gitnexus/test/integration/cfg/worker-roundtrip.test.ts b/gitnexus/test/integration/cfg/worker-roundtrip.test.ts index c46a425aec..e61112ee0d 100644 --- a/gitnexus/test/integration/cfg/worker-roundtrip.test.ts +++ b/gitnexus/test/integration/cfg/worker-roundtrip.test.ts @@ -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=` — 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 }), + ); + }); +}); diff --git a/gitnexus/test/integration/taint-explain.test.ts b/gitnexus/test/integration/taint-explain.test.ts new file mode 100644 index 0000000000..6cda28eaca --- /dev/null +++ b/gitnexus/test/integration/taint-explain.test.ts @@ -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, + * `;` 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(); + 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 }> = []; + 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, + ); + } + 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; + }, + }, +); diff --git a/gitnexus/test/unit/cfg/harvest.test.ts b/gitnexus/test/unit/cfg/harvest.test.ts index 6fccf95bb1..7c8e3a8232 100644 --- a/gitnexus/test/unit/cfg/harvest.test.ts +++ b/gitnexus/test/unit/cfg/harvest.test.ts @@ -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')]]); + }); +}); diff --git a/gitnexus/test/unit/pdg-mode-flip.test.ts b/gitnexus/test/unit/pdg-mode-flip.test.ts index f258ecb8a0..0441464f15 100644 --- a/gitnexus/test/unit/pdg-mode-flip.test.ts +++ b/gitnexus/test/unit/pdg-mode-flip.test.ts @@ -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). diff --git a/gitnexus/test/unit/run-analyze.test.ts b/gitnexus/test/unit/run-analyze.test.ts index 9790f337cb..7c9e2a3e5d 100644 --- a/gitnexus/test/unit/run-analyze.test.ts +++ b/gitnexus/test/unit/run-analyze.test.ts @@ -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 + ); }); }); diff --git a/gitnexus/test/unit/security.test.ts b/gitnexus/test/unit/security.test.ts index 0474fbf220..514df10784 100644 --- a/gitnexus/test/unit/security.test.ts +++ b/gitnexus/test/unit/security.test.ts @@ -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 ─────────────────────────────────────────────── diff --git a/gitnexus/test/unit/taint/model-match.test.ts b/gitnexus/test/unit/taint/model-match.test.ts new file mode 100644 index 0000000000..374961d278 --- /dev/null +++ b/gitnexus/test/unit/taint/model-match.test.ts @@ -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); + }); +}); diff --git a/gitnexus/test/unit/taint/path-codec.test.ts b/gitnexus/test/unit/taint/path-codec.test.ts new file mode 100644 index 0000000000..a7b8918443 --- /dev/null +++ b/gitnexus/test/unit/taint/path-codec.test.ts @@ -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 (; — 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 }); + }); +}); diff --git a/gitnexus/test/unit/taint/propagate.test.ts b/gitnexus/test/unit/taint/propagate.test.ts new file mode 100644 index 0000000000..2e8f9a549b --- /dev/null +++ b/gitnexus/test/unit/taint/propagate.test.ts @@ -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))); + }); +}); diff --git a/gitnexus/test/unit/taint/site-safety.test.ts b/gitnexus/test/unit/taint/site-safety.test.ts new file mode 100644 index 0000000000..ec92e0e554 --- /dev/null +++ b/gitnexus/test/unit/taint/site-safety.test.ts @@ -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) => 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); + 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; + 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); + }); +}); diff --git a/gitnexus/test/unit/taint/source-sink-registry.test.ts b/gitnexus/test/unit/taint/source-sink-registry.test.ts index 43b85d9705..783a663fed 100644 --- a/gitnexus/test/unit/taint/source-sink-registry.test.ts +++ b/gitnexus/test/unit/taint/source-sink-registry.test.ts @@ -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); diff --git a/gitnexus/test/unit/taint/taint-emit.test.ts b/gitnexus/test/unit/taint/taint-emit.test.ts new file mode 100644 index 0000000000..7b73d0e69a --- /dev/null +++ b/gitnexus/test/unit/taint/taint-emit.test.ts @@ -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 { + const ids = new Set(); + 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 `;` 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']); + }); +}); diff --git a/gitnexus/test/unit/tools.test.ts b/gitnexus/test/unit/tools.test.ts index 40376961b6..298a2ee5cd 100644 --- a/gitnexus/test/unit/tools.test.ts +++ b/gitnexus/test/unit/tools.test.ts @@ -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();