GitNexus/gitnexus/test/unit/taint/site-safety.test.ts
Gergő Magyar 14397dd4aa
feat(taint): intra-procedural taint analysis (#2083) (#2164)
* feat(taint): harvest occurrence-tagged call/member sites on StatementFacts (#2083 U1)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Add NaN, Infinity, -Infinity, and a numeric string to the out-of-bounds
limit cases — a regression fence over the interpolated LIMIT, confirming
the Number.isInteger guard rejects every non-integer/non-finite/string
input before it reaches the query.
2026-06-12 07:35:09 +01:00

172 lines
7.4 KiB
TypeScript

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