mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-10 03:27:59 +00:00
* feat(taint): harvest occurrence-tagged call/member sites on StatementFacts (#2083 U1) Worker-side site harvest in TsHarvester: call/new/member-read records with dotted callee paths, receiver slots, per-argument occurrence tagging with nested-site links, per-declarator resultDefs, spread/template/require-literal markers. hasTaintSafeSites validation seam. The pdg parse-cache chunk-key namespace is versioned (pdg:1 -> pdg:2) instead of a global SCHEMA_BUMP so flag-off users keep warm caches; bench fingerprints re-baselined for the three call-bearing scenarios (straight-line/dense-bindings byte-unchanged). * feat(taint): built-in TS/JS source/sink/sanitizer model + site matcher (#2083 U2) Typed spec (kind taxonomy; sanitizers carry neutralizes-kinds), the canonical Express/Node model, and matchFunctionSites: ESM alias/namespace + require- literal callee resolution, bare-name fallback restricted to true globals, sanitizers module-or-global only (never user-shadowable by name), spread/ template arg-position rules, deterministic taintModelVersion. * feat(taint): pure intra-procedural taint propagation engine (#2083 U3) Two-rule model (statement-local + du-fact worklist) with per-taint neutralized-kind exclusion sets: sanitizers exclude only the sink kinds they neutralize (escape(req.body) suppresses res.send but still fires db.query; exec(path.basename(t)) fires), intersection-over-paths so a bypass occurrence keeps the taint live, kill locality on resultDefs, propagate-through args+receiver with viaCall hops, one path per finding, deterministic caps, coverage-gap statuses. Test-first: 38 scenarios on real harvested CFGs. * feat(taint): thread taint caps + model version through pdg config/meta (#2083 U5) resolvePdgConfig gains maxTaintFindingsPerFunction (200), maxTaintHops (32), and the taintModelVersion digest; RepoMeta.pdg + RunScopeResolutionInput surfaces added. The key-union comparator trips full writeback on M2->M3 upgrade and on model-version change without --force (mode-flip tested). No CLI flags or rc keys (programmatic parity with the other caps). * feat(taint): in-phase taint emit with sparse TAINTED/SANITIZES edges (#2083 U4) run.ts pdg window: match-first fast path (solver only when a function has both a matched source and sink) -> computeReachingDefs with the shared RD fact derivation -> computeTaintFlows -> per-finding TAINTED (versioned hop-encoded reason via the shared path codec, statement-level occurrence identity) + per-kill SANITIZES, dedup-before-budget, truncate-and-warn. All emit counters surfaced (aggregate warn for gaps/drops, debug for volume); PROF gains taint=. Flag-off golden untouched. * feat(mcp): explain tool for persisted taint findings (#2083 U6) Anchorless calls enumerate the sparse TAINTED table (bounded, deterministic, limit-clamped); anchored calls (file or symbol via resolveSymbolCandidates) return full decoded hop detail. sinkKind rides a version-1 codec header (1;<kind>|hops — no other persisted channel exists; U4/U6 ship together). RepoMeta.pdg probe yields a no-taint-layer note instead of an error. TAINTED/SANITIZES pinned OUT of VALID_RELATION_TYPES (KTD9a negative- membership tests); generators + canonical skill docs + mirrors updated. * test(taint): acceptance fixture battery, snapshots, and bench gates (#2083 U7) pdg-repo taint-cases fixtures complete the six plan shapes; committed findings/kills snapshot via a shared pure-path harness that also feeds the AE2 exact-equality assertion (stored TAINTED == pure-path findings, the no-explosion gate). New taint-dense bench scenario with four --check gates: per-function findings pinned AT the cap, absolute reason-byte + site-bytes disk ceilings (the load-bearing R10 gate), zero-match pass < 0.5x match- dense, N-linearity. Pre-existing scenario baselines untouched. * refactor(taint): share one pointKey helper across propagate + emit (#2083 review) Extract pointKey(ProgramPoint) to cfg/reaching-defs.ts (colon-separated, matching the codebase block:stmt id convention) and import it in both propagate.ts and emit.ts, replacing the two divergent locals (':' vs '.'). Edge-id material now uses the colon form; ids are in-memory only and no test asserts the pointKey segment shape. * fix(taint): discriminate taint state by source occurrence (#2083 review) Two distinct sources flowing into one variable at one def point no longer collapse to a single TAINTED edge: the taint-state key gains a root source-occurrence discriminator ({point, siteIndex} — the same fields recordFinding's identity uses, excluding kind). Def->use fact lookup keys on the source-independent (binding, def-point) portion. Same-source multi-path flows still share one state so their exclusion sets intersect (the raw arm soundly wins); termination holds (finite keys, monotone shrink, no cross-source ping-pong). Restores the KTD6 identity contract. * fix(mcp): route dotted symbol names in explain to symbol resolution (#2083 review) The fileish classifier matched any dotted name (UserController.create) as a file via its extension-like suffix, so symbol resolution never ran and the tool returned a silent empty file-anchored result. Tighten the classifier to require a path separator or a real source extension (derived from the resolver's EXTENSIONS list, multi-language), so dotted/bare names route to resolveSymbolCandidates (found / ambiguous / not-found). * fix(mcp): gate explain no-taint-layer note on taintModelVersion (#2083 review) An M1/M2-era --pdg index has meta.pdg defined (BasicBlock/REACHING_DEF recorded) but no taintModelVersion and zero TAINTED rows. The probe keyed on generic meta.pdg presence, so explain returned the generic empty note instead of the actionable 'no taint layer — run analyze' hint. Gate on meta.pdg?.taintModelVersion (the field M3 stamps) so an M2-era index gets the layer hint; a taint-stamped index with no findings still gets the generic note. * fix(taint): sequence-expression value flows only the final operand (#2083 review) A comma expression in value position (exec((log(x), 'safe'))) default- descended, fanning every operand's occurrences into the enclosing sink argument — over-tainting exec's arg 0 with x. Add an explicit walkValue case that records earlier operands' uses with occurrence fan-out suppressed (new FactAccumulator.suppressOccurrences) and routes only the last operand through the value path. Sites-layer only; defs/uses/mayDefs byte-identical (cfg + reaching-defs snapshots unchanged). * perf(taint): FIFO head-cursor worklist + dedup before chainHops (#2083 review) Replace queue.shift() (O(N) dequeue) with a strict-FIFO head cursor plus order-preserving prefix reclamation; FIFO is load-bearing because chainHops reads the live taints map whose parent/source/viaCall are rewritten order-sensitively on monotone shrink, so hop determinism is dequeue-order contingent. Extract findingKey() and dedup-check before chainHops in the justify branch — already-recorded identities discard their hop chain (first write wins), so the ancestry walk was pure waste. The else kill branch is untouched. Findings + hops byte-identical (snapshot unchanged). * perf(taint): O(1) member-read dedup via composite-key set (#2083 review) addMemberRead rescanned the whole per-statement sites array per call to dedup by (object, property, parent) — O(n^2) on member-read-dense statements. Track a composite-key Set alongside sites for O(1) dedup. (The require-literal join is already O(sites) with a no-op body on non-require sites, so no early-exit is needed there.) Behavior identical: harvest + model-match + taint snapshots unchanged. * refactor(taint): drop test-only export; source taint caps via emit.ts (#2083 review) Remove the sanitizerNeutralizes export (its only consumers were two test assertions — inlined to entry.neutralizes membership). Re-export the DEFAULT_PDG_MAX_TAINT_* caps from emit.ts and point run.ts at emit.ts, so the pipeline's taint dependency surface is the single orchestration module rather than reaching into propagate.ts. * test(taint): extract the shared TS CFG/taint test harness (#2083 review) The parse/collectFunctions/cfgOf/cfgsOf/importsFor harness was copied byte-for-byte across four suites (harvest, model-match, propagate, taint-emit). Promote it to test/helpers/ts-cfg-harness.ts and import it. site-safety/reaching-defs carry a structurally different inlined builder and are left as-is. Pure extraction, no assertion changes. * test(mcp): harden explain limit-rejection battery (#2083 review) Add NaN, Infinity, -Infinity, and a numeric string to the out-of-bounds limit cases — a regression fence over the interpolated LIMIT, confirming the Number.isInteger guard rejects every non-integer/non-finite/string input before it reaches the query.
This commit is contained in:
parent
28f3b99822
commit
14397dd4aa
48 changed files with 6725 additions and 96 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -654,6 +654,9 @@ const renderSkillMarkdown = (
|
|||
`2. \`query({query: "${community.label.toLowerCase()}"})\` \u2014 find related execution flows`,
|
||||
);
|
||||
lines.push('3. Read key files listed above for implementation details');
|
||||
lines.push(
|
||||
'4. `explain({target: "<file or symbol>"})` — persisted taint findings (source→sink data flows), when indexed with `--pdg`',
|
||||
);
|
||||
lines.push('');
|
||||
|
||||
return lines.join('\n');
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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}. */
|
||||
|
|
|
|||
|
|
@ -42,6 +42,92 @@ export interface BindingEntry {
|
|||
readonly synthetic?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* One occurrence of a binding inside a call/new site's argument position
|
||||
* (#2083 M3 U1). A bare `number` is a DIRECT occurrence (binding index into
|
||||
* {@link FunctionCfg.bindings}); a `[bindingIdx, viaSiteIdx]` tuple marks an
|
||||
* occurrence that reaches this argument THROUGH the nested site at
|
||||
* `viaSiteIdx` (an index into the SAME statement's {@link StatementFacts.sites}
|
||||
* array). The tag is load-bearing for sanitizer interposition (plan KTD4a):
|
||||
* a flat per-arg binding set cannot distinguish `exec(escape(x))` (kill) from
|
||||
* `exec(x)` (finding) — the single most common safe pattern would
|
||||
* false-positive without it.
|
||||
*/
|
||||
export type SiteArgOccurrence = number | readonly [number, number];
|
||||
|
||||
/**
|
||||
* One call site, constructor call, or value-position member read harvested
|
||||
* from a statement (#2083 M3 U1, plan KTD2). Worker-side substrate for the M3
|
||||
* taint pass: the M2 facts carry no expression structure, and the main thread
|
||||
* cannot re-parse (the #1983 OOM shape). Spec-AGNOSTIC — records structure
|
||||
* only, never source/sink/sanitizer-ness (matching is a main-thread concern).
|
||||
*
|
||||
* Integer indices: binding fields (`receiver`/`object`/`resultDefs`/arg
|
||||
* occurrences) index {@link FunctionCfg.bindings}; site references (`parent`,
|
||||
* via-tags) index the OWNING statement's `sites` array. JSON-plain; NO field
|
||||
* here may be named `nodeId` (durable parsedfile-store reviver hazard — see
|
||||
* {@link BindingEntry}).
|
||||
*/
|
||||
export interface SiteRecord {
|
||||
readonly kind: 'call' | 'new' | 'member-read';
|
||||
/**
|
||||
* Dotted callee path for call/new sites whose callee chain is rooted at an
|
||||
* identifier/`this`/`super` (`child_process.exec`, `req.body.toString`).
|
||||
* Optional chaining is normalized (`a?.b()` ⇒ `a.b`); string-literal
|
||||
* subscripts fold into the path (`cp["exec"]` ⇒ `cp.exec`). Absent when the
|
||||
* chain is not statically resolvable (dynamic key, call-rooted chain).
|
||||
*/
|
||||
readonly callee?: string;
|
||||
/**
|
||||
* Binding index of the callee chain's ROOT identifier when the callee is a
|
||||
* member chain (`userInput.trim()` ⇒ `userInput`). Method calls launder
|
||||
* taint without it (plan KTD5 receiver-position TITO). Absent for bare
|
||||
* calls (`exec(x)`) and non-identifier roots.
|
||||
*/
|
||||
readonly receiver?: number;
|
||||
/**
|
||||
* Per-argument-position occurrence entries (trailing empty positions are
|
||||
* trimmed; absent when no argument carries a binding occurrence). For
|
||||
* `template: true` sites every substitution occurrence aggregates at
|
||||
* position 0 (tagged templates have no positional argument list).
|
||||
*/
|
||||
readonly args?: ReadonlyArray<readonly SiteArgOccurrence[]>;
|
||||
/**
|
||||
* Bindings defined by a declarator/assignment whose ENTIRE value (after
|
||||
* unwrapping parens/`await`/`as`/`!`) is this call — `const b = escape(t)`
|
||||
* ⇒ `[b]`. Per-declarator: `const a = t, b = escape(t)` attaches `[b]`
|
||||
* only. Kill placement (KTD4b) keys on this: a sanitizer kills exactly the
|
||||
* defs that receive its result directly.
|
||||
*/
|
||||
readonly resultDefs?: readonly number[];
|
||||
/**
|
||||
* `[siteIdx, argIdx]` of the innermost enclosing call/new site argument
|
||||
* position this site occurs in (`exec(escape(x))` ⇒ escape's parent is
|
||||
* `[execSiteIdx, 0]`). Absent for top-level sites.
|
||||
*/
|
||||
readonly parent?: readonly [number, number];
|
||||
/**
|
||||
* Index of the FIRST spread argument (`exec(...args)` ⇒ 0). Presence means
|
||||
* position matching must degrade soundly (any sink position ≥ this index —
|
||||
* plan KTD2/U2). A number (not boolean) because the matcher needs the index.
|
||||
*/
|
||||
readonly spread?: number;
|
||||
/** Tagged-template call (`sql\`…${id}\``) — argument positions are not positional. */
|
||||
readonly template?: boolean;
|
||||
/**
|
||||
* String-literal first argument when the callee is bare `require` —
|
||||
* CommonJS aliases resolve like ESM imports on the main thread (KTD7).
|
||||
*/
|
||||
readonly requireArg?: string;
|
||||
/** Member read: binding index of the object root (`req.body` ⇒ `req`). */
|
||||
readonly object?: number;
|
||||
/**
|
||||
* Member read: property name (`req.body` ⇒ `'body'`; `req["body"]`
|
||||
* included; dynamic `req[key]` is never recorded — documented KTD10 FN).
|
||||
*/
|
||||
readonly property?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Def/use facts for one harvested statement (or construct header), in
|
||||
* execution order within its block (#2082 M2 U1). `defs`/`uses` are indices
|
||||
|
|
@ -57,12 +143,19 @@ export interface BindingEntry {
|
|||
* treating them as must-defs would falsely kill the prior def on the
|
||||
* not-taken path (a taint false negative on core JS idioms). Optional —
|
||||
* absent means none.
|
||||
*
|
||||
* `sites` (#2083 M3 U1): call/member-read structure for the taint pass —
|
||||
* see {@link SiteRecord}. Optional and omit-when-empty; absent on pre-M3
|
||||
* channels and on statements with no calls or member reads. Sites inside
|
||||
* nested functions are NOT recorded (consistent with def/use invisibility —
|
||||
* the enclosing `arr.forEach(...)` call IS, with receiver `arr`).
|
||||
*/
|
||||
export interface StatementFacts {
|
||||
readonly line: number;
|
||||
readonly defs: readonly number[];
|
||||
readonly uses: readonly number[];
|
||||
readonly mayDefs?: readonly number[];
|
||||
readonly sites?: readonly SiteRecord[];
|
||||
}
|
||||
|
||||
/** A basic block: a maximal straight-line run of statements between leaders. */
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@
|
|||
* parsedfile-store reviver dedups objects keyed on that field name.
|
||||
*/
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import type { BindingEntry, StatementFacts } from '../types.js';
|
||||
import type { BindingEntry, SiteArgOccurrence, SiteRecord, StatementFacts } from '../types.js';
|
||||
|
||||
/** Node types that own a nested CFG — their subtrees are opaque to harvesting. */
|
||||
const NESTED_FUNCTION_TYPES = new Set([
|
||||
|
|
@ -83,6 +83,31 @@ const TYPE_CONTEXT_TYPES = new Set([
|
|||
'asserts_annotation',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Wrappers that don't change which VALUE flows through them (#2083 M3 U1) —
|
||||
* unwrapped when resolving call-result attribution (`const b = (await
|
||||
* escape(t))!` still attaches `resultDefs: [b]` to the escape site) and
|
||||
* member-chain roots. Distinct from {@link TsHarvester.unwrapLvalue}, which is
|
||||
* the narrower LVALUE set.
|
||||
*/
|
||||
const VALUE_WRAPPER_TYPES = new Set([
|
||||
'parenthesized_expression',
|
||||
'non_null_expression',
|
||||
'as_expression',
|
||||
'satisfies_expression',
|
||||
'await_expression',
|
||||
]);
|
||||
|
||||
/** Literal text of a `string` node (concatenated fragments; raw escapes kept). */
|
||||
const stringLiteralText = (node: SyntaxNode): string => {
|
||||
let out = '';
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const c = node.namedChild(i);
|
||||
if (c?.type === 'string_fragment' || c?.type === 'escape_sequence') out += c.text;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
interface Scope {
|
||||
readonly parent: Scope | null;
|
||||
/** name → binding index */
|
||||
|
|
@ -112,6 +137,16 @@ export class TsHarvester {
|
|||
* here falsely kills the prior def on the not-taken path).
|
||||
*/
|
||||
private conditionalDepth = 0;
|
||||
/**
|
||||
* Call/new node id → bindings whose declarator/assignment VALUE is exactly
|
||||
* that call (#2083 M3 U1). Registered by the declarator/assignment handlers
|
||||
* BEFORE the value walk, consumed by {@link visitCall} when it reaches the
|
||||
* node — the indirection keeps result-def attribution per-declarator
|
||||
* (`const a = t, b = escape(t)` attaches `[b]` to the escape site only) and
|
||||
* top-level-only (`const c = cond ? escape(b) : b` attaches nothing — the
|
||||
* bypass occurrence must keep `c` taintable, plan KTD4a).
|
||||
*/
|
||||
private readonly resultDefTargets = new Map<number, number[]>();
|
||||
|
||||
constructor(private readonly fnNode: SyntaxNode) {
|
||||
this.fnId = fnNode.id;
|
||||
|
|
@ -458,7 +493,9 @@ export class TsHarvester {
|
|||
// live def (`x = source(); var x; sink(x)` must keep source→sink;
|
||||
// tri-review P2). `let`/`const` declarators genuinely initialize.
|
||||
if (name && (value || t === 'lexical_declaration')) {
|
||||
const snap = acc.defSnapshot();
|
||||
this.walkDefPattern(name, acc);
|
||||
if (value) this.registerResultDefs(value, acc.defsSince(snap));
|
||||
}
|
||||
if (value) this.walkValue(value, acc);
|
||||
}
|
||||
|
|
@ -466,7 +503,11 @@ export class TsHarvester {
|
|||
case 'assignment_expression': {
|
||||
const left = node.childForFieldName('left');
|
||||
const right = node.childForFieldName('right');
|
||||
if (left) this.walkDefPattern(this.unwrapLvalue(left), acc);
|
||||
if (left) {
|
||||
const snap = acc.defSnapshot();
|
||||
this.walkDefPattern(this.unwrapLvalue(left), acc);
|
||||
if (right) this.registerResultDefs(right, acc.defsSince(snap));
|
||||
}
|
||||
if (right) this.walkValue(right, acc);
|
||||
return;
|
||||
}
|
||||
|
|
@ -552,6 +593,39 @@ export class TsHarvester {
|
|||
if (body) this.walkValue(body, acc);
|
||||
return;
|
||||
}
|
||||
case 'call_expression':
|
||||
// #2083 M3 U1: explicit case (previously default-descended) — same
|
||||
// uses, plus a taint-site record. MUST keep defs/uses byte-identical.
|
||||
this.visitCall(node, acc, 'call');
|
||||
return;
|
||||
case 'new_expression':
|
||||
this.visitCall(node, acc, 'new');
|
||||
return;
|
||||
case 'member_expression':
|
||||
case 'subscript_expression':
|
||||
// #2083 M3 U1: value-position member chain — same uses as the old
|
||||
// default descent (root identifier + dynamic subscript indices), plus
|
||||
// a member-read site for the innermost identifier-rooted access.
|
||||
this.walkChain(node, acc, false);
|
||||
return;
|
||||
case 'sequence_expression': {
|
||||
// Comma operator: only the LAST operand's value flows. Earlier operands
|
||||
// are evaluated for side effects — record their uses but suppress
|
||||
// occurrence fan-out so `exec((log(x), 'safe'))` does not taint exec's
|
||||
// arg 0 with `x` (review fix). Defs/uses stay byte-identical to the old
|
||||
// default descent; only the sites layer narrows.
|
||||
const operands: SyntaxNode[] = [];
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const c = node.namedChild(i);
|
||||
if (c) operands.push(c);
|
||||
}
|
||||
const last = operands.length - 1;
|
||||
operands.forEach((op, i) => {
|
||||
if (i === last) this.walkValue(op, acc);
|
||||
else acc.suppressOccurrences(() => this.walkValue(op, acc));
|
||||
});
|
||||
return;
|
||||
}
|
||||
default:
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const c = node.namedChild(i);
|
||||
|
|
@ -593,8 +667,11 @@ export class TsHarvester {
|
|||
case 'member_expression':
|
||||
case 'subscript_expression':
|
||||
// Property/element write — NOT a scalar def (KTD4); its identifiers
|
||||
// (object, computed key) are uses.
|
||||
this.walkValue(node, acc);
|
||||
// (object, computed key) are uses. WRITE position (#2083 M3 U1): the
|
||||
// written access itself is not a value read — no member-read site for
|
||||
// it (`obj.p = q` records nothing; `req.body.x = v`'s mid-chain LOAD
|
||||
// of `req.body` still does).
|
||||
this.walkChain(node, acc, true);
|
||||
return;
|
||||
default:
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
|
|
@ -603,6 +680,201 @@ export class TsHarvester {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── taint-site harvest (#2083 M3 U1) ────────────────────────────────────
|
||||
|
||||
/** Strip value-transparent wrappers (`(x)`, `x!`, `x as T`, `await x`). */
|
||||
private unwrapValueWrappers(node: SyntaxNode): SyntaxNode {
|
||||
let n = node;
|
||||
while (VALUE_WRAPPER_TYPES.has(n.type)) {
|
||||
const inner = n.namedChild(0);
|
||||
if (!inner) break;
|
||||
n = inner;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* When `value`'s root (after unwrapping) is a call/new node, remember that
|
||||
* its site should carry `resultDefs: defs` — consumed by {@link visitCall}
|
||||
* once the value walk reaches the node.
|
||||
*/
|
||||
private registerResultDefs(value: SyntaxNode, defs: readonly number[]): void {
|
||||
if (defs.length === 0) return;
|
||||
const root = this.unwrapValueWrappers(value);
|
||||
if (root.type === 'call_expression' || root.type === 'new_expression') {
|
||||
this.resultDefTargets.set(root.id, [...defs]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit call/new handler: records a call site (callee path, receiver,
|
||||
* per-arg occurrence entries, spread/template markers, require literal,
|
||||
* result defs) while reproducing EXACTLY the uses the old default descent
|
||||
* recorded — callee chain root + dynamic subscript indices + arguments.
|
||||
*/
|
||||
private visitCall(node: SyntaxNode, acc: FactAccumulator, kind: 'call' | 'new'): void {
|
||||
const calleeNode = node.childForFieldName(kind === 'new' ? 'constructor' : 'function');
|
||||
const argsNode = node.childForFieldName('arguments');
|
||||
const siteIdx = acc.openCallSite(kind);
|
||||
acc.pushFrame(siteIdx);
|
||||
let calleePath: string | undefined;
|
||||
if (calleeNode) {
|
||||
const callee = this.unwrapValueWrappers(calleeNode);
|
||||
if (callee.type === 'identifier') {
|
||||
// The callee NAME is a statement-level use but NOT a value occurrence
|
||||
// flowing into any enclosing argument — `exec(escape(x))` must not
|
||||
// put the `escape` binding itself into exec's arg 0 (only x, tagged
|
||||
// via the escape site). Receiver-chain roots DO fan out (KTD5 TITO).
|
||||
acc.addUseWithoutOccurrence(this.resolve(callee));
|
||||
calleePath = callee.text;
|
||||
} else if (callee.type === 'member_expression' || callee.type === 'subscript_expression') {
|
||||
// skipFinalRead: the final access IS the callee, carried by the
|
||||
// dotted path — recording it as a member read would double-count.
|
||||
// Mid-chain reads (`req.body` inside `req.body.toString()`) ARE
|
||||
// recorded (plan KTD2).
|
||||
const chain = this.walkChain(callee, acc, true);
|
||||
calleePath = chain.path;
|
||||
if (chain.rootIdx !== undefined) acc.setSiteReceiver(siteIdx, chain.rootIdx);
|
||||
} else {
|
||||
// Call-rooted chains, IIFEs, function expressions — no dotted path;
|
||||
// the walk still records uses and nested sites.
|
||||
this.walkValue(callee, acc);
|
||||
}
|
||||
if (calleePath !== undefined) acc.setSiteCallee(siteIdx, calleePath);
|
||||
}
|
||||
const resultDefs = this.resultDefTargets.get(node.id);
|
||||
if (resultDefs !== undefined) acc.setSiteResultDefs(siteIdx, resultDefs);
|
||||
if (argsNode?.type === 'template_string') {
|
||||
// Tagged template (`sql\`…${id}\``): the `arguments` field is a
|
||||
// template_string, not an arguments node — substitution occurrences
|
||||
// aggregate at position 0 and the site is marked non-positional.
|
||||
acc.setSiteTemplate(siteIdx);
|
||||
acc.setFrameArg(0);
|
||||
this.walkValue(argsNode, acc);
|
||||
} else if (argsNode) {
|
||||
let pos = 0;
|
||||
for (let i = 0; i < argsNode.namedChildCount; i++) {
|
||||
const arg = argsNode.namedChild(i);
|
||||
if (!arg || arg.type === 'comment') continue;
|
||||
acc.setFrameArg(pos);
|
||||
if (arg.type === 'spread_element') {
|
||||
acc.setSiteSpread(siteIdx, pos);
|
||||
const inner = arg.namedChild(0);
|
||||
if (inner) this.walkValue(inner, acc);
|
||||
} else {
|
||||
if (kind === 'call' && pos === 0 && calleePath === 'require' && arg.type === 'string') {
|
||||
// CommonJS `require('lit')` — record the literal so the matcher
|
||||
// resolves require'd aliases like ESM imports (plan KTD7).
|
||||
acc.setSiteRequireArg(siteIdx, stringLiteralText(arg));
|
||||
}
|
||||
this.walkValue(arg, acc);
|
||||
}
|
||||
pos++;
|
||||
}
|
||||
}
|
||||
acc.popFrame();
|
||||
}
|
||||
|
||||
/**
|
||||
* Member/subscript chain walk shared by value position, write position, and
|
||||
* callee position. Use-recording is identical to the old default descent
|
||||
* (chain-root identifier once, dynamic subscript index expressions, full
|
||||
* walk of non-identifier roots) — NO double-recording. Member-read sites:
|
||||
* at most ONE per chain — the INNERMOST access — and only when the chain
|
||||
* root is an identifier and the access's key is static (`.prop` or a
|
||||
* string-literal subscript); `skipFinalRead` suppresses it when that access
|
||||
* is the final one (callee / write target). Optional chaining (`?.`) never
|
||||
* appears in the output (field-based traversal normalizes it); dynamic
|
||||
* computed keys record nothing (documented KTD10 FN).
|
||||
*/
|
||||
private walkChain(
|
||||
node: SyntaxNode,
|
||||
acc: FactAccumulator,
|
||||
skipFinalRead: boolean,
|
||||
): { path?: string; rootIdx?: number } {
|
||||
// Collect accesses outer→inner (unshift), then resolve the root.
|
||||
const accesses: Array<{ prop?: string; dynamicIndex?: SyntaxNode }> = [];
|
||||
let cur: SyntaxNode = this.unwrapValueWrappers(node);
|
||||
for (;;) {
|
||||
if (cur.type === 'member_expression') {
|
||||
const prop = cur.childForFieldName('property');
|
||||
accesses.unshift({ prop: prop?.text });
|
||||
const obj = cur.childForFieldName('object');
|
||||
if (!obj) break;
|
||||
cur = this.unwrapValueWrappers(obj);
|
||||
} else if (cur.type === 'subscript_expression') {
|
||||
const index = cur.childForFieldName('index');
|
||||
if (index?.type === 'string') {
|
||||
accesses.unshift({ prop: stringLiteralText(index) });
|
||||
} else {
|
||||
accesses.unshift({ dynamicIndex: index ?? undefined });
|
||||
}
|
||||
const obj = cur.childForFieldName('object');
|
||||
if (!obj) break;
|
||||
cur = this.unwrapValueWrappers(obj);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let rootIdx: number | undefined;
|
||||
let rootSegment: string | undefined;
|
||||
if (cur.type === 'identifier') {
|
||||
rootIdx = this.resolve(cur);
|
||||
acc.addUse(rootIdx);
|
||||
rootSegment = cur.text;
|
||||
} else if (cur.type === 'this' || cur.type === 'super') {
|
||||
rootSegment = cur.text; // path segment only — `this`/`super` never bind
|
||||
} else {
|
||||
this.walkValue(cur, acc); // call-rooted etc. — uses + nested sites
|
||||
}
|
||||
// Dynamic subscript index expressions are real value reads (old default
|
||||
// descent walked them) — inner→outer matches the old recording order.
|
||||
for (const a of accesses) {
|
||||
if (a.dynamicIndex) this.walkValue(a.dynamicIndex, acc);
|
||||
}
|
||||
const innermost = accesses[0];
|
||||
if (
|
||||
rootIdx !== undefined &&
|
||||
innermost?.prop !== undefined &&
|
||||
!(skipFinalRead && accesses.length === 1)
|
||||
) {
|
||||
acc.addMemberRead(rootIdx, innermost.prop);
|
||||
}
|
||||
const path =
|
||||
rootSegment !== undefined && accesses.every((a) => a.prop !== undefined)
|
||||
? [rootSegment, ...accesses.map((a) => a.prop as string)].join('.')
|
||||
: undefined;
|
||||
return { path, rootIdx };
|
||||
}
|
||||
}
|
||||
|
||||
/** Mutable build-time view of a {@link SiteRecord}. */
|
||||
interface MutableSite {
|
||||
kind: SiteRecord['kind'];
|
||||
parent?: [number, number];
|
||||
callee?: string;
|
||||
receiver?: number;
|
||||
args?: SiteArgOccurrence[][];
|
||||
resultDefs?: number[];
|
||||
spread?: number;
|
||||
template?: boolean;
|
||||
requireArg?: string;
|
||||
object?: number;
|
||||
property?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One open call/new site during the walk (#2083 M3 U1). `argIdx` is the
|
||||
* argument position currently being walked, or -1 while outside any argument
|
||||
* (callee walk) — occurrences recorded then do NOT land in this frame's args
|
||||
* (they still fan out to enclosing arg-active frames, via-tagged through this
|
||||
* frame's site: the receiver of a nested call flows into the outer argument
|
||||
* through that call).
|
||||
*/
|
||||
interface SiteFrame {
|
||||
siteIdx: number;
|
||||
argIdx: number;
|
||||
}
|
||||
|
||||
/** Ordered, deduplicating def/use collector for one statement record. */
|
||||
|
|
@ -613,6 +885,13 @@ class FactAccumulator {
|
|||
private readonly defSeen = new Set<number>();
|
||||
private readonly useSeen = new Set<number>();
|
||||
private readonly mayDefSeen = new Set<number>();
|
||||
/** Taint sites recorded for this statement (#2083 M3 U1). */
|
||||
private readonly sites: MutableSite[] = [];
|
||||
/** Composite (object|property|parent) keys of recorded member-read sites, so
|
||||
* dedup is O(1) instead of a rescan of `sites` per read. */
|
||||
private readonly memberReadKeys = new Set<string>();
|
||||
/** Stack of open call/new sites — the occurrence fan-out targets. */
|
||||
private readonly frames: SiteFrame[] = [];
|
||||
|
||||
constructor(private readonly line: number) {}
|
||||
|
||||
|
|
@ -630,6 +909,17 @@ class FactAccumulator {
|
|||
}
|
||||
|
||||
addUse(idx: number): void {
|
||||
// Occurrence fan-out happens BEFORE the statement-level dedup: `exec(x, x)`
|
||||
// records x at BOTH arg positions even though `uses` lists it once.
|
||||
this.recordOccurrence(idx);
|
||||
this.addUseWithoutOccurrence(idx);
|
||||
}
|
||||
|
||||
/**
|
||||
* Statement-level use that is NOT a value occurrence in any open site
|
||||
* argument — bare callee names only (#2083 M3 U1, see visitCall).
|
||||
*/
|
||||
addUseWithoutOccurrence(idx: number): void {
|
||||
if (this.useSeen.has(idx)) return;
|
||||
this.useSeen.add(idx);
|
||||
this.uses.push(idx);
|
||||
|
|
@ -643,6 +933,147 @@ class FactAccumulator {
|
|||
return this.uses.length;
|
||||
}
|
||||
|
||||
// ── site machinery (#2083 M3 U1) ─────────────────────────────────────────
|
||||
|
||||
/** `[defs.length, mayDefs.length]` marker for {@link defsSince}. */
|
||||
defSnapshot(): readonly [number, number] {
|
||||
return [this.defs.length, this.mayDefs.length];
|
||||
}
|
||||
|
||||
/** Binding indices def'd (must- OR may-) since the snapshot was taken. */
|
||||
defsSince(snap: readonly [number, number]): number[] {
|
||||
return [...this.defs.slice(snap[0]), ...this.mayDefs.slice(snap[1])];
|
||||
}
|
||||
|
||||
/** Open a call/new site; parent = innermost enclosing argument position. */
|
||||
openCallSite(kind: 'call' | 'new'): number {
|
||||
const site: MutableSite = { kind };
|
||||
const parent = this.innermostArgPosition();
|
||||
if (parent) site.parent = parent;
|
||||
this.sites.push(site);
|
||||
return this.sites.length - 1;
|
||||
}
|
||||
|
||||
pushFrame(siteIdx: number): void {
|
||||
this.frames.push({ siteIdx, argIdx: -1 });
|
||||
}
|
||||
|
||||
popFrame(): void {
|
||||
this.frames.pop();
|
||||
}
|
||||
|
||||
/** Set the argument position the top frame is currently walking. */
|
||||
setFrameArg(argIdx: number): void {
|
||||
const top = this.frames[this.frames.length - 1];
|
||||
if (top) top.argIdx = argIdx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `fn` with all open arg frames temporarily detached (argIdx = -1), so
|
||||
* identifier reads inside still record USES but do NOT fan occurrences into
|
||||
* the enclosing sink-argument position. Used for the non-value operands of a
|
||||
* sequence (comma) expression — only the final operand's value flows.
|
||||
*/
|
||||
suppressOccurrences(fn: () => void): void {
|
||||
const saved = this.frames.map((f) => f.argIdx);
|
||||
for (const f of this.frames) f.argIdx = -1;
|
||||
try {
|
||||
fn();
|
||||
} finally {
|
||||
this.frames.forEach((f, i) => {
|
||||
f.argIdx = saved[i];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setSiteCallee(siteIdx: number, callee: string): void {
|
||||
this.sites[siteIdx].callee = callee;
|
||||
}
|
||||
|
||||
setSiteReceiver(siteIdx: number, receiver: number): void {
|
||||
this.sites[siteIdx].receiver = receiver;
|
||||
}
|
||||
|
||||
setSiteResultDefs(siteIdx: number, resultDefs: readonly number[]): void {
|
||||
this.sites[siteIdx].resultDefs = [...resultDefs];
|
||||
}
|
||||
|
||||
setSiteSpread(siteIdx: number, firstSpreadArg: number): void {
|
||||
const site = this.sites[siteIdx];
|
||||
if (site.spread === undefined) site.spread = firstSpreadArg;
|
||||
}
|
||||
|
||||
setSiteTemplate(siteIdx: number): void {
|
||||
this.sites[siteIdx].template = true;
|
||||
}
|
||||
|
||||
setSiteRequireArg(siteIdx: number, literal: string): void {
|
||||
this.sites[siteIdx].requireArg = literal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a value-position member read. Exact duplicates within the
|
||||
* statement (same object/property/parent position) dedup; reads at
|
||||
* DIFFERENT argument positions stay distinct (`exec(req.body, req.body)`
|
||||
* is two occurrences — KTD6 finding identity needs both).
|
||||
*/
|
||||
addMemberRead(object: number, property: string): void {
|
||||
const parent = this.innermostArgPosition();
|
||||
const dedupKey = `${object}|${property}|${parent ? `${parent[0]}:${parent[1]}` : 'top'}`;
|
||||
if (this.memberReadKeys.has(dedupKey)) return;
|
||||
this.memberReadKeys.add(dedupKey);
|
||||
const site: MutableSite = { kind: 'member-read' };
|
||||
if (parent) site.parent = parent;
|
||||
site.object = object;
|
||||
site.property = property;
|
||||
this.sites.push(site);
|
||||
}
|
||||
|
||||
private innermostArgPosition(): [number, number] | undefined {
|
||||
for (let i = this.frames.length - 1; i >= 0; i--) {
|
||||
const f = this.frames[i];
|
||||
if (f.argIdx >= 0) return [f.siteIdx, f.argIdx];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fan a binding occurrence out to every arg-active open frame. The entry is
|
||||
* via-tagged with the site of the IMMEDIATELY nested frame when one exists:
|
||||
* `exec(escape(x))` puts a plain `x` in escape's arg 0 and `[x, escapeIdx]`
|
||||
* in exec's arg 0 — the KTD4a interposition substrate.
|
||||
*/
|
||||
private recordOccurrence(idx: number): void {
|
||||
for (let i = this.frames.length - 1; i >= 0; i--) {
|
||||
const f = this.frames[i];
|
||||
if (f.argIdx < 0) continue;
|
||||
const via = i + 1 < this.frames.length ? this.frames[i + 1].siteIdx : undefined;
|
||||
this.pushArgEntry(f.siteIdx, f.argIdx, idx, via);
|
||||
}
|
||||
}
|
||||
|
||||
private pushArgEntry(
|
||||
siteIdx: number,
|
||||
argIdx: number,
|
||||
bindingIdx: number,
|
||||
via: number | undefined,
|
||||
): void {
|
||||
const site = this.sites[siteIdx];
|
||||
const args = (site.args ??= []);
|
||||
while (args.length <= argIdx) args.push([]);
|
||||
const list = args[argIdx];
|
||||
// Dedup exact (binding, via) pairs per position — `f(x + x)` is one entry;
|
||||
// `f(x + g(x))` keeps the plain AND the via-tagged entry (distinct paths).
|
||||
for (const e of list) {
|
||||
const match =
|
||||
typeof e === 'number'
|
||||
? via === undefined && e === bindingIdx
|
||||
: via !== undefined && e[0] === bindingIdx && e[1] === via;
|
||||
if (match) return;
|
||||
}
|
||||
list.push(via === undefined ? bindingIdx : [bindingIdx, via]);
|
||||
}
|
||||
|
||||
finish(): StatementFacts {
|
||||
return {
|
||||
line: this.line,
|
||||
|
|
@ -651,6 +1082,21 @@ class FactAccumulator {
|
|||
// Optional field stays absent when empty — keeps the serialized
|
||||
// side-channel payload lean (most statements have no may-defs).
|
||||
...(this.mayDefs.length > 0 ? { mayDefs: this.mayDefs } : {}),
|
||||
// Sites likewise omit-when-empty (#2083 M3 U1): flag-off runs never
|
||||
// harvest, and most fact-bearing statements carry no calls.
|
||||
...(this.sites.length > 0 ? { sites: this.sites.map(finalizeSite) } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Trim trailing empty arg positions; drop `args` entirely when all-empty. */
|
||||
const finalizeSite = (site: MutableSite): SiteRecord => {
|
||||
const args = site.args;
|
||||
if (args !== undefined) {
|
||||
let end = args.length;
|
||||
while (end > 0 && args[end - 1].length === 0) end--;
|
||||
if (end === 0) delete site.args;
|
||||
else if (end < args.length) site.args = args.slice(0, end);
|
||||
}
|
||||
return site as SiteRecord;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -354,6 +354,8 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
|
|||
pdg: ctx.options?.pdg === true,
|
||||
pdgMaxEdgesPerFunction: ctx.options?.pdgMaxEdgesPerFunction,
|
||||
pdgMaxReachingDefEdgesPerFunction: ctx.options?.pdgMaxReachingDefEdgesPerFunction,
|
||||
pdgMaxTaintFindingsPerFunction: ctx.options?.pdgMaxTaintFindingsPerFunction,
|
||||
pdgMaxTaintHops: ctx.options?.pdgMaxTaintHops,
|
||||
recordResolutionOutcome: (outcome) => {
|
||||
resolutionOutcomes.push(outcome);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)`,
|
||||
);
|
||||
|
|
|
|||
297
gitnexus/src/core/ingestion/taint/emit.ts
Normal file
297
gitnexus/src/core/ingestion/taint/emit.ts
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
/**
|
||||
* In-phase taint emission (#2083 M3 U4, plan KTD1/KTD6).
|
||||
*
|
||||
* Per-file driver for the M3 taint pass: gate → match → solve → propagate →
|
||||
* persist sparse `TAINTED` + `SANITIZES` edges. Invoked from the pdg window in
|
||||
* scope-resolution (`pipeline/run.ts`), immediately after `emitFileReachingDefs`
|
||||
* inside the SAME per-file try — per-file isolation for free (KTD1). Mirrors
|
||||
* `emitFileReachingDefs` (cfg/emit.ts) for the budget/dedup/warn discipline and
|
||||
* the telemetry-result shape.
|
||||
*
|
||||
* ## Per-function pipeline (ordering is load-bearing)
|
||||
*
|
||||
* 1. `hasTaintSafeSites` — a corrupted-store site annotation degrades to
|
||||
* SKIP-TAINT-KEEP-RD for this function (counted + warned), never a crash
|
||||
* (KTD2; the matcher/propagator dereference indices unvalidated).
|
||||
* 2. `matchFunctionSites` against the language spec (the import index is
|
||||
* built ONCE per file — imports are a file-level fact).
|
||||
* 3. ZERO-MATCH FAST PATH: the solver runs only when the function has at
|
||||
* least one matched source AND one matched sink. In a typical repo almost
|
||||
* no function has both; an unconditional second `computeReachingDefs` per
|
||||
* function would ship a near-2× solve cost to every `--pdg` user.
|
||||
* 4. `computeReachingDefs` with the taint `maxFacts` — by DEFAULT the M2
|
||||
* derived `DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION` (deliberate
|
||||
* reuse, not a new constant: the fact-materialization envelope is a
|
||||
* memory question, O(defs×uses), orthogonal to the findings cap, and M2
|
||||
* already validated exactly this envelope on the same solver in the same
|
||||
* window). The run.ts caller derives `limits.maxFacts` from the SAME
|
||||
* RD-edge-cap formula `emitFileReachingDefs` uses, so taint coverage and
|
||||
* RD coverage truncate together — a function is never `truncated` for one
|
||||
* layer and `computed` for the other.
|
||||
* 5. `computeTaintFlows` — a non-`computed` status is a per-function
|
||||
* COVERAGE GAP (R4: counted by `gapReason`, function skipped entirely,
|
||||
* never partially analyzed).
|
||||
* 6. Emit one `TAINTED` edge per finding and one `SANITIZES` edge per kill.
|
||||
* Kills are emitted even when findings are zero — a fully-sanitized
|
||||
* function's kills are exactly its evidence of safety.
|
||||
*
|
||||
* ## Identity, dedup, budget (KTD6)
|
||||
*
|
||||
* Findings carry STATEMENT-LEVEL identity — function anchor + sink kind +
|
||||
* source occurrence (point/site/object-binding/property) + sink occurrence
|
||||
* (point/site/arg/binding) — NOT the REACHING_DEF block-level key (block-pair
|
||||
* conflation would drop `exec(req.body, req.query)`'s second finding). The
|
||||
* propagation engine dedups by this exact key BEFORE its deterministic cap
|
||||
* (`maxFindingsPerFunction`) and counts the overflow; this module templates
|
||||
* the same coordinates into the edge id (binding identity via the shared
|
||||
* `bindingKey`; the free-text `property` rides LAST so it can never collide
|
||||
* into another component) and warns with the drop count on truncation.
|
||||
*
|
||||
* `reason` carries the versioned hop encoding (`taint/path-codec.ts` — U6's
|
||||
* `explain` decodes the same module) for `TAINTED`, and the killed binding's
|
||||
* plain name for `SANITIZES` (M0/S1 queryability verdict, like REACHING_DEF).
|
||||
*
|
||||
* ## Warn split (R4 vs noise)
|
||||
*
|
||||
* Unsafe-site skips and cap drops warn PER FUNCTION here (rare, actionable —
|
||||
* mirrors `emitFileReachingDefs`' malformed/cap warns). Solver coverage gaps
|
||||
* (`truncated`/`overflow`) do NOT re-warn per function: the RD layer already
|
||||
* warned for the same function with the same solver status (same `maxFacts`
|
||||
* derivation — see step 4), and a duplicate `[taint]` line per mega-function
|
||||
* would be pure spam. They are counted (+ exampled) in the result and the
|
||||
* run.ts caller aggregates them into ONE unconditional `logger.warn` per
|
||||
* language (R4) — never dropped on the floor (the M2 lesson).
|
||||
*/
|
||||
|
||||
import type { ParsedImport } from 'gitnexus-shared';
|
||||
import type { KnowledgeGraph } from '../../graph/types.js';
|
||||
import { generateId } from '../../../lib/utils.js';
|
||||
import {
|
||||
basicBlockId,
|
||||
bindingKey,
|
||||
DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION,
|
||||
} from '../cfg/emit.js';
|
||||
import { computeReachingDefs, pointKey, type ProgramPoint } from '../cfg/reaching-defs.js';
|
||||
import type { BindingEntry, FunctionCfg } from '../cfg/types.js';
|
||||
import { hasTaintSafeSites } from './site-safety.js';
|
||||
import { buildTaintImportIndex, matchFunctionSites } from './match.js';
|
||||
import {
|
||||
computeTaintFlows,
|
||||
DEFAULT_PDG_MAX_TAINT_FINDINGS_PER_FUNCTION,
|
||||
DEFAULT_PDG_MAX_TAINT_HOPS,
|
||||
} from './propagate.js';
|
||||
|
||||
// Re-exported so the pipeline (run.ts) sources the taint default caps through
|
||||
// this orchestration module rather than reaching into propagate.ts directly.
|
||||
export {
|
||||
DEFAULT_PDG_MAX_TAINT_FINDINGS_PER_FUNCTION,
|
||||
DEFAULT_PDG_MAX_TAINT_HOPS,
|
||||
} from './propagate.js';
|
||||
import { encodeTaintPath } from './path-codec.js';
|
||||
import type { SourceSinkSanitizerSpec } from './source-sink-config.js';
|
||||
|
||||
/** Cap on example anchors carried per result (aggregate-warn material, R4). */
|
||||
const MAX_EXAMPLES = 5;
|
||||
|
||||
export interface TaintEmitLimits {
|
||||
/** Per-function findings cap (post-dedup). `undefined` ⇒
|
||||
* {@link DEFAULT_PDG_MAX_TAINT_FINDINGS_PER_FUNCTION}; `0` ⇒ unlimited. */
|
||||
readonly maxFindingsPerFunction?: number;
|
||||
/** Per-finding hop cap (source-side prefix kept). `undefined` ⇒
|
||||
* {@link DEFAULT_PDG_MAX_TAINT_HOPS}; `0` ⇒ unlimited. */
|
||||
readonly maxHops?: number;
|
||||
/**
|
||||
* Solver fact-materialization cap for the taint-side `computeReachingDefs`
|
||||
* call. `undefined` ⇒ {@link DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION}
|
||||
* (the M2 derived default — see the module doc for why it is REUSED rather
|
||||
* than derived from the findings cap); `0` ⇒ unlimited.
|
||||
*/
|
||||
readonly maxFacts?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full taint-emit telemetry for one file. EVERY counter is surfaced by the
|
||||
* run.ts aggregate (the M2 emit result had two fields dropped on the floor —
|
||||
* the plan names that mistake; don't repeat it).
|
||||
*/
|
||||
export interface TaintEmitResult {
|
||||
/** Functions fully propagated (`computeTaintFlows` returned `computed`). */
|
||||
functionsAnalyzed: number;
|
||||
/** Functions skipped by the zero-match fast path (no solver call). */
|
||||
functionsSkippedNoMatch: number;
|
||||
/** Functions whose `sites` failed {@link hasTaintSafeSites} (skip-taint-keep-RD). */
|
||||
functionsSkippedUnsafeSites: number;
|
||||
/** Source+sink functions skipped on a non-`computed` solver status (R4). */
|
||||
functionsCoverageGap: { truncated: number; overflow: number; 'no-facts': number };
|
||||
/** TAINTED edges persisted. */
|
||||
findingsEmitted: number;
|
||||
/** SANITIZES edges persisted (emitted even when findings are zero). */
|
||||
killsEmitted: number;
|
||||
/** Findings dropped by the per-function cap (post-dedup), summed. */
|
||||
findingsDropped: number;
|
||||
/** Findings whose persisted hop path is a truncated prefix (hop/byte cap). */
|
||||
hopsTruncatedFindings: number;
|
||||
/** ≤{@link MAX_EXAMPLES} `file:line` anchors of gap/unsafe-site functions. */
|
||||
coverageGapExamples: string[];
|
||||
/** ≤{@link MAX_EXAMPLES} `file:line` anchors of cap-dropped functions. */
|
||||
droppedExamples: string[];
|
||||
}
|
||||
|
||||
const pushExample = (list: string[], anchor: string): void => {
|
||||
if (list.length < MAX_EXAMPLES) list.push(anchor);
|
||||
};
|
||||
|
||||
/**
|
||||
* Run the taint pass over one file's emit-safe CFGs and persist TAINTED +
|
||||
* SANITIZES edges. `cfgs` MUST already be `isEmitSafeCfg`-filtered (the same
|
||||
* `wellFormed` array the caller fed `emitFileCfgs`/`emitFileReachingDefs`) —
|
||||
* block/edge anchors are trusted here; only the M3 `sites` layer is
|
||||
* re-validated (`hasTaintSafeSites`). Never throws on well-formed input;
|
||||
* the caller's per-file try isolates the rest.
|
||||
*/
|
||||
export function emitFileTaint(
|
||||
graph: KnowledgeGraph,
|
||||
cfgs: readonly FunctionCfg[],
|
||||
parsedImports: readonly ParsedImport[],
|
||||
spec: SourceSinkSanitizerSpec,
|
||||
limits?: TaintEmitLimits,
|
||||
onWarn?: (message: string) => void,
|
||||
): TaintEmitResult {
|
||||
const result: TaintEmitResult = {
|
||||
functionsAnalyzed: 0,
|
||||
functionsSkippedNoMatch: 0,
|
||||
functionsSkippedUnsafeSites: 0,
|
||||
functionsCoverageGap: { truncated: 0, overflow: 0, 'no-facts': 0 },
|
||||
findingsEmitted: 0,
|
||||
killsEmitted: 0,
|
||||
findingsDropped: 0,
|
||||
hopsTruncatedFindings: 0,
|
||||
coverageGapExamples: [],
|
||||
droppedExamples: [],
|
||||
};
|
||||
|
||||
// Imports are a FILE-level fact — build the index once, not per function.
|
||||
const importIndex = buildTaintImportIndex(parsedImports);
|
||||
const maxFindingsPerFunction =
|
||||
limits?.maxFindingsPerFunction ?? DEFAULT_PDG_MAX_TAINT_FINDINGS_PER_FUNCTION;
|
||||
const maxHops = limits?.maxHops ?? DEFAULT_PDG_MAX_TAINT_HOPS;
|
||||
const maxFacts = limits?.maxFacts ?? DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION;
|
||||
|
||||
// Defensive cross-CFG id guard: finding identity is unique WITHIN a
|
||||
// function by construction (the propagation engine dedups), so a repeat can
|
||||
// only mean two CFGs sharing an anchor — skip, never double-insert.
|
||||
const seenEdgeIds = new Set<string>();
|
||||
|
||||
for (const cfg of cfgs) {
|
||||
const { filePath, functionStartLine, functionStartColumn } = cfg;
|
||||
const anchor = `${filePath}:${functionStartLine}`;
|
||||
|
||||
if (!hasTaintSafeSites(cfg)) {
|
||||
result.functionsSkippedUnsafeSites++;
|
||||
pushExample(result.coverageGapExamples, anchor);
|
||||
onWarn?.(
|
||||
`[taint] ${anchor}: malformed site annotations (out-of-range binding/site ` +
|
||||
`indices) — taint skipped for this function; its CFG and REACHING_DEF ` +
|
||||
`layers are unaffected`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const matches = matchFunctionSites(cfg, spec, importIndex);
|
||||
if (!matches.hasSource || !matches.hasSink) {
|
||||
// Zero-match fast path: no solver call (see module doc step 3).
|
||||
result.functionsSkippedNoMatch++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const defUse = computeReachingDefs(cfg, { maxFacts });
|
||||
const flows = computeTaintFlows(cfg, defUse, matches, { maxFindingsPerFunction, maxHops });
|
||||
if (flows.status === 'coverage-gap') {
|
||||
// R4: skipped entirely, counted by reason; aggregate-warned by the
|
||||
// caller (the RD layer already per-function-warned this solver status).
|
||||
result.functionsCoverageGap[flows.gapReason ?? 'no-facts']++;
|
||||
pushExample(result.coverageGapExamples, anchor);
|
||||
continue;
|
||||
}
|
||||
result.functionsAnalyzed++;
|
||||
|
||||
const bindings: readonly BindingEntry[] = cfg.bindings ?? [];
|
||||
const fnAnchor = `${filePath}:${functionStartLine}:${functionStartColumn}`;
|
||||
const blockId = (p: ProgramPoint): string =>
|
||||
basicBlockId(filePath, functionStartLine, functionStartColumn, p.blockIndex);
|
||||
const bKey = (idx: number): string => {
|
||||
const b = bindings[idx];
|
||||
return b === undefined ? `#${idx}` : bindingKey(b);
|
||||
};
|
||||
|
||||
// SANITIZES — one edge per kill, REGARDLESS of findings (kills can and do
|
||||
// exist with zero findings: a fully-sanitized flow IS the kill evidence).
|
||||
for (const kill of flows.kills) {
|
||||
const id = generateId(
|
||||
'SANITIZES',
|
||||
`${fnAnchor}:${pointKey(kill.sanitizer)}->${pointKey(kill.killedDef)}:` +
|
||||
bKey(kill.bindingIdx),
|
||||
);
|
||||
if (seenEdgeIds.has(id)) continue;
|
||||
seenEdgeIds.add(id);
|
||||
graph.addRelationship({
|
||||
id,
|
||||
type: 'SANITIZES',
|
||||
sourceId: blockId(kill.sanitizer),
|
||||
targetId: blockId(kill.killedDef),
|
||||
confidence: 1.0,
|
||||
reason: bindings[kill.bindingIdx]?.name ?? `#${kill.bindingIdx}`,
|
||||
});
|
||||
result.killsEmitted++;
|
||||
}
|
||||
|
||||
// TAINTED — one edge per finding (already deduped + capped upstream).
|
||||
for (const finding of flows.findings) {
|
||||
const { source, sink } = finding;
|
||||
// KTD6 statement-level identity: function anchor + kind + source
|
||||
// occurrence + sink occurrence + binding keys. The rule-(b) occurrence
|
||||
// coordinates (site index / arg index) distinguish
|
||||
// `exec(req.body, req.query)`'s two findings; `property` is free-text
|
||||
// (string-literal subscripts) and rides LAST so it cannot collide into
|
||||
// another component.
|
||||
const id = generateId(
|
||||
'TAINTED',
|
||||
`${fnAnchor}:${finding.sinkKind}:` +
|
||||
`${pointKey(source.point)}.${source.siteIndex}:${bKey(source.objectBindingIdx)}:` +
|
||||
`${pointKey(sink.point)}.${sink.siteIndex}.${sink.argIndex}:${bKey(sink.bindingIdx)}:` +
|
||||
`${sink.entryName}:${source.property}`,
|
||||
);
|
||||
if (seenEdgeIds.has(id)) continue;
|
||||
seenEdgeIds.add(id);
|
||||
// `kind` rides the reason's `;<kind>` header — the only persisted
|
||||
// channel for the finding's category (the edge id embedding it is not a
|
||||
// stored column; `step` is INT32). U6's `explain` decodes it back.
|
||||
const encoded = encodeTaintPath(
|
||||
finding.hops.map((h) => ({ name: h.name, line: h.point.line, viaCall: h.viaCall })),
|
||||
{ truncated: finding.hopsTruncated === true, kind: finding.sinkKind },
|
||||
);
|
||||
if (encoded.truncated) result.hopsTruncatedFindings++;
|
||||
graph.addRelationship({
|
||||
id,
|
||||
type: 'TAINTED',
|
||||
sourceId: blockId(source.point),
|
||||
targetId: blockId(sink.point),
|
||||
confidence: 1.0,
|
||||
reason: encoded.reason,
|
||||
});
|
||||
result.findingsEmitted++;
|
||||
}
|
||||
|
||||
if (flows.droppedFindings > 0) {
|
||||
result.findingsDropped += flows.droppedFindings;
|
||||
pushExample(result.droppedExamples, anchor);
|
||||
onWarn?.(
|
||||
`[taint] ${anchor}: per-function taint findings cap ` +
|
||||
`(${maxFindingsPerFunction}) reached — dropped ${flows.droppedFindings} of ` +
|
||||
`${flows.findings.length + flows.droppedFindings} deduped findings`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
374
gitnexus/src/core/ingestion/taint/match.ts
Normal file
374
gitnexus/src/core/ingestion/taint/match.ts
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
/**
|
||||
* Import-aware taint-site matcher (#2083 M3 U2, plan KTD7).
|
||||
*
|
||||
* Classifies a function's harvested {@link SiteRecord}s against a registered
|
||||
* {@link SourceSinkSanitizerSpec}: which member reads are SOURCES, which
|
||||
* call/new sites are SINKS (and at which argument positions), and which are
|
||||
* SANITIZERS. Pure main-thread data work — sites + bindings come from the U1
|
||||
* worker harvest, imports from `ParsedFile.parsedImports`; no AST, no I/O.
|
||||
*
|
||||
* PRECONDITION: the caller must gate the CFG through `hasTaintSafeSites`
|
||||
* (taint/site-safety.ts) first — this module dereferences binding/site
|
||||
* indices without re-validating them.
|
||||
*
|
||||
* ## Callee resolution precedence (bare and member-rooted calls)
|
||||
*
|
||||
* 1. ESM import join — the callee root's local name is resolved through the
|
||||
* {@link TaintImportIndex} built from `parsedImports` (`named`/`alias`
|
||||
* members, `namespace`/default-import module handles); `import { exec as
|
||||
* run } from 'child_process'` makes `run(c)` resolve to
|
||||
* `child_process.exec`, and `import * as cp …` makes `cp.exec(c)` resolve
|
||||
* the same way.
|
||||
* 2. require-literal join — a binding whose in-function defining site carries
|
||||
* `requireArg` resolves like a namespace handle (`const cp =
|
||||
* require('child_process'); cp.exec(c)`). A BARE call of a require-joined
|
||||
* binding is matched under BOTH interpretations, `<module>.default` (the
|
||||
* module/default export invoked directly) and `<module>.<localName>`
|
||||
* (non-renamed destructured require — the harvest attaches `resultDefs`
|
||||
* to destructured bindings without recording the property path, and the
|
||||
* binding name IS the member name in the non-renamed case).
|
||||
* 3. Bare-name fallback — TRUE GLOBALS only (`global: true` entries: `eval`,
|
||||
* `new Function`, `encodeURIComponent`), and only when the name is neither
|
||||
* import-bound nor shadowed. Conventional receiver names (`req`/`request`
|
||||
* member-read sources, `res.send`, `.query`/`.execute`) are matched
|
||||
* name-based by their own mechanisms, never via the global fallback.
|
||||
*
|
||||
* ## Shadowing rule (exact)
|
||||
*
|
||||
* A name is treated as function-local — blocking import/global resolution —
|
||||
* iff the function's binding table contains a NON-`synthetic` entry with that
|
||||
* name (an in-function `function exec(){}` / `const exec = …`). Synthetic
|
||||
* bindings (kind `module`, `synthetic: true`) are imports, true globals, or
|
||||
* enclosing-scope captures and do not shadow. Member-call roots use the
|
||||
* harvested `receiver` binding index directly (no name scan).
|
||||
*
|
||||
* ## Documented resolution gaps (direction stated, per plan KTD10)
|
||||
*
|
||||
* - MODULE-LEVEL `const cp = require('child_process')`: the binding is
|
||||
* synthetic inside the function, produces no `ParsedImport`, and its
|
||||
* defining site lives outside the function's harvested sites — the
|
||||
* require join cannot see it. Module-mechanism sinks miss (FN) and
|
||||
* sanitizers don't kill (FP noise — never a false kill, the safe
|
||||
* direction). Only in-function requires resolve.
|
||||
* - RENAMED destructured require (`const { exec: run } = require(…)`):
|
||||
* the dual interpretation resolves `run` to `child_process.run` — no
|
||||
* match (FN). Non-renamed destructures resolve exactly.
|
||||
* - CONSERVATIVE shadow scan for bare calls: ANY non-synthetic binding of
|
||||
* the callee name anywhere in the function blocks import/global
|
||||
* resolution, even when the shadow is block-scoped elsewhere and the call
|
||||
* site actually sees the import (FN; rare; safe for sanitizers).
|
||||
* - MODULE-LEVEL user declarations are indistinguishable from imports in
|
||||
* the binding table (both synthetic). ESM forbids a module-level
|
||||
* declaration colliding with an import name, so the import join is
|
||||
* authoritative when an import exists; a module-level user function
|
||||
* shadowing a TRUE GLOBAL (e.g. a local `encodeURIComponent`) is not
|
||||
* detectable and would still match (pathological; accepted).
|
||||
* - Handle COPIES (`const c2 = cp; c2.exec(…)`) are not followed — joins
|
||||
* are one level deep (binding → import/require), never through
|
||||
* assignments (FN).
|
||||
* - `this.`/`super.`-rooted and call-rooted callee chains have no
|
||||
* resolvable root: only the syntactic `anyReceiver`/`receivers`
|
||||
* mechanisms can match them.
|
||||
* - `reexport`/`wildcard`/`dynamic-*`/`side-effect` imports introduce no
|
||||
* matcher-visible local binding and are skipped by the index.
|
||||
*/
|
||||
|
||||
import type { ParsedImport } from 'gitnexus-shared';
|
||||
import type { FunctionCfg, SiteRecord } from '../cfg/types.js';
|
||||
import type {
|
||||
SourceSinkSanitizerSpec,
|
||||
TaintMemberSourceEntry,
|
||||
TaintSanitizerEntry,
|
||||
TaintSinkEntry,
|
||||
} from './source-sink-config.js';
|
||||
|
||||
/** What a local name imported into the file denotes. */
|
||||
export interface TaintImportBinding {
|
||||
/** Normalized module specifier (`node:` scheme stripped). */
|
||||
readonly module: string;
|
||||
/**
|
||||
* Exported member bound by a named/aliased import; `undefined` when the
|
||||
* local name is a MODULE HANDLE (namespace import, or a default import —
|
||||
* CJS interop makes the default export ≈ the module object).
|
||||
*/
|
||||
readonly member?: string;
|
||||
}
|
||||
|
||||
/** Local name → import provenance for one file. Build once per file (U4). */
|
||||
export type TaintImportIndex = ReadonlyMap<string, TaintImportBinding>;
|
||||
|
||||
/** A member-read site matched as a taint source. */
|
||||
export interface MatchedSourceRead {
|
||||
/** Index into the owning statement's `sites` array. */
|
||||
readonly siteIndex: number;
|
||||
readonly entry: TaintMemberSourceEntry;
|
||||
}
|
||||
|
||||
/** A call/new site matched as a sink. */
|
||||
export interface MatchedSinkCall {
|
||||
/** Index into the owning statement's `sites` array. */
|
||||
readonly siteIndex: number;
|
||||
readonly entry: TaintSinkEntry;
|
||||
/**
|
||||
* Positions (indices into `site.args`) that are registered sink positions
|
||||
* AND carry at least one recorded binding occurrence, after the spread
|
||||
* rule (a recorded position ≥ `site.spread` matches when any registered
|
||||
* position ≥ the spread index exists — runtime positions after a spread
|
||||
* are unknowable) and the template rule (`template: true` aggregates all
|
||||
* substitutions at position 0 and matches any-position). Never empty — a
|
||||
* sink whose dangerous positions carry no occurrences cannot produce a
|
||||
* finding and is not reported.
|
||||
*/
|
||||
readonly argPositions: readonly number[];
|
||||
}
|
||||
|
||||
/** A call site matched as a sanitizer (import-aware/global only — see module doc). */
|
||||
export interface MatchedSanitizerCall {
|
||||
/** Index into the owning statement's `sites` array. */
|
||||
readonly siteIndex: number;
|
||||
readonly entry: TaintSanitizerEntry;
|
||||
/**
|
||||
* Bindings the sanitizer's result defines directly (`const b = escape(t)`
|
||||
* ⇒ `b`) — U3's kill targets (KTD4b). Empty for value-position sanitizer
|
||||
* calls (`exec(escape(x))`), whose effect is occurrence INTERPOSITION via
|
||||
* the site's `parent`/via-tag chain, not a def kill.
|
||||
*/
|
||||
readonly resultDefs: readonly number[];
|
||||
}
|
||||
|
||||
/** All matches within one statement. Emitted only when at least one list is non-empty. */
|
||||
export interface StatementMatches {
|
||||
readonly blockIndex: number;
|
||||
readonly statementIndex: number;
|
||||
readonly line: number;
|
||||
readonly sources: readonly MatchedSourceRead[];
|
||||
readonly sinks: readonly MatchedSinkCall[];
|
||||
readonly sanitizers: readonly MatchedSanitizerCall[];
|
||||
}
|
||||
|
||||
/** Classified sites for one function, in (block, statement, site, entry) order. */
|
||||
export interface FunctionSiteMatches {
|
||||
readonly statements: readonly StatementMatches[];
|
||||
/** Fast-path gates for U4: the solver runs only when both are true. */
|
||||
readonly hasSource: boolean;
|
||||
readonly hasSink: boolean;
|
||||
}
|
||||
|
||||
const stripNodeScheme = (specifier: string): string =>
|
||||
specifier.startsWith('node:') ? specifier.slice('node:'.length) : specifier;
|
||||
|
||||
/**
|
||||
* Build the local-name → module/member index from a file's `parsedImports`.
|
||||
* Only `named`/`alias`/`namespace` kinds bind matcher-visible local names;
|
||||
* `importedName === 'default'` collapses to a module handle.
|
||||
*/
|
||||
export function buildTaintImportIndex(imports: readonly ParsedImport[]): TaintImportIndex {
|
||||
const index = new Map<string, TaintImportBinding>();
|
||||
for (const imp of imports) {
|
||||
if (imp.kind === 'named' || imp.kind === 'alias') {
|
||||
const module = stripNodeScheme(imp.targetRaw);
|
||||
index.set(
|
||||
imp.localName,
|
||||
imp.importedName === 'default' ? { module } : { module, member: imp.importedName },
|
||||
);
|
||||
} else if (imp.kind === 'namespace') {
|
||||
index.set(imp.localName, { module: stripNodeScheme(imp.targetRaw) });
|
||||
}
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
/** Internal: a callee's resolution — canonical dotted names + syntactic path. */
|
||||
interface ResolvedCallee {
|
||||
/** Syntactic dotted path segments (`cp.exec` ⇒ `['cp','exec']`). */
|
||||
readonly path: readonly string[];
|
||||
/** Module-resolved canonical names this callee may denote. */
|
||||
readonly canonical: readonly string[];
|
||||
/** True when the bare root may denote an ECMAScript global (unshadowed, un-imported). */
|
||||
readonly globalRoot: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a function's harvested sites against a language spec. See the
|
||||
* module doc for resolution precedence, the shadowing rule, and gaps.
|
||||
*/
|
||||
export function matchFunctionSites(
|
||||
cfg: FunctionCfg,
|
||||
spec: SourceSinkSanitizerSpec,
|
||||
imports: TaintImportIndex,
|
||||
): FunctionSiteMatches {
|
||||
const bindings = cfg.bindings ?? [];
|
||||
|
||||
// Non-synthetic (in-function-declared) binding indices by name — the
|
||||
// shadow scan + bare-call require-join lookup.
|
||||
const nonSyntheticByName = new Map<string, number[]>();
|
||||
bindings.forEach((b, i) => {
|
||||
if (b.synthetic === true) return;
|
||||
const list = nonSyntheticByName.get(b.name);
|
||||
if (list) list.push(i);
|
||||
else nonSyntheticByName.set(b.name, [i]);
|
||||
});
|
||||
|
||||
// require-literal join: binding index → module specifier. A binding def'd
|
||||
// by two DIFFERENT require literals is conflicted → dropped (resolving it
|
||||
// either way could fabricate a sanitizer kill).
|
||||
const requireByBinding = new Map<number, string>();
|
||||
const conflicted = new Set<number>();
|
||||
for (const block of cfg.blocks) {
|
||||
for (const stmt of block.statements ?? []) {
|
||||
for (const site of stmt.sites ?? []) {
|
||||
if (site.requireArg === undefined || site.resultDefs === undefined) continue;
|
||||
const module = stripNodeScheme(site.requireArg);
|
||||
for (const def of site.resultDefs) {
|
||||
if (conflicted.has(def)) continue;
|
||||
const prior = requireByBinding.get(def);
|
||||
if (prior === undefined) requireByBinding.set(def, module);
|
||||
else if (prior !== module) {
|
||||
requireByBinding.delete(def);
|
||||
conflicted.add(def);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resolveCallee = (site: SiteRecord): ResolvedCallee | undefined => {
|
||||
if (site.callee === undefined) return undefined;
|
||||
const path = site.callee.split('.');
|
||||
const root = path[0];
|
||||
const rest = path.slice(1);
|
||||
const canonical: string[] = [];
|
||||
let globalRoot = false;
|
||||
|
||||
if (site.receiver !== undefined) {
|
||||
// Member chain with an identifier root — origin known by binding index.
|
||||
const rb = bindings[site.receiver];
|
||||
if (rb.synthetic === true) {
|
||||
const imp = imports.get(rb.name);
|
||||
if (imp !== undefined) {
|
||||
const base = imp.member === undefined ? [imp.module] : [imp.module, imp.member];
|
||||
canonical.push([...base, ...rest].join('.'));
|
||||
}
|
||||
} else {
|
||||
const module = requireByBinding.get(site.receiver);
|
||||
if (module !== undefined) canonical.push([module, ...rest].join('.'));
|
||||
}
|
||||
} else if (path.length === 1) {
|
||||
// Bare call. `this`/`super`/call-rooted chains never get here (those
|
||||
// are dotted-without-receiver or callee-less).
|
||||
const locals = nonSyntheticByName.get(root);
|
||||
if (locals !== undefined) {
|
||||
// Shadowed by an in-function declaration — only the require join
|
||||
// applies, under the dual interpretation (module doc).
|
||||
for (const idx of locals) {
|
||||
const module = requireByBinding.get(idx);
|
||||
if (module !== undefined) canonical.push(`${module}.default`, `${module}.${root}`);
|
||||
}
|
||||
} else {
|
||||
const imp = imports.get(root);
|
||||
if (imp !== undefined) {
|
||||
canonical.push(
|
||||
imp.member === undefined ? `${imp.module}.default` : `${imp.module}.${imp.member}`,
|
||||
);
|
||||
} else {
|
||||
globalRoot = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { path, canonical, globalRoot };
|
||||
};
|
||||
|
||||
/** The spread/template/registered-position rule (see MatchedSinkCall doc). */
|
||||
const positionMatches = (entry: TaintSinkEntry, site: SiteRecord, p: number): boolean => {
|
||||
if (site.template === true) return true;
|
||||
if (entry.args === undefined) return true;
|
||||
if (site.spread !== undefined && p >= site.spread) {
|
||||
const spread = site.spread;
|
||||
return entry.args.some((q) => q >= spread);
|
||||
}
|
||||
return entry.args.includes(p);
|
||||
};
|
||||
|
||||
const sinkMechanismHit = (
|
||||
entry: TaintSinkEntry,
|
||||
site: SiteRecord,
|
||||
r: ResolvedCallee,
|
||||
): boolean => {
|
||||
if (entry.module !== undefined) return r.canonical.includes(`${entry.module}.${entry.name}`);
|
||||
if (entry.global === true) {
|
||||
return (
|
||||
r.globalRoot &&
|
||||
r.path.length === 1 &&
|
||||
r.path[0] === entry.name &&
|
||||
(entry.newOnly !== true || site.kind === 'new')
|
||||
);
|
||||
}
|
||||
if (entry.anyReceiver === true) {
|
||||
return r.path.length >= 2 && r.path[r.path.length - 1] === entry.name;
|
||||
}
|
||||
if (entry.receivers !== undefined) {
|
||||
return r.path.length === 2 && entry.receivers.includes(r.path[0]) && r.path[1] === entry.name;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Sanitizers: module + global mechanisms ONLY — never receiver-conventional,
|
||||
// never bare-name for non-globals (a false kill is the forbidden direction).
|
||||
const sanitizerMechanismHit = (entry: TaintSanitizerEntry, r: ResolvedCallee): boolean => {
|
||||
if (entry.module !== undefined) return r.canonical.includes(`${entry.module}.${entry.name}`);
|
||||
if (entry.global === true) {
|
||||
return r.globalRoot && r.path.length === 1 && r.path[0] === entry.name;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const statements: StatementMatches[] = [];
|
||||
let hasSource = false;
|
||||
let hasSink = false;
|
||||
|
||||
cfg.blocks.forEach((block, blockIndex) => {
|
||||
block.statements?.forEach((stmt, statementIndex) => {
|
||||
const sites = stmt.sites;
|
||||
if (sites === undefined || sites.length === 0) return;
|
||||
const sources: MatchedSourceRead[] = [];
|
||||
const sinks: MatchedSinkCall[] = [];
|
||||
const sanitizers: MatchedSanitizerCall[] = [];
|
||||
|
||||
sites.forEach((site, siteIndex) => {
|
||||
if (site.kind === 'member-read') {
|
||||
if (site.object === undefined || site.property === undefined) return;
|
||||
const objectName = bindings[site.object].name;
|
||||
const property = site.property;
|
||||
for (const entry of spec.sources) {
|
||||
if (entry.objects.includes(objectName) && entry.properties.includes(property)) {
|
||||
sources.push({ siteIndex, entry });
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// call / new
|
||||
const resolved = resolveCallee(site);
|
||||
if (resolved === undefined) return;
|
||||
for (const entry of spec.sinks) {
|
||||
if (!sinkMechanismHit(entry, site, resolved)) continue;
|
||||
const argPositions: number[] = [];
|
||||
site.args?.forEach((occurrences, p) => {
|
||||
if (occurrences.length > 0 && positionMatches(entry, site, p)) argPositions.push(p);
|
||||
});
|
||||
if (argPositions.length > 0) sinks.push({ siteIndex, entry, argPositions });
|
||||
}
|
||||
for (const entry of spec.sanitizers) {
|
||||
if (!sanitizerMechanismHit(entry, resolved)) continue;
|
||||
sanitizers.push({ siteIndex, entry, resultDefs: site.resultDefs ?? [] });
|
||||
}
|
||||
});
|
||||
|
||||
if (sources.length === 0 && sinks.length === 0 && sanitizers.length === 0) return;
|
||||
hasSource ||= sources.length > 0;
|
||||
hasSink ||= sinks.length > 0;
|
||||
statements.push({ blockIndex, statementIndex, line: stmt.line, sources, sinks, sanitizers });
|
||||
});
|
||||
});
|
||||
|
||||
return { statements, hasSource, hasSink };
|
||||
}
|
||||
267
gitnexus/src/core/ingestion/taint/path-codec.ts
Normal file
267
gitnexus/src/core/ingestion/taint/path-codec.ts
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
/**
|
||||
* Taint-path reason codec (#2083 M3 U4/U6, plan KTD6).
|
||||
*
|
||||
* THE one shared encoder/decoder for the hop-encoded `reason` carried on
|
||||
* persisted `TAINTED` edges: the U4 emit path writes it, the U6 MCP `explain`
|
||||
* tool reads it. Two hand-rolled copies of a wire format drift — both sides
|
||||
* MUST import from here.
|
||||
*
|
||||
* ## Wire format (version `1`)
|
||||
*
|
||||
* ```
|
||||
* 1[;<kind>]|<name>:<line>[:<flags>]|<name>:<line>[:<flags>]|…[|~]
|
||||
* ```
|
||||
*
|
||||
* - One-character version prefix (`TAINT_PATH_CODEC_VERSION`), then an
|
||||
* OPTIONAL `;<kind>` header segment, then ordered source→sink hops, each
|
||||
* `variable:line[:flags]`.
|
||||
* - `kind` is the finding's sink category (`SinkKind`, e.g.
|
||||
* `command-injection`). It rides the reason because it is the ONLY
|
||||
* persisted channel: the CodeRelation columns are
|
||||
* `type/confidence/reason/step` — `step` is INT32 and the emit-time edge id
|
||||
* (which embeds the kind) is not a stored column. The U6 `explain` tool
|
||||
* reads it for finding classification. Charset `[a-z0-9-]` (printable
|
||||
* ASCII, disjoint from every structural delimiter); `;` itself is printable
|
||||
* ASCII and never appears in hop names (identifier charset) or flags.
|
||||
* U6 deviation note: this header was added by U6 WITHIN version `1` —
|
||||
* U4 and U6 ship in the same release, so no reason string without the
|
||||
* header was ever persisted by a released build; the decoder still accepts
|
||||
* header-less strings (`kind` simply decodes as `undefined`).
|
||||
* - `flags` is a lowercase-letter set; only `c` (= the hop passed through an
|
||||
* unmodeled call, KTD5 `viaCall`) is defined today — the rest of the
|
||||
* alphabet is RESERVED, and the decoder accepts unknown flag letters so a
|
||||
* future writer's output stays decodable.
|
||||
* - A trailing `|~` segment is the TRUNCATION MARKER: the encoded path is a
|
||||
* source-side PREFIX of the real one (hop cap, byte cap, or an unencodable
|
||||
* hop name). Decoders MUST report it as "path incomplete" — never an error.
|
||||
*
|
||||
* ## Delimiter / round-trip discipline (KTD6)
|
||||
*
|
||||
* Every structural character (`|`, `:`, `~`, digits, flag letters) is
|
||||
* printable ASCII: `sanitizeUTF8` (csv-generator.ts) strips control
|
||||
* characters, lone surrogates, and U+FFFE/FFFF — printable ASCII passes
|
||||
* through byte-exact, so the encoding survives `escapeCSVField ∘
|
||||
* sanitizeUTF8` and the DB load unchanged (pinned by the round-trip test).
|
||||
* None of the delimiters can appear in a JS identifier.
|
||||
*
|
||||
* Hop names are identifier-charset by U1 construction (the harvest records
|
||||
* binding names), but the encoder DEFENDS anyway: a hop whose name falls
|
||||
* outside the safe charset (or whose line is not a non-negative integer) is
|
||||
* never emitted — encoding stops at the offending hop and sets the truncation
|
||||
* marker, preserving the prefix-of-the-true-path invariant rather than
|
||||
* corrupting the format. (`#` is in the charset: JS private names are
|
||||
* `#field`, and the propagation engine's fallback hop names are `#<idx>`.)
|
||||
*
|
||||
* The byte cap (`TAINT_REASON_MAX_BYTES`, KTD6's "absolute reason-byte cap")
|
||||
* bounds the persisted reason column regardless of hop caps: overflow drops
|
||||
* TRAILING hops (keeps the source side) and sets the marker. All structural
|
||||
* chars and valid names are single-byte ASCII, so `string.length` IS the
|
||||
* byte length.
|
||||
*/
|
||||
|
||||
/** One-character format version prefix. Bump on any wire-format change. */
|
||||
export const TAINT_PATH_CODEC_VERSION = '1';
|
||||
|
||||
/**
|
||||
* Absolute cap on the encoded reason's byte length (KTD6). 4096 comfortably
|
||||
* holds ~100 hops of realistic identifiers — far beyond the default hop cap
|
||||
* (32) — while bounding the persisted column even at `maxHops: 0` (unlimited).
|
||||
*/
|
||||
export const TAINT_REASON_MAX_BYTES = 4096;
|
||||
|
||||
/** The truncation-marker segment content (rides as a trailing `|~`). */
|
||||
export const TAINT_PATH_TRUNCATION_MARKER = '~';
|
||||
|
||||
/**
|
||||
* Safe hop-name charset: ASCII identifier characters plus `#` (JS private
|
||||
* names / the propagation engine's `#<idx>` fallback). Deliberately ASCII-only
|
||||
* — a Unicode identifier is VALID JS but is skipped (truncation marker) rather
|
||||
* than risking a `sanitizeUTF8` byte change breaking decode (defensive
|
||||
* simplification; documented FN on path completeness, never on the finding).
|
||||
*/
|
||||
const SAFE_NAME = /^[A-Za-z0-9_$#]+$/;
|
||||
|
||||
/** Decoder-side flags charset — `c` defined, the rest reserved (see module doc). */
|
||||
const FLAGS = /^[a-z]*$/;
|
||||
|
||||
/**
|
||||
* Kind-header charset: lowercase + digits + hyphen — covers every `SinkKind`
|
||||
* label and stays disjoint from the structural delimiters (`;|:~`).
|
||||
*/
|
||||
const SAFE_KIND = /^[a-z0-9-]+$/;
|
||||
|
||||
/** Encoder input hop — shape-compatible with `TaintHop` (propagate.ts). */
|
||||
export interface TaintPathHopInput {
|
||||
readonly name: string;
|
||||
readonly line: number;
|
||||
readonly viaCall?: boolean;
|
||||
}
|
||||
|
||||
export interface EncodeTaintPathOptions {
|
||||
/**
|
||||
* The hop list is already a truncated prefix (e.g. the propagation engine's
|
||||
* `hopsTruncated` from its hop cap) — emit the marker even when every hop
|
||||
* fits.
|
||||
*/
|
||||
readonly truncated?: boolean;
|
||||
/** Byte cap override (tests). Default {@link TAINT_REASON_MAX_BYTES}. */
|
||||
readonly maxBytes?: number;
|
||||
/**
|
||||
* Finding sink category (`SinkKind`) carried in the `;<kind>` header — the
|
||||
* only persisted channel for it (see the module doc). A value outside the
|
||||
* `[a-z0-9-]` charset is DROPPED (header omitted), never corrupted into the
|
||||
* wire string; `SinkKind` is a closed lowercase-hyphen union so this is
|
||||
* purely defensive.
|
||||
*/
|
||||
readonly kind?: string;
|
||||
}
|
||||
|
||||
export interface EncodedTaintPath {
|
||||
/** The wire string for the TAINTED edge's `reason` column. */
|
||||
readonly reason: string;
|
||||
/** True when the marker was emitted (caller-flagged, byte cap, or bad hop). */
|
||||
readonly truncated: boolean;
|
||||
}
|
||||
|
||||
export interface DecodedTaintHop {
|
||||
readonly variable: string;
|
||||
readonly line: number;
|
||||
/** The hop passed through an unmodeled call (flag `c`, KTD5). */
|
||||
readonly viaCall: boolean;
|
||||
}
|
||||
|
||||
export interface DecodedTaintPath {
|
||||
readonly ok: true;
|
||||
readonly version: string;
|
||||
/** Finding sink category from the `;<kind>` header; absent when not encoded. */
|
||||
readonly kind?: string;
|
||||
/** Ordered source→sink hops (a PREFIX when `truncated`). */
|
||||
readonly hops: readonly DecodedTaintHop[];
|
||||
/** Path incomplete (trailing `|~`) — informational, NOT an error. */
|
||||
readonly truncated: boolean;
|
||||
}
|
||||
|
||||
/** Typed parse failure — the decoder never throws. */
|
||||
export interface TaintPathDecodeFailure {
|
||||
readonly ok: false;
|
||||
readonly error: string;
|
||||
}
|
||||
|
||||
export type TaintPathDecodeResult = DecodedTaintPath | TaintPathDecodeFailure;
|
||||
|
||||
/**
|
||||
* Encode an ordered hop list into the versioned `reason` wire string.
|
||||
* Deterministic; never throws. See the module doc for the format and the
|
||||
* three truncation triggers (caller flag, unencodable hop, byte cap).
|
||||
*/
|
||||
export function encodeTaintPath(
|
||||
hops: readonly TaintPathHopInput[],
|
||||
options?: EncodeTaintPathOptions,
|
||||
): EncodedTaintPath {
|
||||
// Kind header (defensively validated — see EncodeTaintPathOptions.kind).
|
||||
const kindHeader =
|
||||
typeof options?.kind === 'string' && SAFE_KIND.test(options.kind) ? `;${options.kind}` : '';
|
||||
// Floor: version char + kind header + room for the marker — a smaller cap
|
||||
// could not hold even the empty truncated path. The header is identity
|
||||
// material (finding classification), so it is never sacrificed to the byte
|
||||
// cap; trailing hops are.
|
||||
const maxBytes = Math.max(
|
||||
options?.maxBytes ?? TAINT_REASON_MAX_BYTES,
|
||||
TAINT_PATH_CODEC_VERSION.length + kindHeader.length + 2,
|
||||
);
|
||||
let truncated = options?.truncated === true;
|
||||
const segments: string[] = [];
|
||||
let total = TAINT_PATH_CODEC_VERSION.length + kindHeader.length;
|
||||
for (const hop of hops) {
|
||||
if (
|
||||
typeof hop.name !== 'string' ||
|
||||
!SAFE_NAME.test(hop.name) ||
|
||||
!Number.isInteger(hop.line) ||
|
||||
hop.line < 0
|
||||
) {
|
||||
// Unencodable hop: drop it AND everything after it so the emitted hops
|
||||
// stay a faithful source-side prefix (a silent mid-path gap would lie).
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
const segment = `|${hop.name}:${hop.line}${hop.viaCall === true ? ':c' : ''}`;
|
||||
if (total + segment.length > maxBytes) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
segments.push(segment);
|
||||
total += segment.length;
|
||||
}
|
||||
if (truncated) {
|
||||
// Make room for the trailing `|~` marker (drop trailing hops as needed).
|
||||
while (segments.length > 0 && total + 2 > maxBytes) {
|
||||
total -= (segments.pop() as string).length;
|
||||
}
|
||||
}
|
||||
const reason =
|
||||
TAINT_PATH_CODEC_VERSION +
|
||||
kindHeader +
|
||||
segments.join('') +
|
||||
(truncated ? `|${TAINT_PATH_TRUNCATION_MARKER}` : '');
|
||||
return { reason, truncated };
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a `reason` wire string. Returns a typed failure for anything that is
|
||||
* not a well-formed version-`1` path — never throws. A truncated path decodes
|
||||
* `ok: true` with `truncated: true` ("path incomplete", per KTD6).
|
||||
*/
|
||||
export function decodeTaintPath(reason: unknown): TaintPathDecodeResult {
|
||||
if (typeof reason !== 'string' || reason.length === 0) {
|
||||
return { ok: false, error: 'empty or non-string reason' };
|
||||
}
|
||||
const version = reason[0];
|
||||
if (version !== TAINT_PATH_CODEC_VERSION) {
|
||||
return { ok: false, error: `unsupported taint-path version '${version}'` };
|
||||
}
|
||||
let body = reason.slice(1);
|
||||
// Optional `;<kind>` header segment (finding sink category — see module doc).
|
||||
let kind: string | undefined;
|
||||
if (body.startsWith(';')) {
|
||||
const headerEnd = body.indexOf('|');
|
||||
kind = headerEnd === -1 ? body.slice(1) : body.slice(1, headerEnd);
|
||||
if (!SAFE_KIND.test(kind)) {
|
||||
return { ok: false, error: `invalid kind header '${kind}'` };
|
||||
}
|
||||
body = headerEnd === -1 ? '' : body.slice(headerEnd);
|
||||
}
|
||||
const hops: DecodedTaintHop[] = [];
|
||||
if (body.length === 0)
|
||||
return { ok: true, version, ...(kind ? { kind } : {}), hops, truncated: false };
|
||||
if (!body.startsWith('|')) {
|
||||
return { ok: false, error: 'malformed body: expected a hop separator after the version' };
|
||||
}
|
||||
const parts = body.slice(1).split('|');
|
||||
let truncated = false;
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
if (part === TAINT_PATH_TRUNCATION_MARKER) {
|
||||
if (i !== parts.length - 1) {
|
||||
return { ok: false, error: 'truncation marker not in trailing position' };
|
||||
}
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
const fields = part.split(':');
|
||||
if (fields.length < 2 || fields.length > 3) {
|
||||
return { ok: false, error: `malformed hop segment '${part}'` };
|
||||
}
|
||||
const [name, lineStr, flags = ''] = fields;
|
||||
if (!SAFE_NAME.test(name)) {
|
||||
return { ok: false, error: `invalid hop variable '${name}'` };
|
||||
}
|
||||
if (!/^\d+$/.test(lineStr)) {
|
||||
return { ok: false, error: `invalid hop line '${lineStr}'` };
|
||||
}
|
||||
if (!FLAGS.test(flags)) {
|
||||
return { ok: false, error: `invalid hop flags '${flags}'` };
|
||||
}
|
||||
hops.push({ variable: name, line: Number(lineStr), viaCall: flags.includes('c') });
|
||||
}
|
||||
return { ok: true, version, ...(kind ? { kind } : {}), hops, truncated };
|
||||
}
|
||||
880
gitnexus/src/core/ingestion/taint/propagate.ts
Normal file
880
gitnexus/src/core/ingestion/taint/propagate.ts
Normal file
|
|
@ -0,0 +1,880 @@
|
|||
/**
|
||||
* Pure intra-procedural taint propagation engine (#2083 M3 U3).
|
||||
*
|
||||
* Forward taint reachability over one function's reaching-definition facts
|
||||
* (M2 `computeReachingDefs`) and matched taint sites (U2 `matchFunctionSites`)
|
||||
* — sources in, findings + sanitizer kills + coverage status out. PURE AND
|
||||
* DETERMINISTIC, mirroring the reaching-defs contract: no graph, no I/O, no
|
||||
* logger; insertion-ordered worklist; explicitly sorted outputs; snapshot
|
||||
* tests and content-derived edge ids (U4) rely on it.
|
||||
*
|
||||
* PRECONDITIONS: the caller gates the CFG through `hasTaintSafeSites`
|
||||
* (taint/site-safety.ts) and the emit-safety checks before calling — this
|
||||
* module dereferences binding/site/statement indices without re-validating.
|
||||
*
|
||||
* ## The two-rule model (plan HTD)
|
||||
*
|
||||
* - **Rule (b), statement-local:** a matched SOURCE occurrence (member read)
|
||||
* whose intra-statement occurrence path — the member-read's `parent` chain —
|
||||
* reaches a matched SINK argument position produces an immediate single-hop
|
||||
* finding (`exec(req.body)`). The same statement SEEDS taint: every binding
|
||||
* the statement defines becomes tainted (see the precision floor below).
|
||||
* - **Rule (a), worklist:** for each tainted `(binding, defPoint)`, every
|
||||
* def→use fact delivers the taint to a use statement, where occurrences of
|
||||
* the binding in matched sink argument positions produce findings and the
|
||||
* statement's own defs are tainted onward. The fact graph contains genuine
|
||||
* cycles (loop back-edges, same-statement self-facts) — the visited-set
|
||||
* discipline below is load-bearing, not defensive.
|
||||
*
|
||||
* ## Sanitizer semantics — the KIND-SET exclusion model (KTD4, sharpened)
|
||||
*
|
||||
* The plan sketches a binary kill; this module implements the strictly more
|
||||
* precise SOUND refinement: a taint carries a set of *excluded* (neutralized)
|
||||
* `SinkKind`s accumulated through sanitizer hops, and a sink fires unless its
|
||||
* kind is in the taint's exclusion set. A binary kill is the special case
|
||||
* where the sanitizer neutralizes the sink's kind; the kind-set model
|
||||
* additionally keeps `const b = escape(req.body); db.query(b)` a FINDING
|
||||
* (an HTML escaper does not neutralize SQL — un-tainting `b` outright would
|
||||
* be a suppressed live injection, the forbidden false-negative direction)
|
||||
* while still suppressing `res.send(b)` (xss IS neutralized).
|
||||
*
|
||||
* - **Occurrence interposition (KTD4a):** evaluated over the U1 site
|
||||
* structure. An occurrence reaching a sink arg / def-feeding position
|
||||
* through a matched sanitizer site accumulates that sanitizer's
|
||||
* `neutralizes` kinds on that PATH; a direct occurrence contributes the
|
||||
* empty set. Per-position narrowing (`entry.args`) is respected; receiver
|
||||
* flow through a sanitizer is NOT neutralized (the receiver is not the
|
||||
* sanitized payload), and spread/template positions are never neutralized
|
||||
* (position unprovable) — both sound-direction choices (under-kill).
|
||||
* - **Intersection over paths:** a def fed by several occurrence paths
|
||||
* excludes a kind only when EVERY path neutralizes it
|
||||
* (`const c = cond ? escape(b) : b` taints `c` with NO exclusions — the
|
||||
* direct arm's ∅ intersects everything away). Equally, a taint re-derived
|
||||
* along a second route keeps the INTERSECTION of the exclusion sets and is
|
||||
* re-processed whenever the set SHRINKS — a less-neutralized taint is
|
||||
* strictly more dangerous. Exclusion sets only shrink over a finite
|
||||
* lattice, so the worklist terminates.
|
||||
* - **Kill locality (KTD4b):** a kill applies to the def the sanitizer
|
||||
* produces (`SiteRecord.resultDefs`) only; the flowing binding's own taint
|
||||
* is untouched (`const c = escape(b); exec(b)` still finds `b`'s flow).
|
||||
* `x = escape(x)` works because taint keys on the DEF POINT: the
|
||||
* sanitizer statement's def enters the set with the sanitizer's kinds
|
||||
* excluded, while the seed def keeps flowing wherever the CFG still
|
||||
* carries it (zero-iteration loops, conditional sanitizers — may-path
|
||||
* mechanics need no special handling here, kills are absent from facts).
|
||||
*
|
||||
* ## Statement-coalescing precision floor (documented FP)
|
||||
*
|
||||
* Statement facts conflate multi-declarator statements: a statement that
|
||||
* uses tainted `b` and defines `c` taints `c` with NO exclusions even when
|
||||
* the two are textually unrelated (`const a = clean(z), b = g(t)` floor-
|
||||
* taints `a` from `t` — pinned by a test). The per-declarator `resultDefs`
|
||||
* precision narrows the EXCLUSION computation (and powers kills) only — a
|
||||
* def in a call's `resultDefs` is fed exactly through that call, so its
|
||||
* exclusions come from the paths into it; when the tainted input provably
|
||||
* never flows into that call, the floor still taints the def (sound) but
|
||||
* records no kill (a kill requires evidence of flow through the sanitizer).
|
||||
*
|
||||
* ## Propagate-through (KTD5)
|
||||
*
|
||||
* Taint in any argument or in the receiver of an UNMODELED call flows to
|
||||
* the call's result defs, marked `viaCall` on the hop so `explain` can
|
||||
* express lower confidence. An occurrence that reaches the unmodeled call
|
||||
* only through a sanitizer carries the neutralization through
|
||||
* (`const y = unknownFn(escape(b))` excludes the sanitizer's kinds — the
|
||||
* plan's deliberate precision choice over flat-conservative).
|
||||
*
|
||||
* ## Kills output
|
||||
*
|
||||
* `kills` records every sanitizer that ACTUALLY neutralized kinds on a
|
||||
* flowing taint — U4 emits `SANITIZES` edges from them. Two shapes share the
|
||||
* record: result-def kills (`killedDef` = the def the sanitizer produces;
|
||||
* `bindingIdx` = that def's binding) and value-position interposition kills
|
||||
* (`exec(escape(x))` — no def exists; `killedDef` = the sink statement's own
|
||||
* point, `bindingIdx` = the interposed binding). Interposition kills are
|
||||
* recorded only when the (input, sink, position) produced no finding — a
|
||||
* bypassed sanitizer (`exec(x + escape(x))`) killed nothing.
|
||||
*/
|
||||
|
||||
import type { FunctionCfg, SiteRecord, StatementFacts } from '../cfg/types.js';
|
||||
import { pointKey } from '../cfg/reaching-defs.js';
|
||||
import type { DefUseFact, FunctionDefUse, ProgramPoint } from '../cfg/reaching-defs.js';
|
||||
import type {
|
||||
FunctionSiteMatches,
|
||||
MatchedSanitizerCall,
|
||||
MatchedSinkCall,
|
||||
StatementMatches,
|
||||
} from './match.js';
|
||||
import type { SinkKind, SourceKind } from './source-sink-config.js';
|
||||
|
||||
/**
|
||||
* Default per-function findings cap (U5 config resolution; cfg/emit.ts
|
||||
* DEFAULT_* pattern). Resolved into the RepoMeta `pdg` stamp by
|
||||
* `resolvePdgConfig` so a cap change trips full writeback; `0` = unlimited
|
||||
* is preserved like the other pdg caps. 200 is generous — a real function
|
||||
* with more deduped source→sink findings is a fixture or a disaster, and
|
||||
* the truncation is deterministic + counted (`droppedFindings`).
|
||||
*/
|
||||
export const DEFAULT_PDG_MAX_TAINT_FINDINGS_PER_FUNCTION = 200;
|
||||
|
||||
/**
|
||||
* Default per-finding hop cap (U5; joins the RepoMeta `pdg` stamp like the
|
||||
* findings cap). Bounds the persisted `reason` hop encoding (KTD6 pins the
|
||||
* hop cap in config); 32 intra-procedural def→use hops is far beyond any
|
||||
* legible path — overflow keeps the source-side prefix and sets
|
||||
* `hopsTruncated`, parsed downstream as "path incomplete", never an error.
|
||||
*/
|
||||
export const DEFAULT_PDG_MAX_TAINT_HOPS = 32;
|
||||
|
||||
export interface TaintLimits {
|
||||
/**
|
||||
* Maximum findings per function AFTER dedup; the sorted finding list is
|
||||
* truncated deterministically and the overflow counted in
|
||||
* `droppedFindings`. `undefined`/0 ⇒ unlimited.
|
||||
*/
|
||||
readonly maxFindingsPerFunction?: number;
|
||||
/**
|
||||
* Maximum hops retained per finding (source-side prefix kept); overflow
|
||||
* sets `hopsTruncated`. `undefined`/0 ⇒ unlimited.
|
||||
*/
|
||||
readonly maxHops?: number;
|
||||
}
|
||||
|
||||
/** One hop of a finding's path — enough for U4's reason codec (name, line, flag). */
|
||||
export interface TaintHop {
|
||||
/** Index into the function's binding table. */
|
||||
readonly bindingIdx: number;
|
||||
/** Resolved binding name (carried so U4 never re-joins the table). */
|
||||
readonly name: string;
|
||||
readonly point: ProgramPoint;
|
||||
/** The value passed through an unmodeled call to get here (KTD5). */
|
||||
readonly viaCall?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The KTD6 rule-(b) source identity material: the matched member-read
|
||||
* occurrence itself — statement point + site index + object/property. For
|
||||
* worklist findings this is the ROOT source the taint chain was seeded from.
|
||||
*/
|
||||
export interface TaintSourceOccurrence {
|
||||
readonly point: ProgramPoint;
|
||||
/** Index into the source statement's `sites` array. */
|
||||
readonly siteIndex: number;
|
||||
readonly objectBindingIdx: number;
|
||||
readonly property: string;
|
||||
readonly kind: SourceKind;
|
||||
}
|
||||
|
||||
/** The sink side of a finding's identity: point + site + argument + binding. */
|
||||
export interface TaintSinkOccurrence {
|
||||
readonly point: ProgramPoint;
|
||||
/** Index into the sink statement's `sites` array. */
|
||||
readonly siteIndex: number;
|
||||
/** Matched sink argument position the tainted occurrence landed in. */
|
||||
readonly argIndex: number;
|
||||
/**
|
||||
* The binding whose occurrence reached the sink position (for rule-(b)
|
||||
* findings: the source member-read's object binding).
|
||||
*/
|
||||
readonly bindingIdx: number;
|
||||
/** The matched sink entry's `name` (e.g. `exec`) — finding classification. */
|
||||
readonly entryName: string;
|
||||
}
|
||||
|
||||
export interface TaintFinding {
|
||||
readonly sinkKind: SinkKind;
|
||||
readonly source: TaintSourceOccurrence;
|
||||
readonly sink: TaintSinkOccurrence;
|
||||
/**
|
||||
* Ordered source→sink path, one path per finding (the CodeQL
|
||||
* `--max-paths=1` convention): the taint chain's def hops followed by the
|
||||
* sink-use hop. Rule-(b) findings carry the single sink-statement hop.
|
||||
*/
|
||||
readonly hops: readonly TaintHop[];
|
||||
readonly hopsTruncated?: boolean;
|
||||
}
|
||||
|
||||
/** A sanitizer that neutralized kinds on a flowing taint — U4's SANITIZES rows. */
|
||||
export interface SanitizerKill {
|
||||
/** Statement point of the sanitizer call site. */
|
||||
readonly sanitizer: ProgramPoint;
|
||||
/**
|
||||
* The killed def's point (result-def kills — always the sanitizer's own
|
||||
* statement in the intra-statement model) or the suppressed sink
|
||||
* statement's point (value-position interposition kills).
|
||||
*/
|
||||
readonly killedDef: ProgramPoint;
|
||||
/** The killed def's binding, or the interposed binding for value-position kills. */
|
||||
readonly bindingIdx: number;
|
||||
/** Sorted, deduped kinds the sanitizer neutralized at that position. */
|
||||
readonly neutralized: readonly SinkKind[];
|
||||
}
|
||||
|
||||
export interface FunctionTaintResult {
|
||||
/**
|
||||
* `computed` — full propagation ran.
|
||||
* `coverage-gap` — the solver result was not `computed`; the function is
|
||||
* skipped for findings entirely (R4: never partially
|
||||
* analyzed), `gapReason` carries the solver status.
|
||||
*/
|
||||
readonly status: 'computed' | 'coverage-gap';
|
||||
readonly gapReason?: 'truncated' | 'overflow' | 'no-facts';
|
||||
/** Deduped (KTD6 identity), deterministically sorted, capped. */
|
||||
readonly findings: readonly TaintFinding[];
|
||||
readonly kills: readonly SanitizerKill[];
|
||||
/** Findings dropped by `maxFindingsPerFunction` (post-dedup). */
|
||||
readonly droppedFindings: number;
|
||||
}
|
||||
|
||||
/** Canonical SinkKind order for deterministic `neutralized` arrays. */
|
||||
const KIND_ORDER: readonly SinkKind[] = [
|
||||
'code-injection',
|
||||
'command-injection',
|
||||
'path-traversal',
|
||||
'sql-injection',
|
||||
'xss',
|
||||
];
|
||||
const kindRank = new Map<SinkKind, number>(KIND_ORDER.map((k, i) => [k, i]));
|
||||
const sortKinds = (kinds: Iterable<SinkKind>): SinkKind[] =>
|
||||
[...new Set(kinds)].sort((a, b) => (kindRank.get(a) ?? 99) - (kindRank.get(b) ?? 99));
|
||||
|
||||
const EMPTY_KINDS: ReadonlySet<SinkKind> = new Set();
|
||||
|
||||
/** One intra-statement occurrence path (interposition evidence). */
|
||||
interface OccPath {
|
||||
/** Kinds neutralized along the path (union of traversed sanitizer hops). */
|
||||
readonly kinds: ReadonlySet<SinkKind>;
|
||||
/** The path traverses an unmodeled call/new site. */
|
||||
readonly viaCall: boolean;
|
||||
/** Matched sanitizers traversed, with the kinds each contributed. */
|
||||
readonly sanitizers: ReadonlyArray<{ siteIndex: number; kinds: readonly SinkKind[] }>;
|
||||
}
|
||||
|
||||
const DIRECT_PATH: OccPath = { kinds: EMPTY_KINDS, viaCall: false, sanitizers: [] };
|
||||
|
||||
/** Per-statement match/site context, indexed once per visited statement. */
|
||||
interface StmtContext {
|
||||
readonly point: ProgramPoint;
|
||||
readonly facts: StatementFacts;
|
||||
readonly sites: readonly SiteRecord[];
|
||||
readonly sinksBySite: ReadonlyMap<number, readonly MatchedSinkCall[]>;
|
||||
readonly sanitizersBySite: ReadonlyMap<number, readonly MatchedSanitizerCall[]>;
|
||||
/** binding → site indices whose `resultDefs` contain it (kill targets). */
|
||||
readonly resultDefSites: ReadonlyMap<number, readonly number[]>;
|
||||
}
|
||||
|
||||
/** One tainted (binding, defPoint) with the current minimal exclusion set. */
|
||||
interface TaintState {
|
||||
readonly bindingIdx: number;
|
||||
readonly point: ProgramPoint;
|
||||
/** Mutable: only ever SHRINKS (intersection on re-derivation). */
|
||||
exclusions: ReadonlySet<SinkKind>;
|
||||
/** Taint-chain parent key, or undefined for seeds. */
|
||||
parentKey?: string;
|
||||
/** Root source occurrence the chain was seeded from. */
|
||||
source: TaintSourceOccurrence;
|
||||
viaCall: boolean;
|
||||
/** Exclusion-set size at last processing — skips no-op requeues. */
|
||||
processedSize: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute taint flows for one function. See the module doc for the two-rule
|
||||
* model, the kind-set exclusion semantics, and the precision floor.
|
||||
*/
|
||||
export function computeTaintFlows(
|
||||
cfg: FunctionCfg,
|
||||
defUse: FunctionDefUse,
|
||||
matches: FunctionSiteMatches,
|
||||
limits?: TaintLimits,
|
||||
): FunctionTaintResult {
|
||||
if (defUse.status !== 'computed') {
|
||||
return {
|
||||
status: 'coverage-gap',
|
||||
gapReason: defUse.status,
|
||||
findings: [],
|
||||
kills: [],
|
||||
droppedFindings: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const bindings = defUse.bindings;
|
||||
|
||||
// ── per-statement context (built lazily; statements revisit often) ────────
|
||||
const matchByPoint = new Map<string, StatementMatches>();
|
||||
for (const sm of matches.statements) {
|
||||
matchByPoint.set(`${sm.blockIndex}:${sm.statementIndex}`, sm);
|
||||
}
|
||||
const ctxCache = new Map<string, StmtContext | undefined>();
|
||||
const contextAt = (blockIndex: number, stmtIndex: number): StmtContext | undefined => {
|
||||
const key = `${blockIndex}:${stmtIndex}`;
|
||||
if (ctxCache.has(key)) return ctxCache.get(key);
|
||||
const facts = cfg.blocks[blockIndex]?.statements?.[stmtIndex];
|
||||
let ctx: StmtContext | undefined;
|
||||
if (facts) {
|
||||
const sm = matchByPoint.get(key);
|
||||
const sinksBySite = new Map<number, MatchedSinkCall[]>();
|
||||
const sanitizersBySite = new Map<number, MatchedSanitizerCall[]>();
|
||||
for (const s of sm?.sinks ?? []) {
|
||||
const list = sinksBySite.get(s.siteIndex);
|
||||
if (list) list.push(s);
|
||||
else sinksBySite.set(s.siteIndex, [s]);
|
||||
}
|
||||
for (const s of sm?.sanitizers ?? []) {
|
||||
const list = sanitizersBySite.get(s.siteIndex);
|
||||
if (list) list.push(s);
|
||||
else sanitizersBySite.set(s.siteIndex, [s]);
|
||||
}
|
||||
const resultDefSites = new Map<number, number[]>();
|
||||
facts.sites?.forEach((site, siteIndex) => {
|
||||
for (const d of site.resultDefs ?? []) {
|
||||
const list = resultDefSites.get(d);
|
||||
if (list) list.push(siteIndex);
|
||||
else resultDefSites.set(d, [siteIndex]);
|
||||
}
|
||||
});
|
||||
ctx = {
|
||||
point: { blockIndex, stmtIndex, line: facts.line },
|
||||
facts,
|
||||
sites: facts.sites ?? [],
|
||||
sinksBySite,
|
||||
sanitizersBySite,
|
||||
resultDefSites,
|
||||
};
|
||||
}
|
||||
ctxCache.set(key, ctx);
|
||||
return ctx;
|
||||
};
|
||||
|
||||
/** Kinds the matched sanitizers at `siteIndex` neutralize for input position `argPos`. */
|
||||
const neutralizedAt = (ctx: StmtContext, siteIndex: number, argPos: number): SinkKind[] => {
|
||||
const sans = ctx.sanitizersBySite.get(siteIndex);
|
||||
if (!sans) return [];
|
||||
const site = ctx.sites[siteIndex];
|
||||
// Spread/template positions are never provably the sanitized argument —
|
||||
// do not neutralize (sound: under-kill). Exact positions check `args`.
|
||||
if (site.template === true || (site.spread !== undefined && argPos >= site.spread)) return [];
|
||||
const kinds: SinkKind[] = [];
|
||||
for (const san of sans) {
|
||||
if (san.entry.args === undefined || san.entry.args.includes(argPos)) {
|
||||
kinds.push(...san.entry.neutralizes);
|
||||
}
|
||||
}
|
||||
return sortKinds(kinds);
|
||||
};
|
||||
|
||||
/** A call/new site the model does not understand (anything but a matched sanitizer). */
|
||||
const isUnmodeledCall = (ctx: StmtContext, siteIndex: number): boolean => {
|
||||
const site = ctx.sites[siteIndex];
|
||||
return site.kind !== 'member-read' && !ctx.sanitizersBySite.has(siteIndex);
|
||||
};
|
||||
|
||||
const emerge = (ctx: StmtContext, siteIndex: number, argPos: number, inner: OccPath): OccPath => {
|
||||
const added = neutralizedAt(ctx, siteIndex, argPos);
|
||||
return {
|
||||
kinds: added.length === 0 ? inner.kinds : new Set([...inner.kinds, ...added]),
|
||||
viaCall: inner.viaCall || isUnmodeledCall(ctx, siteIndex),
|
||||
sanitizers:
|
||||
added.length === 0 ? inner.sanitizers : [...inner.sanitizers, { siteIndex, kinds: added }],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* STRICT occurrence paths of binding `b` flowing OUT of site `siteIndex`'s
|
||||
* result — found arg entries and the receiver only, each with the site's
|
||||
* own neutralization/viaCall applied. Empty when `b` provably never flows
|
||||
* in (the caller falls back to the floor and records NO kill). `guard`
|
||||
* breaks corrupted-store via cycles (site-safety checks ranges, not
|
||||
* acyclicity).
|
||||
*/
|
||||
const flowsOutOf = (
|
||||
ctx: StmtContext,
|
||||
b: number,
|
||||
siteIndex: number,
|
||||
guard: Set<number>,
|
||||
): OccPath[] => {
|
||||
if (guard.has(siteIndex)) return [];
|
||||
guard.add(siteIndex);
|
||||
const site = ctx.sites[siteIndex];
|
||||
const out: OccPath[] = [];
|
||||
site.args?.forEach((entries, argPos) => {
|
||||
for (const e of entries) {
|
||||
if (typeof e === 'number') {
|
||||
if (e === b) out.push(emerge(ctx, siteIndex, argPos, DIRECT_PATH));
|
||||
} else if (e[0] === b) {
|
||||
// Via-tagged: the occurrence reaches this position THROUGH the
|
||||
// nested site. When the nested site shows no recognized channel
|
||||
// for `b` (callee-chain occurrences — dynamic subscript keys), the
|
||||
// via-tag is still evidence of flow: fall back to a direct,
|
||||
// UN-neutralized path (sound: never a false kill).
|
||||
const inner = flowsOutOf(ctx, b, e[1], guard);
|
||||
const paths =
|
||||
inner.length > 0
|
||||
? inner
|
||||
: [{ ...DIRECT_PATH, viaCall: isUnmodeledCall(ctx, e[1]) } satisfies OccPath];
|
||||
for (const p of paths) out.push(emerge(ctx, siteIndex, argPos, p));
|
||||
}
|
||||
}
|
||||
});
|
||||
if (site.receiver === b) {
|
||||
// Receiver TITO (KTD5): the receiver's value flows through the call
|
||||
// into its result — but a sanitizer does not neutralize its receiver.
|
||||
out.push({ ...DIRECT_PATH, viaCall: isUnmodeledCall(ctx, siteIndex) });
|
||||
}
|
||||
guard.delete(siteIndex);
|
||||
return out;
|
||||
};
|
||||
|
||||
/** Occurrence paths of `b` INTO sink position (s, p) — no emergence from s. */
|
||||
const pathsIntoPosition = (
|
||||
ctx: StmtContext,
|
||||
b: number,
|
||||
siteIndex: number,
|
||||
argPos: number,
|
||||
): OccPath[] => {
|
||||
const entries = ctx.sites[siteIndex].args?.[argPos] ?? [];
|
||||
const out: OccPath[] = [];
|
||||
for (const e of entries) {
|
||||
if (typeof e === 'number') {
|
||||
if (e === b) out.push(DIRECT_PATH);
|
||||
} else if (e[0] === b) {
|
||||
const inner = flowsOutOf(ctx, b, e[1], new Set());
|
||||
if (inner.length > 0) out.push(...inner);
|
||||
else out.push({ ...DIRECT_PATH, viaCall: isUnmodeledCall(ctx, e[1]) });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
/**
|
||||
* Walk a SOURCE member-read's `parent` chain. Linear (each site has one
|
||||
* parent); invokes `onPosition` with the accumulated path BEFORE the
|
||||
* ancestor's own emergence (the value flows INTO the ancestor at that
|
||||
* position) — sink checks and stop-at-site joins both hang off it.
|
||||
*/
|
||||
const climbSourceChain = (
|
||||
ctx: StmtContext,
|
||||
srcSiteIndex: number,
|
||||
onPosition: (siteIndex: number, argPos: number, sofar: OccPath) => boolean,
|
||||
): void => {
|
||||
const visited = new Set<number>([srcSiteIndex]);
|
||||
let cur = ctx.sites[srcSiteIndex];
|
||||
let sofar: OccPath = DIRECT_PATH;
|
||||
while (cur.parent) {
|
||||
const [siteIndex, argPos] = cur.parent;
|
||||
if (visited.has(siteIndex)) return; // corrupted-store parent cycle
|
||||
visited.add(siteIndex);
|
||||
if (onPosition(siteIndex, argPos, sofar)) return;
|
||||
sofar = emerge(ctx, siteIndex, argPos, sofar);
|
||||
cur = ctx.sites[siteIndex];
|
||||
}
|
||||
};
|
||||
|
||||
/** Source path INTO site `target` (with target's emergence), or undefined. */
|
||||
const sourceFlowsOutOf = (
|
||||
ctx: StmtContext,
|
||||
srcSiteIndex: number,
|
||||
target: number,
|
||||
): OccPath | undefined => {
|
||||
let found: OccPath | undefined;
|
||||
climbSourceChain(ctx, srcSiteIndex, (siteIndex, argPos, sofar) => {
|
||||
if (siteIndex !== target) return false;
|
||||
found = emerge(ctx, target, argPos, sofar);
|
||||
return true;
|
||||
});
|
||||
return found;
|
||||
};
|
||||
|
||||
// ── accumulators ──────────────────────────────────────────────────────────
|
||||
const findingsByIdentity = new Map<string, TaintFinding>();
|
||||
const killsByIdentity = new Map<
|
||||
string,
|
||||
{ kill: Omit<SanitizerKill, 'neutralized'>; kinds: Set<SinkKind> }
|
||||
>();
|
||||
|
||||
const recordKill = (
|
||||
sanitizer: ProgramPoint,
|
||||
killedDef: ProgramPoint,
|
||||
bindingIdx: number,
|
||||
kinds: readonly SinkKind[],
|
||||
): void => {
|
||||
if (kinds.length === 0) return;
|
||||
const key = `${pointKey(sanitizer)}|${pointKey(killedDef)}|${bindingIdx}`;
|
||||
const existing = killsByIdentity.get(key);
|
||||
if (existing) for (const k of kinds) existing.kinds.add(k);
|
||||
else
|
||||
killsByIdentity.set(key, {
|
||||
kill: { sanitizer, killedDef, bindingIdx },
|
||||
kinds: new Set(kinds),
|
||||
});
|
||||
};
|
||||
|
||||
// KTD6 statement-level finding identity: source occurrence + sink occurrence
|
||||
// + kind (NOT entryName). Computed standalone so the worklist can dedup-check
|
||||
// BEFORE the cost of chainHops (first write wins; dedup-before-budget).
|
||||
const findingKey = (
|
||||
sinkKind: SinkKind,
|
||||
source: TaintSourceOccurrence,
|
||||
sink: Pick<TaintSinkOccurrence, 'point' | 'siteIndex' | 'argIndex' | 'bindingIdx'>,
|
||||
): string =>
|
||||
[
|
||||
sinkKind,
|
||||
pointKey(source.point),
|
||||
source.siteIndex,
|
||||
source.objectBindingIdx,
|
||||
source.property,
|
||||
pointKey(sink.point),
|
||||
sink.siteIndex,
|
||||
sink.argIndex,
|
||||
sink.bindingIdx,
|
||||
].join('|');
|
||||
|
||||
const recordFinding = (
|
||||
sinkKind: SinkKind,
|
||||
source: TaintSourceOccurrence,
|
||||
sink: TaintSinkOccurrence,
|
||||
hops: TaintHop[],
|
||||
hopsTruncated: boolean,
|
||||
): void => {
|
||||
const key = findingKey(sinkKind, source, sink);
|
||||
if (findingsByIdentity.has(key)) return;
|
||||
const maxHops = limits?.maxHops && limits.maxHops > 0 ? limits.maxHops : Infinity;
|
||||
let truncated = hopsTruncated;
|
||||
let kept = hops;
|
||||
if (hops.length > maxHops) {
|
||||
kept = hops.slice(0, maxHops);
|
||||
truncated = true;
|
||||
}
|
||||
findingsByIdentity.set(key, {
|
||||
sinkKind,
|
||||
source,
|
||||
sink,
|
||||
hops: kept,
|
||||
...(truncated ? { hopsTruncated: true } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
// ── taint state ───────────────────────────────────────────────────────────
|
||||
const taints = new Map<string, TaintState>();
|
||||
const queue: string[] = [];
|
||||
|
||||
/** The (binding, def-point) portion of a state key — the def→use fact-table
|
||||
* lookup key, source-independent. */
|
||||
const defKey = (bindingIdx: number, point: ProgramPoint): string =>
|
||||
`${bindingIdx}:${pointKey(point)}`;
|
||||
/** Full taint-state key: the def-point portion plus a ROOT source-occurrence
|
||||
* discriminator ({point, siteIndex} — the same source fields recordFinding's
|
||||
* identity uses, deliberately excluding `kind`). Distinct sources reaching
|
||||
* one def get distinct states, so a second source is no longer dropped
|
||||
* (KTD6); same-source multi-path derivations still share a key so their
|
||||
* exclusion sets intersect (the raw arm soundly wins). */
|
||||
const stateKey = (
|
||||
bindingIdx: number,
|
||||
point: ProgramPoint,
|
||||
source: TaintSourceOccurrence,
|
||||
): string => `${defKey(bindingIdx, point)}#${pointKey(source.point)}:${source.siteIndex}`;
|
||||
|
||||
const deriveTaint = (
|
||||
bindingIdx: number,
|
||||
point: ProgramPoint,
|
||||
exclusions: ReadonlySet<SinkKind>,
|
||||
parentKey: string | undefined,
|
||||
source: TaintSourceOccurrence,
|
||||
viaCall: boolean,
|
||||
): void => {
|
||||
const key = stateKey(bindingIdx, point, source);
|
||||
const existing = taints.get(key);
|
||||
if (!existing) {
|
||||
taints.set(key, {
|
||||
bindingIdx,
|
||||
point,
|
||||
exclusions,
|
||||
parentKey,
|
||||
source,
|
||||
viaCall,
|
||||
processedSize: -1,
|
||||
});
|
||||
queue.push(key);
|
||||
return;
|
||||
}
|
||||
// Monotone shrink: keep the intersection; re-process only when it got
|
||||
// strictly smaller (a less-neutralized derivation is more dangerous).
|
||||
const inter = new Set<SinkKind>();
|
||||
for (const k of existing.exclusions) if (exclusions.has(k)) inter.add(k);
|
||||
if (inter.size < existing.exclusions.size) {
|
||||
existing.exclusions = inter;
|
||||
existing.parentKey = parentKey;
|
||||
existing.source = source;
|
||||
existing.viaCall = viaCall;
|
||||
queue.push(key);
|
||||
}
|
||||
};
|
||||
|
||||
/** Taint-chain hops from seed to `key`, with a cycle guard (re-derivation
|
||||
* can rewire parents into a loop — truncate instead of spinning). */
|
||||
const chainHops = (key: string): { hops: TaintHop[]; truncated: boolean } => {
|
||||
const reversed: TaintHop[] = [];
|
||||
const seen = new Set<string>();
|
||||
let cur: string | undefined = key;
|
||||
let truncated = false;
|
||||
while (cur !== undefined) {
|
||||
if (seen.has(cur)) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
seen.add(cur);
|
||||
const t = taints.get(cur);
|
||||
if (!t) break;
|
||||
reversed.push({
|
||||
bindingIdx: t.bindingIdx,
|
||||
name: bindings[t.bindingIdx]?.name ?? `#${t.bindingIdx}`,
|
||||
point: t.point,
|
||||
...(t.viaCall ? { viaCall: true } : {}),
|
||||
});
|
||||
cur = t.parentKey;
|
||||
}
|
||||
return { hops: reversed.reverse(), truncated };
|
||||
};
|
||||
|
||||
/** Intersection of path kind-sets; viaCall = any path through a call. */
|
||||
const summarizePaths = (
|
||||
paths: readonly OccPath[],
|
||||
): { kinds: ReadonlySet<SinkKind>; viaCall: boolean } => {
|
||||
let kinds: ReadonlySet<SinkKind> | undefined;
|
||||
let viaCall = false;
|
||||
for (const p of paths) {
|
||||
viaCall ||= p.viaCall;
|
||||
if (kinds === undefined) {
|
||||
kinds = p.kinds;
|
||||
} else {
|
||||
const inter = new Set<SinkKind>();
|
||||
for (const k of kinds) if (p.kinds.has(k)) inter.add(k);
|
||||
kinds = inter;
|
||||
}
|
||||
}
|
||||
return { kinds: kinds ?? EMPTY_KINDS, viaCall };
|
||||
};
|
||||
|
||||
/**
|
||||
* Taint every def of `ctx`'s statement from one input. `pathsInto(c)`
|
||||
* supplies the input's strict occurrence paths into call site `c` —
|
||||
* defining the resultDefs precision and the kill evidence; an empty list
|
||||
* means "no provable flow" and the floor applies (taint with the input's
|
||||
* own exclusions, no kill).
|
||||
*/
|
||||
const feedDefs = (
|
||||
ctx: StmtContext,
|
||||
inputExclusions: ReadonlySet<SinkKind>,
|
||||
parentKey: string | undefined,
|
||||
source: TaintSourceOccurrence,
|
||||
pathsInto: (siteIndex: number) => OccPath[],
|
||||
): void => {
|
||||
const defs = [...ctx.facts.defs, ...(ctx.facts.mayDefs ?? [])];
|
||||
if (defs.length === 0) return;
|
||||
const seen = new Set<number>();
|
||||
for (const d of defs) {
|
||||
if (seen.has(d)) continue;
|
||||
seen.add(d);
|
||||
const rdSites = ctx.resultDefSites.get(d);
|
||||
let addKinds: ReadonlySet<SinkKind> = EMPTY_KINDS;
|
||||
let viaCall = false;
|
||||
if (rdSites) {
|
||||
const paths: OccPath[] = [];
|
||||
for (const c of rdSites) paths.push(...pathsInto(c));
|
||||
if (paths.length > 0) {
|
||||
const summary = summarizePaths(paths);
|
||||
addKinds = summary.kinds;
|
||||
viaCall = summary.viaCall;
|
||||
for (const p of paths) {
|
||||
for (const san of p.sanitizers) {
|
||||
recordKill(ctx.point, ctx.point, d, san.kinds);
|
||||
}
|
||||
}
|
||||
}
|
||||
// else: floor — tainted with no exclusions added, no kill (the input
|
||||
// provably never flows into the producing call; conflation FP pinned).
|
||||
}
|
||||
const exclusions =
|
||||
addKinds.size === 0 ? inputExclusions : new Set([...inputExclusions, ...addKinds]);
|
||||
deriveTaint(d, ctx.point, exclusions, parentKey, source, viaCall);
|
||||
}
|
||||
};
|
||||
|
||||
// ── rule (b) + seeding: statements with matched sources ───────────────────
|
||||
for (const sm of matches.statements) {
|
||||
if (sm.sources.length === 0) continue;
|
||||
const ctx = contextAt(sm.blockIndex, sm.statementIndex);
|
||||
if (!ctx) continue;
|
||||
for (const src of sm.sources) {
|
||||
const srcSite = ctx.sites[src.siteIndex];
|
||||
if (srcSite?.object === undefined || srcSite.property === undefined) continue;
|
||||
const sourceOcc: TaintSourceOccurrence = {
|
||||
point: ctx.point,
|
||||
siteIndex: src.siteIndex,
|
||||
objectBindingIdx: srcSite.object,
|
||||
property: srcSite.property,
|
||||
kind: src.entry.kind,
|
||||
};
|
||||
|
||||
// Statement-local sink checks along the member-read's parent chain.
|
||||
climbSourceChain(ctx, src.siteIndex, (siteIndex, argPos, sofar) => {
|
||||
for (const sink of ctx.sinksBySite.get(siteIndex) ?? []) {
|
||||
if (!sink.argPositions.includes(argPos)) continue;
|
||||
const kind = sink.entry.kind;
|
||||
if (!sofar.kinds.has(kind)) {
|
||||
recordFinding(
|
||||
kind,
|
||||
sourceOcc,
|
||||
{
|
||||
point: ctx.point,
|
||||
siteIndex,
|
||||
argIndex: argPos,
|
||||
bindingIdx: srcSite.object as number,
|
||||
entryName: sink.entry.name,
|
||||
},
|
||||
[
|
||||
{
|
||||
bindingIdx: srcSite.object as number,
|
||||
name: bindings[srcSite.object as number]?.name ?? `#${srcSite.object}`,
|
||||
point: ctx.point,
|
||||
...(sofar.viaCall ? { viaCall: true } : {}),
|
||||
},
|
||||
],
|
||||
false,
|
||||
);
|
||||
} else {
|
||||
for (const san of sofar.sanitizers) {
|
||||
if (san.kinds.includes(kind)) {
|
||||
recordKill(ctx.point, ctx.point, srcSite.object as number, san.kinds);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// Seed every def of the statement (precision floor + resultDefs kills).
|
||||
feedDefs(ctx, EMPTY_KINDS, undefined, sourceOcc, (c) => {
|
||||
const p = sourceFlowsOutOf(ctx, src.siteIndex, c);
|
||||
return p ? [p] : [];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── rule (a): worklist over def→use facts ─────────────────────────────────
|
||||
const factsByDef = new Map<string, DefUseFact[]>();
|
||||
for (const f of defUse.facts) {
|
||||
const key = defKey(f.bindingIdx, f.def);
|
||||
const list = factsByDef.get(key);
|
||||
if (list) list.push(f);
|
||||
else factsByDef.set(key, [f]);
|
||||
}
|
||||
|
||||
// Strict-FIFO worklist via a head cursor (not Array.shift, which is O(N) per
|
||||
// dequeue). FIFO order is load-bearing beyond perf: chainHops reconstructs
|
||||
// hops from the live `taints` map, whose parentKey/source/viaCall are
|
||||
// rewritten order-sensitively on monotone shrink — so hop-content
|
||||
// determinism is contingent on dequeue order matching enqueue order. Do NOT
|
||||
// sort or reprioritize the worklist.
|
||||
let head = 0;
|
||||
while (head < queue.length) {
|
||||
const key = queue[head++];
|
||||
// Reclaim the consumed prefix periodically so the array doesn't grow
|
||||
// unbounded across a long run (order-preserving — pure memory hygiene).
|
||||
if (head > 1024 && head * 2 > queue.length) {
|
||||
queue.splice(0, head);
|
||||
head = 0;
|
||||
}
|
||||
const t = taints.get(key) as TaintState;
|
||||
if (t.processedSize === t.exclusions.size) continue; // no-op requeue
|
||||
t.processedSize = t.exclusions.size;
|
||||
const b = t.bindingIdx;
|
||||
const E = t.exclusions;
|
||||
|
||||
// Facts are keyed by (binding, def-point) only — look up by the def portion
|
||||
// of this state, not the source-discriminated state key.
|
||||
for (const fact of factsByDef.get(defKey(b, t.point)) ?? []) {
|
||||
const ctx = contextAt(fact.use.blockIndex, fact.use.stmtIndex);
|
||||
if (!ctx) continue;
|
||||
|
||||
// Sink check: occurrences of `b` at matched sink argument positions.
|
||||
for (const [siteIndex, sinks] of ctx.sinksBySite) {
|
||||
for (const sink of sinks) {
|
||||
const kind = sink.entry.kind;
|
||||
for (const argPos of sink.argPositions) {
|
||||
const paths = pathsIntoPosition(ctx, b, siteIndex, argPos);
|
||||
if (paths.length === 0) continue;
|
||||
if (E.has(kind)) continue; // suppressed at def time; kill already recorded
|
||||
const justify = paths.find((p) => !p.kinds.has(kind));
|
||||
if (justify) {
|
||||
const sinkOcc = {
|
||||
point: ctx.point,
|
||||
siteIndex,
|
||||
argIndex: argPos,
|
||||
bindingIdx: b,
|
||||
entryName: sink.entry.name,
|
||||
};
|
||||
// Dedup BEFORE chainHops: already-recorded identities discard
|
||||
// their hop chain anyway (first write wins), so skip the walk.
|
||||
if (findingsByIdentity.has(findingKey(kind, t.source, sinkOcc))) continue;
|
||||
const chain = chainHops(key);
|
||||
chain.hops.push({
|
||||
bindingIdx: b,
|
||||
name: bindings[b]?.name ?? `#${b}`,
|
||||
point: ctx.point,
|
||||
...(justify.viaCall ? { viaCall: true } : {}),
|
||||
});
|
||||
recordFinding(kind, t.source, sinkOcc, chain.hops, chain.truncated);
|
||||
} else {
|
||||
// EVERY path interposed — value-position kill(s) held.
|
||||
for (const p of paths) {
|
||||
for (const san of p.sanitizers) {
|
||||
if (san.kinds.includes(kind)) recordKill(ctx.point, ctx.point, b, san.kinds);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Def-feed: the use statement's own defs become tainted.
|
||||
feedDefs(ctx, E, key, t.source, (c) => flowsOutOf(ctx, b, c, new Set()));
|
||||
}
|
||||
}
|
||||
|
||||
// ── deterministic assembly ────────────────────────────────────────────────
|
||||
const comparePoints = (a: ProgramPoint, b: ProgramPoint): number =>
|
||||
a.blockIndex - b.blockIndex || a.stmtIndex - b.stmtIndex;
|
||||
|
||||
const findings = [...findingsByIdentity.values()].sort(
|
||||
(a, b) =>
|
||||
comparePoints(a.source.point, b.source.point) ||
|
||||
a.source.siteIndex - b.source.siteIndex ||
|
||||
comparePoints(a.sink.point, b.sink.point) ||
|
||||
a.sink.siteIndex - b.sink.siteIndex ||
|
||||
a.sink.argIndex - b.sink.argIndex ||
|
||||
a.sink.bindingIdx - b.sink.bindingIdx ||
|
||||
(kindRank.get(a.sinkKind) ?? 99) - (kindRank.get(b.sinkKind) ?? 99),
|
||||
);
|
||||
const maxFindings =
|
||||
limits?.maxFindingsPerFunction && limits.maxFindingsPerFunction > 0
|
||||
? limits.maxFindingsPerFunction
|
||||
: Infinity;
|
||||
const kept = findings.length > maxFindings ? findings.slice(0, maxFindings) : findings;
|
||||
|
||||
const kills = [...killsByIdentity.values()]
|
||||
.map(({ kill, kinds }) => ({ ...kill, neutralized: sortKinds(kinds) }))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
comparePoints(a.sanitizer, b.sanitizer) ||
|
||||
comparePoints(a.killedDef, b.killedDef) ||
|
||||
a.bindingIdx - b.bindingIdx,
|
||||
);
|
||||
|
||||
return {
|
||||
status: 'computed',
|
||||
findings: kept,
|
||||
kills,
|
||||
droppedFindings: findings.length - kept.length,
|
||||
};
|
||||
}
|
||||
103
gitnexus/src/core/ingestion/taint/site-safety.ts
Normal file
103
gitnexus/src/core/ingestion/taint/site-safety.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
/**
|
||||
* Taint-site safety validation (#2083 M3 U1, plan KTD2).
|
||||
*
|
||||
* Mirrors `hasEmitSafeFacts` (cfg/emit.ts): an untrusted `cfgSideChannel`
|
||||
* element — possibly from a corrupted durable parsedfile store — must never
|
||||
* crash the taint pass or fabricate matches from out-of-range indices. The
|
||||
* degradation contract is per-FUNCTION and one-directional: a CFG whose sites
|
||||
* fail this check is SKIPPED FOR TAINT ONLY — the BasicBlock/CFG layer and
|
||||
* the REACHING_DEF projection (guarded by their own checks) are unaffected.
|
||||
*
|
||||
* Checked: exactly the indices the taint matcher dereferences — binding
|
||||
* indices (`receiver`/`object`/`resultDefs`/arg occurrences) against the
|
||||
* function's binding table, and intra-statement site references (`parent`
|
||||
* site / via-tags) against the OWNING statement's `sites` array. Site
|
||||
* references are statement-local by construction (each statement's
|
||||
* FactAccumulator starts at index 0); a cross-statement reference is
|
||||
* corruption, not a feature.
|
||||
*
|
||||
* Lives in `taint/` (not cfg/emit.ts): U4's taint emit path is the only
|
||||
* consumer, and the guard must evolve with the matcher that dereferences
|
||||
* these fields.
|
||||
*/
|
||||
import type { FunctionCfg, SiteRecord } from '../cfg/types.js';
|
||||
|
||||
const SITE_KINDS = new Set<SiteRecord['kind']>(['call', 'new', 'member-read']);
|
||||
|
||||
/**
|
||||
* Whether a structurally-valid CFG's M3 `sites` annotations are safe to feed
|
||||
* to the taint matcher/propagator. `true` when no statement carries sites
|
||||
* (pre-M3 channel, or no calls) — absence is the well-formed empty case.
|
||||
*/
|
||||
export const hasTaintSafeSites = (cfg: FunctionCfg): boolean => {
|
||||
// Sites carry binding indices — a channel with sites but no binding table
|
||||
// has nothing to range-check them against: reject (checked per statement).
|
||||
const bindingCount = Array.isArray(cfg.bindings) ? cfg.bindings.length : -1;
|
||||
for (const block of cfg.blocks) {
|
||||
const stmts = block.statements;
|
||||
if (stmts === undefined) continue;
|
||||
if (!Array.isArray(stmts)) return false;
|
||||
for (const s of stmts) {
|
||||
if (s?.sites === undefined) continue;
|
||||
if (bindingCount < 0) return false;
|
||||
if (!isSafeSiteList(s.sites, bindingCount)) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const isSafeSiteList = (sites: unknown, bindingCount: number): boolean => {
|
||||
if (!Array.isArray(sites)) return false;
|
||||
const siteCount = sites.length;
|
||||
const bindingInRange = (i: unknown): boolean =>
|
||||
Number.isInteger(i) && (i as number) >= 0 && (i as number) < bindingCount;
|
||||
const siteInRange = (i: unknown): boolean =>
|
||||
Number.isInteger(i) && (i as number) >= 0 && (i as number) < siteCount;
|
||||
|
||||
for (const site of sites as ReadonlyArray<Partial<SiteRecord> | null | undefined>) {
|
||||
if (site === null || typeof site !== 'object') return false;
|
||||
if (typeof site.kind !== 'string' || !SITE_KINDS.has(site.kind)) return false;
|
||||
if (site.callee !== undefined && typeof site.callee !== 'string') return false;
|
||||
if (site.receiver !== undefined && !bindingInRange(site.receiver)) return false;
|
||||
if (site.requireArg !== undefined && typeof site.requireArg !== 'string') return false;
|
||||
if (site.template !== undefined && typeof site.template !== 'boolean') return false;
|
||||
if (
|
||||
site.spread !== undefined &&
|
||||
(!Number.isInteger(site.spread) || (site.spread as number) < 0)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (site.parent !== undefined) {
|
||||
const p = site.parent;
|
||||
if (!Array.isArray(p) || p.length !== 2) return false;
|
||||
if (!siteInRange(p[0])) return false;
|
||||
if (!Number.isInteger(p[1]) || (p[1] as number) < 0) return false;
|
||||
}
|
||||
if (site.resultDefs !== undefined) {
|
||||
if (!Array.isArray(site.resultDefs) || !site.resultDefs.every(bindingInRange)) return false;
|
||||
}
|
||||
if (site.args !== undefined) {
|
||||
if (!Array.isArray(site.args)) return false;
|
||||
for (const position of site.args) {
|
||||
if (!Array.isArray(position)) return false;
|
||||
for (const entry of position) {
|
||||
if (typeof entry === 'number') {
|
||||
if (!bindingInRange(entry)) return false;
|
||||
} else if (Array.isArray(entry) && entry.length === 2) {
|
||||
if (!bindingInRange(entry[0]) || !siteInRange(entry[1])) return false;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (site.kind === 'member-read') {
|
||||
// The matcher dereferences both unconditionally on member reads.
|
||||
if (!bindingInRange(site.object) || typeof site.property !== 'string') return false;
|
||||
} else {
|
||||
if (site.object !== undefined && !bindingInRange(site.object)) return false;
|
||||
if (site.property !== undefined && typeof site.property !== 'string') return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
|
@ -1,38 +1,119 @@
|
|||
/**
|
||||
* Source/sink/sanitizer config model (issue #2080, taint/PDG substrate M0).
|
||||
* Source/sink/sanitizer config model (issue #2080 M0 seam, extended by #2083
|
||||
* M3 U2).
|
||||
*
|
||||
* The per-language taint configuration *shape*. M0 ships only the type and an
|
||||
* (empty) registry seam — no analysis consumes it yet. M3 (#2083, intra-proc
|
||||
* taint) populates per-language specs and reads them when emitting TAINTED /
|
||||
* SANITIZES edges.
|
||||
* The per-language taint configuration *shape*. M0 shipped only the bare
|
||||
* `{name, args?}` callable matcher and an empty registry seam; M3 U2 extends
|
||||
* it with the `kind` taxonomy and the resolution-mechanism fields the
|
||||
* import-aware matcher (`taint/match.ts`) needs, and fills the registry with
|
||||
* the built-in TS/JS model (`taint/typescript-model.ts`).
|
||||
*
|
||||
* Kept deliberately minimal: enough for M3 to express "callable X is a
|
||||
* source / sink / sanitizer, optionally for argument position N" without M0
|
||||
* committing to matcher semantics it cannot yet validate. The shape is
|
||||
* expected to grow (e.g. sanitizer escape conditions, return-position taint)
|
||||
* when M3 makes contact with real flows; that is a forward-declared-interface
|
||||
* design choice, not a finished contract.
|
||||
* Design rule: entries describe WHAT a callable is (category + how its name
|
||||
* resolves), never HOW matching works — matching semantics (import joins,
|
||||
* shadow checks, spread/template position rules) live in the matcher so the
|
||||
* spec stays declarative data that can hash into `taintModelVersion`.
|
||||
*/
|
||||
|
||||
/** Categories of taint sources. M3 ships remote HTTP input only. */
|
||||
export type SourceKind = 'remote-input';
|
||||
|
||||
/**
|
||||
* Identifies a callable that participates in taint flow. `name` is matched
|
||||
* against a resolved callable (simple or qualified name — exact matching
|
||||
* semantics are M3's call). `args` optionally narrows to specific 0-based
|
||||
* argument positions that carry taint (for a source/sink) or clear it (for a
|
||||
* sanitizer); omit to mean "unspecified / all".
|
||||
* Vulnerability categories for sinks. Sanitizers reference the SAME taxonomy
|
||||
* via {@link TaintSanitizerEntry.neutralizes}: a sanitizer kill applies only
|
||||
* when it neutralizes the matched sink's kind (`path.basename` strips
|
||||
* directories, not shell metacharacters — a kind-blind kill is a suppressed
|
||||
* live command injection, the forbidden false-negative direction).
|
||||
*/
|
||||
export interface TaintCallableMatcher {
|
||||
export type SinkKind =
|
||||
| 'command-injection'
|
||||
| 'code-injection'
|
||||
| 'path-traversal'
|
||||
| 'sql-injection'
|
||||
| 'xss';
|
||||
|
||||
/**
|
||||
* Identifies a callable that participates in taint flow. `name` is the
|
||||
* callable's own (unqualified) name — qualification comes from the
|
||||
* resolution-mechanism fields on the extending entry types, not from dotted
|
||||
* `name` strings. `args` optionally narrows to specific 0-based argument
|
||||
* positions that carry taint into a sink (or are cleared by a sanitizer);
|
||||
* omit to mean "all positions".
|
||||
*/
|
||||
export interface TaintCallableMatcher<K extends string = string> {
|
||||
readonly name: string;
|
||||
readonly args?: readonly number[];
|
||||
/** Category label — drives finding classification and sanitizer kind-compat. */
|
||||
readonly kind: K;
|
||||
}
|
||||
|
||||
/**
|
||||
* The taint configuration for a single language: which callables introduce
|
||||
* taint (sources), which are dangerous to reach with tainted input (sinks),
|
||||
* and which clear taint (sanitizers).
|
||||
* A sink callable. Exactly one resolution mechanism should be set per entry:
|
||||
*
|
||||
* - `module` — the callable lives in a package/builtin module; the matcher
|
||||
* resolves call sites against it import-aware (ESM `parsedImports` aliases,
|
||||
* namespace handles, and the CommonJS `require('<literal>')` join). `name`
|
||||
* is the exported member (`'exec'` of `'child_process'`); the pseudo-name
|
||||
* `'default'` denotes invoking the module's default export / the module
|
||||
* handle itself.
|
||||
* - `global` — a true ECMAScript global (`eval`, `Function`); matched by bare
|
||||
* name only when the name is not shadowed by an in-function declaration and
|
||||
* not bound by an import. `newOnly` further restricts to `new` expressions
|
||||
* (`new Function(body)`).
|
||||
* - `anyReceiver` — a method matched on ANY receiver chain by its final
|
||||
* segment (`.query(sql)` / `.execute(sql)` on whatever the DB handle is
|
||||
* named) — deliberately name-conventional, like Semgrep's default rules.
|
||||
* - `receivers` — a method matched only on the listed conventional receiver
|
||||
* names (`res.send` / `res.write`); exactly `<receiver>.<name>`, name-based.
|
||||
*/
|
||||
export interface TaintSinkEntry extends TaintCallableMatcher<SinkKind> {
|
||||
readonly module?: string;
|
||||
readonly global?: boolean;
|
||||
/** Only meaningful with `global`: match `new <name>(…)` sites only. */
|
||||
readonly newOnly?: boolean;
|
||||
readonly anyReceiver?: boolean;
|
||||
readonly receivers?: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A sanitizer callable. Carries the sink kinds it `neutralizes` instead of a
|
||||
* `kind` of its own. STRICTER resolution than sinks by design: only the
|
||||
* `module` (import-aware) and `global` mechanisms exist — never a bare-name
|
||||
* convention — because a sanitizer mis-match is a false KILL (a user's own
|
||||
* `escape` helper must not suppress findings), while a sink mis-match is
|
||||
* merely noise. `args` narrows which argument positions are cleared (omit =
|
||||
* all).
|
||||
*/
|
||||
export interface TaintSanitizerEntry {
|
||||
readonly name: string;
|
||||
readonly args?: readonly number[];
|
||||
readonly neutralizes: readonly SinkKind[];
|
||||
readonly module?: string;
|
||||
readonly global?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A member-read taint source: reading `<object>.<property>` where the object
|
||||
* is one of the conventional receiver `objects` names (`req`/`request`) and
|
||||
* the property is one of `properties` (`body`, `query`, …). Matching is
|
||||
* name-based on the harvested `member-read` site (Semgrep-convention, not
|
||||
* type-aware — the accepted M3 FP/FN trade recorded in the plan's risk
|
||||
* table). One entry fans out over the objects × properties product.
|
||||
*/
|
||||
export interface TaintMemberSourceEntry {
|
||||
readonly kind: SourceKind;
|
||||
readonly objects: readonly string[];
|
||||
readonly properties: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The taint configuration for a single language: which member reads introduce
|
||||
* taint (sources), which callables are dangerous to reach with tainted input
|
||||
* (sinks), and which callables clear it (sanitizers). M3 sources are
|
||||
* member-read entries only; call-result sources are a forward extension
|
||||
* (add a union variant), not a missing case.
|
||||
*/
|
||||
export interface SourceSinkSanitizerSpec {
|
||||
readonly sources: readonly TaintCallableMatcher[];
|
||||
readonly sinks: readonly TaintCallableMatcher[];
|
||||
readonly sanitizers: readonly TaintCallableMatcher[];
|
||||
readonly sources: readonly TaintMemberSourceEntry[];
|
||||
readonly sinks: readonly TaintSinkEntry[];
|
||||
readonly sanitizers: readonly TaintSanitizerEntry[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
108
gitnexus/src/core/ingestion/taint/typescript-model.ts
Normal file
108
gitnexus/src/core/ingestion/taint/typescript-model.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
/**
|
||||
* Built-in TS/JS taint model (#2083 M3 U2, plan KTD7).
|
||||
*
|
||||
* The canonical Express/Node source/sink/sanitizer set, registered for the
|
||||
* `typescript` and `javascript` language ids via the EXPLICIT
|
||||
* {@link registerBuiltinTaintModels} seam — deliberately not an import
|
||||
* side-effect, so the U4 emit path controls WHEN registration happens (call
|
||||
* it once before the pdg window runs; it is idempotent — the registry is
|
||||
* last-write-wins on the same language id).
|
||||
*
|
||||
* `taintModelVersion` is a deterministic digest of the FULL model content
|
||||
* (entries, kinds, args, modules). It joins the RepoMeta `pdg` stamp in U5 so
|
||||
* that ANY model change — adding an entry, relabeling a kind — trips full
|
||||
* writeback on an existing `--pdg` index (R7): persisted findings must never
|
||||
* outlive the model that produced them.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type { SourceSinkSanitizerSpec } from './source-sink-config.js';
|
||||
import { registerSourceSinkConfig } from './source-sink-registry.js';
|
||||
|
||||
/**
|
||||
* The built-in TS/JS model. Module provenance uses bare specifier names —
|
||||
* the matcher normalizes the `node:` scheme prefix, so `import { exec } from
|
||||
* 'node:child_process'` resolves identically.
|
||||
*/
|
||||
export const TS_JS_TAINT_MODEL: SourceSinkSanitizerSpec = {
|
||||
sources: [
|
||||
// Express-convention request member reads, matched name-based on the
|
||||
// receiver (`req`/`request`) — the plan's accepted Semgrep-style trade.
|
||||
{
|
||||
kind: 'remote-input',
|
||||
objects: ['req', 'request'],
|
||||
properties: ['body', 'query', 'params', 'headers', 'cookies'],
|
||||
},
|
||||
],
|
||||
sinks: [
|
||||
// Command execution — the command string is argument 0.
|
||||
{ name: 'exec', kind: 'command-injection', args: [0], module: 'child_process' },
|
||||
{ name: 'execSync', kind: 'command-injection', args: [0], module: 'child_process' },
|
||||
{ name: 'spawn', kind: 'command-injection', args: [0], module: 'child_process' },
|
||||
// Code evaluation. `eval` takes code at 0; `new Function(...)` treats
|
||||
// EVERY argument as source text (params + body), so `args` is omitted
|
||||
// (= all positions) rather than pinned to 0.
|
||||
{ name: 'eval', kind: 'code-injection', args: [0], global: true },
|
||||
{ name: 'Function', kind: 'code-injection', global: true, newOnly: true },
|
||||
// Filesystem path consumption — path argument 0.
|
||||
{ name: 'readFile', kind: 'path-traversal', args: [0], module: 'fs' },
|
||||
{ name: 'readFileSync', kind: 'path-traversal', args: [0], module: 'fs' },
|
||||
{ name: 'writeFile', kind: 'path-traversal', args: [0], module: 'fs' },
|
||||
{ name: 'writeFileSync', kind: 'path-traversal', args: [0], module: 'fs' },
|
||||
// SQL — `.query(sql)` / `.execute(sql)` member calls on ANY receiver
|
||||
// (mysql2/pg/knex handles go by many names; receiver-conventional).
|
||||
{ name: 'query', kind: 'sql-injection', args: [0], anyReceiver: true },
|
||||
{ name: 'execute', kind: 'sql-injection', args: [0], anyReceiver: true },
|
||||
// Reflected XSS — Express response writes, conventional receiver `res`.
|
||||
{ name: 'send', kind: 'xss', args: [0], receivers: ['res'] },
|
||||
{ name: 'write', kind: 'xss', args: [0], receivers: ['res'] },
|
||||
],
|
||||
sanitizers: [
|
||||
// URL-encoding: neutralizes markup injection AND path separators
|
||||
// (`%2F` is not a separator inside a path component).
|
||||
{ name: 'encodeURIComponent', neutralizes: ['xss', 'path-traversal'], global: true },
|
||||
// `escape-html` exports its function as the module default — the
|
||||
// `'default'` pseudo-name matches the default-imported / require'd
|
||||
// module handle being invoked directly.
|
||||
{ name: 'default', neutralizes: ['xss'], module: 'escape-html' },
|
||||
{ name: 'encode', neutralizes: ['xss'], module: 'he' },
|
||||
{ name: 'basename', neutralizes: ['path-traversal'], module: 'path' },
|
||||
{ name: 'escape', neutralizes: ['xss'], module: 'validator' },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Deterministic digest of a spec's full content. Key order is canonicalized
|
||||
* (recursively sorted) so the version reflects CONTENT, not literal layout;
|
||||
* array order is semantic (entry identity) and intentionally preserved.
|
||||
*/
|
||||
export function computeTaintModelVersion(spec: SourceSinkSanitizerSpec): string {
|
||||
return createHash('sha256').update(canonicalJson(spec)).digest('hex').slice(0, 12);
|
||||
}
|
||||
|
||||
function canonicalJson(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
|
||||
if (value !== null && typeof value === 'object') {
|
||||
const entries = Object.entries(value as Record<string, unknown>)
|
||||
.filter(([, v]) => v !== undefined)
|
||||
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
||||
.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`);
|
||||
return `{${entries.join(',')}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
/** Version stamp of the built-in TS/JS model (joins the RepoMeta pdg key in U5). */
|
||||
export const taintModelVersion: string = computeTaintModelVersion(TS_JS_TAINT_MODEL);
|
||||
|
||||
/**
|
||||
* Register the built-in model for TypeScript and JavaScript. Explicit init
|
||||
* seam for the U4 emit path (call before the pdg window consumes the
|
||||
* registry); idempotent. Vue and other TS-adjacent language ids are
|
||||
* deliberately NOT registered — the M3 scope is TS/JS only.
|
||||
*/
|
||||
export function registerBuiltinTaintModels(): void {
|
||||
registerSourceSinkConfig(SupportedLanguages.TypeScript, TS_JS_TAINT_MODEL);
|
||||
registerSourceSinkConfig(SupportedLanguages.JavaScript, TS_JS_TAINT_MODEL);
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -54,8 +54,29 @@ import {
|
|||
import { PhaseTimer } from '../../core/search/phase-timer.js';
|
||||
import { checkStalenessAsync, checkCwdMatch } from '../../core/git-staleness.js';
|
||||
import { logger } from '../../core/logger.js';
|
||||
import { LIST_REPOS_DEFAULT_LIMIT, LIST_REPOS_MAX_LIMIT } from '../tools.js';
|
||||
import {
|
||||
LIST_REPOS_DEFAULT_LIMIT,
|
||||
LIST_REPOS_MAX_LIMIT,
|
||||
EXPLAIN_DEFAULT_LIMIT,
|
||||
EXPLAIN_MAX_LIMIT,
|
||||
} from '../tools.js';
|
||||
import { findImportCycles } from '../../core/graph/import-cycles.js';
|
||||
import { decodeTaintPath } from '../../core/ingestion/taint/path-codec.js';
|
||||
import { EXTENSIONS } from '../../core/ingestion/import-resolvers/utils.js';
|
||||
|
||||
/** Real source-file extensions (`.ts`, `.py`, …) from the resolver's list,
|
||||
* excluding the empty entry and the `/index.*` forms — used to decide whether
|
||||
* an `explain` target is a file path vs a (possibly dotted) symbol name. */
|
||||
const SOURCE_FILE_EXTENSIONS: readonly string[] = EXTENSIONS.filter(
|
||||
(e) => e.startsWith('.') && !e.includes('/'),
|
||||
);
|
||||
/** A target is path-ish if it has a path separator or ends in a known source
|
||||
* extension. A bare dotted symbol (`UserController.create`) is NOT path-ish. */
|
||||
function looksLikeFilePath(target: string): boolean {
|
||||
if (/[\\/]/.test(target)) return true;
|
||||
const lower = target.toLowerCase();
|
||||
return SOURCE_FILE_EXTENSIONS.some((ext) => lower.endsWith(ext));
|
||||
}
|
||||
// AI context generation is CLI-only (gitnexus analyze)
|
||||
// import { generateAIContextFiles } from '../../cli/ai-context.js';
|
||||
|
||||
|
|
@ -1243,6 +1264,8 @@ export class LocalBackend {
|
|||
}
|
||||
case 'context':
|
||||
return this.context(repo, params);
|
||||
case 'explain':
|
||||
return this.explain(repo, params);
|
||||
case 'impact':
|
||||
return this.impact(repo, params);
|
||||
case 'detect_changes':
|
||||
|
|
@ -2730,6 +2753,259 @@ export class LocalBackend {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Explain tool (#2083 M3 U6) — persisted taint-finding explanation.
|
||||
* WAL-aware wrapper mirroring `context`.
|
||||
*/
|
||||
private async explain(
|
||||
repo: RepoHandle,
|
||||
params: { target?: string; limit?: number },
|
||||
): Promise<any> {
|
||||
try {
|
||||
return await this._explainImpl(repo, params);
|
||||
} catch (err: any) {
|
||||
const msg = (err instanceof Error ? err.message : String(err)) || 'Explain query failed';
|
||||
if (isWalCorruptionError(err)) {
|
||||
return {
|
||||
error: msg,
|
||||
recoverySuggestion: WAL_RECOVERY_SUGGESTION,
|
||||
};
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Taint findings are persisted as `TAINTED` rows in CodeRelation whose
|
||||
* endpoints are BOTH BasicBlock nodes — the label anchor restricts every
|
||||
* query here to the BasicBlock→BasicBlock partition of the rel table
|
||||
* (which holds only the sparse, per-function-capped pdg layers), never a
|
||||
* global symbol-space scan (the S1 verdict; LadybugDB has no rel-property
|
||||
* index, so the label anchor IS the bound).
|
||||
*
|
||||
* Anchoring granularity:
|
||||
* - file target → BasicBlock id prefix (`BasicBlock:<filePath>:` — the
|
||||
* shared `basicBlockId` template) with an exact-or-suffix path match so
|
||||
* `vuln.ts` finds `src/vuln.ts`.
|
||||
* - symbol target → resolved via `resolveSymbolCandidates` (the context()
|
||||
* path: ambiguous ⇒ ranked candidates, unknown ⇒ not-found), then the
|
||||
* file id-prefix PLUS source-block startLine within the symbol's
|
||||
* [startLine, endLine] span. Findings are intra-procedural, so filtering
|
||||
* the SOURCE endpoint is sufficient — both endpoints share the function.
|
||||
* Symbols without a line span degrade to the file-level filter.
|
||||
*
|
||||
* The per-finding `sinkKind` and hop path decode from the persisted
|
||||
* `reason` via the SHARED `taint/path-codec.ts` (the U4 write path encodes
|
||||
* with the same module — `;<kind>` header + ordered `variable:line` hops).
|
||||
*/
|
||||
private async _explainImpl(
|
||||
repo: RepoHandle,
|
||||
params: { target?: string; limit?: number },
|
||||
): Promise<any> {
|
||||
await this.ensureInitialized(repo);
|
||||
|
||||
const rawLimit = params.limit ?? EXPLAIN_DEFAULT_LIMIT;
|
||||
if (!Number.isInteger(rawLimit) || rawLimit < 1 || rawLimit > EXPLAIN_MAX_LIMIT) {
|
||||
return {
|
||||
error: `Invalid "limit": expected an integer in [1, ${EXPLAIN_MAX_LIMIT}], got ${JSON.stringify(params.limit)}.`,
|
||||
};
|
||||
}
|
||||
const limit = rawLimit;
|
||||
|
||||
const NO_TAINT_NOTE =
|
||||
'no taint layer — run gitnexus analyze --pdg to record taint findings for this repo';
|
||||
|
||||
// Cheap meta probe: the TAINT layer exists iff the pdg stamp carries a
|
||||
// `taintModelVersion` (the field M3 added). An M1/M2-era `--pdg` index has
|
||||
// `meta.pdg` defined but no taintModelVersion — BasicBlock/REACHING_DEF
|
||||
// exist, zero TAINTED rows do — so it must surface the no-taint-layer hint,
|
||||
// not the generic "analyzed, nothing found" note. An unreadable meta (e.g.
|
||||
// a seeded test DB) falls through to the row-existence probe below.
|
||||
let pdgStamped: boolean | undefined;
|
||||
try {
|
||||
const meta = await loadMeta(path.dirname(repo.lbugPath));
|
||||
if (meta) pdgStamped = meta.pdg?.taintModelVersion !== undefined;
|
||||
} catch {
|
||||
/* meta unreadable — decide from the DB below */
|
||||
}
|
||||
if (pdgStamped === false) {
|
||||
return { findings: [], totalFindings: 0, note: NO_TAINT_NOTE };
|
||||
}
|
||||
|
||||
// Resolve the optional anchor into a WHERE clause on the SOURCE block.
|
||||
const target = typeof params.target === 'string' ? params.target.trim() : '';
|
||||
let anchorClause = '';
|
||||
const queryParams: Record<string, unknown> = {};
|
||||
let anchor: { file: string; symbol?: string; startLine?: number; endLine?: number } | undefined;
|
||||
|
||||
// Build the anchor as a file filter (used only when `target` is path-ish).
|
||||
const buildFileAnchor = (): void => {
|
||||
// Exact path via the BasicBlock id-prefix template, OR a
|
||||
// path-separator-aligned suffix so partial paths work like context()'s
|
||||
// file_path hint ("vuln.ts" ⇒ "src/vuln.ts", never "devuln.ts").
|
||||
anchorClause =
|
||||
'AND (a.id STARTS WITH $idPrefix OR a.filePath = $targetPath OR a.filePath ENDS WITH $targetSuffix)';
|
||||
queryParams.idPrefix = `BasicBlock:${target}:`;
|
||||
queryParams.targetPath = target;
|
||||
queryParams.targetSuffix = `/${target}`;
|
||||
anchor = { file: target as string };
|
||||
};
|
||||
|
||||
// Resolve `target` as a symbol into the anchor. Returns an early-return
|
||||
// payload (not_found / ambiguous) or undefined on success.
|
||||
const resolveSymbolAnchor = async (): Promise<Record<string, unknown> | undefined> => {
|
||||
const outcome = await this.resolveSymbolCandidates(repo, { name: target as string }, {});
|
||||
if (outcome.kind === 'not_found') {
|
||||
return { error: `Symbol '${target}' not found` };
|
||||
}
|
||||
if (outcome.kind === 'ambiguous') {
|
||||
return {
|
||||
status: 'ambiguous',
|
||||
message: `Found ${outcome.candidates.length} symbols matching '${target}'. Re-call explain with the file path, or disambiguate via context() first.`,
|
||||
candidates: outcome.candidates.map((c) => ({
|
||||
uid: c.id,
|
||||
name: c.name,
|
||||
kind: c.type,
|
||||
filePath: c.filePath,
|
||||
line: c.startLine,
|
||||
score: Number(c.score.toFixed(2)),
|
||||
})),
|
||||
};
|
||||
}
|
||||
const sym = outcome.symbol;
|
||||
queryParams.idPrefix = `BasicBlock:${sym.filePath}:`;
|
||||
anchor = { file: sym.filePath, symbol: sym.name };
|
||||
if (
|
||||
typeof sym.startLine === 'number' &&
|
||||
typeof sym.endLine === 'number' &&
|
||||
sym.endLine >= sym.startLine
|
||||
) {
|
||||
anchorClause =
|
||||
'AND a.id STARTS WITH $idPrefix AND a.startLine >= $symStart AND a.startLine <= $symEnd';
|
||||
queryParams.symStart = sym.startLine;
|
||||
queryParams.symEnd = sym.endLine;
|
||||
anchor.startLine = sym.startLine;
|
||||
anchor.endLine = sym.endLine;
|
||||
} else {
|
||||
// No usable span — degrade to the file-level filter (documented).
|
||||
anchorClause = 'AND a.id STARTS WITH $idPrefix';
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// Bounded by construction: the BasicBlock→BasicBlock partition holds only
|
||||
// the sparse pdg layers, TAINTED rows are per-function-capped at analyze
|
||||
// time, and the page is LIMIT-bounded (the limit is a validated integer —
|
||||
// interpolated because LadybugDB does not parameterize LIMIT).
|
||||
const runAnchoredQuery = async (): Promise<{ rows: unknown[]; totalFindings: number }> => {
|
||||
const matchClause = `
|
||||
MATCH (a:BasicBlock)-[r:CodeRelation]->(b:BasicBlock)
|
||||
WHERE r.type = 'TAINTED' ${anchorClause}`;
|
||||
const [qRows, countRows] = await Promise.all([
|
||||
executeParameterized(
|
||||
repo.lbugPath,
|
||||
`${matchClause}
|
||||
RETURN a.id AS sourceBlockId, a.filePath AS file, a.startLine AS sourceStart,
|
||||
b.startLine AS sinkStart, r.reason AS reason, b.id AS sinkBlockId
|
||||
ORDER BY sourceBlockId, sinkBlockId, reason
|
||||
LIMIT ${limit}`,
|
||||
queryParams,
|
||||
),
|
||||
executeParameterized(
|
||||
repo.lbugPath,
|
||||
`${matchClause}
|
||||
RETURN COUNT(*) AS total`,
|
||||
queryParams,
|
||||
),
|
||||
]);
|
||||
return {
|
||||
rows: qRows,
|
||||
totalFindings: Number((countRows[0] as any)?.total ?? (countRows[0] as any)?.[0] ?? 0),
|
||||
};
|
||||
};
|
||||
|
||||
if (target) {
|
||||
if (looksLikeFilePath(target)) {
|
||||
buildFileAnchor();
|
||||
} else {
|
||||
// A bare or dotted symbol name (`UserController.create`) — resolve as a
|
||||
// symbol rather than silently file-anchoring to an empty result.
|
||||
const early = await resolveSymbolAnchor();
|
||||
if (early) return early;
|
||||
}
|
||||
}
|
||||
|
||||
const { rows, totalFindings } = await runAnchoredQuery();
|
||||
|
||||
if (totalFindings === 0 && pdgStamped === undefined && !target) {
|
||||
// Meta was unreadable and the repo-wide enumerate found nothing — the
|
||||
// count above WAS the existence probe; surface the layer hint.
|
||||
return { findings: [], totalFindings: 0, note: NO_TAINT_NOTE };
|
||||
}
|
||||
if (totalFindings === 0 && pdgStamped === undefined && target) {
|
||||
// Anchored miss with unreadable meta: one extra bounded probe decides
|
||||
// "no findings for this anchor" vs "no taint layer at all".
|
||||
const probe = await executeParameterized(
|
||||
repo.lbugPath,
|
||||
`MATCH (a:BasicBlock)-[r:CodeRelation]->(b:BasicBlock) WHERE r.type = 'TAINTED' RETURN r.reason AS reason LIMIT 1`,
|
||||
{},
|
||||
);
|
||||
if (probe.length === 0) {
|
||||
return { findings: [], totalFindings: 0, note: NO_TAINT_NOTE };
|
||||
}
|
||||
}
|
||||
|
||||
const findings = rows.map((r: any) => {
|
||||
const sourceBlockId = String(r.sourceBlockId ?? r[0] ?? '');
|
||||
const file = String(r.file ?? r[1] ?? '');
|
||||
const sourceStart = (r.sourceStart ?? r[2]) as number | undefined;
|
||||
const sinkStart = (r.sinkStart ?? r[3]) as number | undefined;
|
||||
const reason = r.reason ?? r[4];
|
||||
// basicBlockId = `BasicBlock:<filePath>:<fnLine>:<fnCol>:<blockIdx>` —
|
||||
// split from the RIGHT (the filePath may itself contain ':').
|
||||
const idParts = sourceBlockId.split(':');
|
||||
const fnLine = Number(idParts[idParts.length - 3]);
|
||||
const decoded = decodeTaintPath(reason);
|
||||
if (!decoded.ok) {
|
||||
// Unreadable reason (foreign/corrupt row): surface the finding's
|
||||
// existence with its block anchors, never throw.
|
||||
return {
|
||||
file,
|
||||
...(Number.isInteger(fnLine) ? { functionLine: fnLine } : {}),
|
||||
sinkKind: 'unknown',
|
||||
source: { line: sourceStart },
|
||||
sink: { line: sinkStart },
|
||||
hops: [],
|
||||
pathIncomplete: true,
|
||||
};
|
||||
}
|
||||
const hops = decoded.hops.map((h) => ({
|
||||
variable: h.variable,
|
||||
line: h.line,
|
||||
...(h.viaCall ? { viaCall: true } : {}),
|
||||
}));
|
||||
const first = hops[0];
|
||||
const last = hops[hops.length - 1];
|
||||
return {
|
||||
file,
|
||||
...(Number.isInteger(fnLine) ? { functionLine: fnLine } : {}),
|
||||
sinkKind: decoded.kind ?? 'unknown',
|
||||
source: first ? { variable: first.variable, line: first.line } : { line: sourceStart },
|
||||
sink: { line: last?.line ?? sinkStart },
|
||||
hops,
|
||||
...(decoded.truncated ? { pathIncomplete: true } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...(anchor ? { anchor } : {}),
|
||||
findings,
|
||||
totalFindings,
|
||||
...(totalFindings > findings.length ? { truncated: true } : {}),
|
||||
note: 'Intra-procedural findings only — cross-function, closure/callback, property/field, and implicit flows are not modeled; absence of a finding is not proof of safety. SANITIZES (kill) edges are queryable via cypher.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy explore — kept for backwards compatibility with resources.ts.
|
||||
* Routes cluster/process types to direct graph queries.
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -62,6 +62,16 @@ const DESTRUCTIVE_TOOL_ANNOTATIONS: ToolAnnotations = {
|
|||
export const LIST_REPOS_DEFAULT_LIMIT = 50;
|
||||
export const LIST_REPOS_MAX_LIMIT = 200;
|
||||
|
||||
/**
|
||||
* Pagination bounds for the `explain` tool (#2083 M3 U6). Findings are sparse
|
||||
* and capped per function at analyze time, but a large repo can still
|
||||
* accumulate enough TAINTED rows to blow MCP/LLM token limits — the response
|
||||
* is page-bounded like `list_repos`. Exported so the backend clamp
|
||||
* (`local-backend.ts`) and the schema stay a single source of truth.
|
||||
*/
|
||||
export const EXPLAIN_DEFAULT_LIMIT = 50;
|
||||
export const EXPLAIN_MAX_LIMIT = 200;
|
||||
|
||||
export const GITNEXUS_TOOLS: ToolDefinition[] = [
|
||||
{
|
||||
name: 'list_repos',
|
||||
|
|
@ -513,6 +523,50 @@ SERVICE: optional monorepo path prefix (case-sensitive path segments). When "rep
|
|||
required: ['target', 'direction'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'explain',
|
||||
description: `Explain persisted taint findings: intra-procedural source→sink data flows (TAINTED edges) recorded by \`gitnexus analyze --pdg\`.
|
||||
|
||||
Each finding carries the sink category (command-injection, code-injection, path-traversal, sql-injection, xss), the source/sink lines, and the ordered hop path with the variable carried on each hop (decoded from the persisted path encoding).
|
||||
|
||||
WHEN TO USE: Security review — "what taint findings exist in this repo / file / function?". Requires the repo to be indexed with \`gitnexus analyze --pdg\`; without that layer the tool returns a clear "no taint layer" note, not an error.
|
||||
|
||||
ANCHORLESS (no "target"): enumerates all persisted findings for the repo — bounded ("limit", deterministic order), with "totalFindings" and a "truncated" flag.
|
||||
ANCHORED ("target" = file path or symbol/function name): full hop detail for that anchor. A file-ish target (contains "/" or an extension) filters by file; a symbol name resolves like context() — ambiguous names return ranked candidates, unknown names return not-found. Symbol anchoring is line-range granular (findings whose source block starts inside the symbol's span).
|
||||
|
||||
CONTRACT CAVEATS (intra-procedural M3 scope — absent flows are NOT proof of safety):
|
||||
- Cross-function flows are not modeled (a flow through a helper function is invisible).
|
||||
- Closure/callback flows are invisible in both directions (e.g. arr.forEach(() => sink(y))).
|
||||
- Property/field flows are not tracked (obj.x = taint; sink(obj.y) has no chain).
|
||||
- Guard-style sanitizers (if (isValid(x))) and implicit/control-dependence flows are not modeled.
|
||||
- CommonJS aliasing is partially modeled (require('<literal>') joins resolve; dynamic requires do not).
|
||||
- Exception-path over-approximation can produce false-positive noise.
|
||||
|
||||
Findings are deliberately NOT part of impact()'s traversal or the web schema — explain is the dedicated taint consumer. SANITIZES (kill) edges are queryable via cypher.`,
|
||||
annotations: READ_ONLY_TOOL_ANNOTATIONS,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
target: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Optional anchor: a file path (e.g. "src/handlers/run.ts" — suffix match accepted) or a symbol/function name (resolved like context()). Omit to enumerate all findings for the repo.',
|
||||
},
|
||||
limit: {
|
||||
type: 'integer',
|
||||
description: `Max findings returned (default: ${EXPLAIN_DEFAULT_LIMIT}, max: ${EXPLAIN_MAX_LIMIT}). "totalFindings" reports the full matched count; "truncated" is set when the page is smaller.`,
|
||||
default: EXPLAIN_DEFAULT_LIMIT,
|
||||
minimum: 1,
|
||||
maximum: EXPLAIN_MAX_LIMIT,
|
||||
},
|
||||
repo: {
|
||||
type: 'string',
|
||||
description: 'Repository name or path. Omit if only one repo is indexed.',
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'route_map',
|
||||
description: `Show API route mappings: which components/hooks fetch which API endpoints, and which handler files serve them.
|
||||
|
|
@ -647,6 +701,7 @@ const BRANCH_SCOPED_TOOLS = new Set([
|
|||
'cypher',
|
||||
'context',
|
||||
'detect_changes',
|
||||
'explain',
|
||||
'check',
|
||||
'impact',
|
||||
'rename',
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
123
gitnexus/test/helpers/taint-fixture.ts
Normal file
123
gitnexus/test/helpers/taint-fixture.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
/**
|
||||
* Shared pure-path taint harness over the pdg-repo fixture (#2083 M3 U7).
|
||||
*
|
||||
* Runs the SAME per-function pipeline the in-phase emit driver runs —
|
||||
* collect CFGs → site-safety gate → match → zero-match fast path →
|
||||
* `computeReachingDefs` → `computeTaintFlows` — but build-free on the main
|
||||
* thread (parse via tree-sitter directly, like reaching-defs-snapshot).
|
||||
*
|
||||
* Two consumers, deliberately fed from ONE module so they cannot drift:
|
||||
* - `taint-snapshot.test.ts` serializes the per-function results (AE1/AE3);
|
||||
* - `pipeline-pdg.test.ts` sums findings/kills as the EXPECTED stored-row
|
||||
* counts for the sparse-persistence gate (AE2): the real worker pipeline
|
||||
* must persist exactly one TAINTED row per pure-path finding and one
|
||||
* SANITIZES row per kill — O(findings), never a REACHING_DEF-style
|
||||
* explosion.
|
||||
*
|
||||
* Limits mirror the run.ts derivation: findings/hops caps at their U5
|
||||
* defaults, `maxFacts` at the RD-edge-cap formula's default product
|
||||
* (`DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION` — same number).
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import Parser from 'tree-sitter';
|
||||
import TypeScript from 'tree-sitter-typescript';
|
||||
import type { ParsedImport } from 'gitnexus-shared';
|
||||
import { collectFunctionCfgs } from '../../src/core/ingestion/cfg/collect.js';
|
||||
import { computeReachingDefs } from '../../src/core/ingestion/cfg/reaching-defs.js';
|
||||
import { DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION } from '../../src/core/ingestion/cfg/emit.js';
|
||||
import { getProvider } from '../../src/core/ingestion/languages/index.js';
|
||||
import { SupportedLanguages } from '../../src/config/supported-languages.js';
|
||||
import { hasTaintSafeSites } from '../../src/core/ingestion/taint/site-safety.js';
|
||||
import { buildTaintImportIndex, matchFunctionSites } from '../../src/core/ingestion/taint/match.js';
|
||||
import { TS_JS_TAINT_MODEL } from '../../src/core/ingestion/taint/typescript-model.js';
|
||||
import {
|
||||
computeTaintFlows,
|
||||
DEFAULT_PDG_MAX_TAINT_FINDINGS_PER_FUNCTION,
|
||||
DEFAULT_PDG_MAX_TAINT_HOPS,
|
||||
type FunctionTaintResult,
|
||||
} from '../../src/core/ingestion/taint/propagate.js';
|
||||
import type { FunctionCfg } from '../../src/core/ingestion/cfg/types.js';
|
||||
|
||||
/** The taint-bearing fixture files (sample.ts is the zero-match control). */
|
||||
export const TAINT_FIXTURE_FILES = ['vuln.ts', 'taint-cases.ts', 'sample.ts'] as const;
|
||||
|
||||
/**
|
||||
* Hand-built `ParsedImport` lists matching each fixture file's import
|
||||
* statements (the build-free path has no extractor run). MUST stay in sync
|
||||
* with the fixture sources — the AE2 equality against the real pipeline
|
||||
* (which uses extracted `parsedImports`) breaks loudly if they drift.
|
||||
*/
|
||||
export const TAINT_FIXTURE_IMPORTS: Record<string, readonly ParsedImport[]> = {
|
||||
'vuln.ts': [
|
||||
{ kind: 'named', localName: 'exec', importedName: 'exec', targetRaw: 'child_process' },
|
||||
],
|
||||
'taint-cases.ts': [
|
||||
{ kind: 'named', localName: 'exec', importedName: 'exec', targetRaw: 'child_process' },
|
||||
],
|
||||
'sample.ts': [],
|
||||
};
|
||||
|
||||
export interface FixtureFunctionTaint {
|
||||
readonly file: string;
|
||||
readonly startLine: number;
|
||||
readonly cfg: FunctionCfg;
|
||||
/**
|
||||
* `no-match` — the zero-match fast path skipped the solver entirely;
|
||||
* `unsafe-sites` — `hasTaintSafeSites` rejected the harvest;
|
||||
* otherwise the `computeTaintFlows` status (`computed` / `coverage-gap`).
|
||||
*/
|
||||
readonly status: 'no-match' | 'unsafe-sites' | FunctionTaintResult['status'];
|
||||
/** Present iff the solver ran (status `computed` or `coverage-gap`). */
|
||||
readonly flows?: FunctionTaintResult;
|
||||
}
|
||||
|
||||
/** Run the pure taint path over one fixture file. Deterministic. */
|
||||
export function computeFixtureFileTaint(
|
||||
fixtureDir: string,
|
||||
file: string,
|
||||
): readonly FixtureFunctionTaint[] {
|
||||
const visitor = getProvider(SupportedLanguages.TypeScript).cfgVisitor;
|
||||
if (!visitor) throw new Error('no cfgVisitor for TypeScript');
|
||||
const source = fs.readFileSync(path.join(fixtureDir, file), 'utf8');
|
||||
const parser = new Parser();
|
||||
parser.setLanguage(TypeScript.typescript);
|
||||
const cfgs = collectFunctionCfgs(parser.parse(source).rootNode, visitor, file).cfgs;
|
||||
|
||||
const imports = TAINT_FIXTURE_IMPORTS[file];
|
||||
if (imports === undefined) {
|
||||
throw new Error(`no FIXTURE_IMPORTS entry for ${file} — add it (see module doc)`);
|
||||
}
|
||||
const importIndex = buildTaintImportIndex(imports);
|
||||
|
||||
return cfgs.map((cfg) => {
|
||||
const base = { file, startLine: cfg.functionStartLine, cfg };
|
||||
if (!hasTaintSafeSites(cfg)) return { ...base, status: 'unsafe-sites' as const };
|
||||
const matches = matchFunctionSites(cfg, TS_JS_TAINT_MODEL, importIndex);
|
||||
if (!matches.hasSource || !matches.hasSink) return { ...base, status: 'no-match' as const };
|
||||
const defUse = computeReachingDefs(cfg, {
|
||||
maxFacts: DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION,
|
||||
});
|
||||
const flows = computeTaintFlows(cfg, defUse, matches, {
|
||||
maxFindingsPerFunction: DEFAULT_PDG_MAX_TAINT_FINDINGS_PER_FUNCTION,
|
||||
maxHops: DEFAULT_PDG_MAX_TAINT_HOPS,
|
||||
});
|
||||
return { ...base, status: flows.status, flows };
|
||||
});
|
||||
}
|
||||
|
||||
/** Run the pure taint path over the whole fixture battery, in file order. */
|
||||
export function computeFixtureTaint(fixtureDir: string): readonly FixtureFunctionTaint[] {
|
||||
return TAINT_FIXTURE_FILES.flatMap((f) => computeFixtureFileTaint(fixtureDir, f));
|
||||
}
|
||||
|
||||
/** Pure-path totals — the AE2 expected stored-row counts. */
|
||||
export function fixtureTaintTotals(fixtureDir: string): { findings: number; kills: number } {
|
||||
let findings = 0;
|
||||
let kills = 0;
|
||||
for (const fn of computeFixtureTaint(fixtureDir)) {
|
||||
findings += fn.flows?.findings.length ?? 0;
|
||||
kills += fn.flows?.kills.length ?? 0;
|
||||
}
|
||||
return { findings, kills };
|
||||
}
|
||||
68
gitnexus/test/helpers/ts-cfg-harness.ts
Normal file
68
gitnexus/test/helpers/ts-cfg-harness.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
/**
|
||||
* Shared TS CFG/taint unit-test harness (#2083 review).
|
||||
*
|
||||
* Parses real TypeScript source through the worker-side CFG visitor and the
|
||||
* scope-capture import interpreter, so taint unit tests run against the exact
|
||||
* structures the pipeline feeds `computeReachingDefs` / `matchFunctionSites` /
|
||||
* `computeTaintFlows`, never hand-built mocks. Extracted from the byte-identical
|
||||
* copies that lived in model-match / propagate / taint-emit / harvest tests.
|
||||
*/
|
||||
|
||||
import Parser from 'tree-sitter';
|
||||
import TypeScript from 'tree-sitter-typescript';
|
||||
import type { ParsedImport } from 'gitnexus-shared';
|
||||
import type { SyntaxNode } from '../../src/core/ingestion/utils/ast-helpers.js';
|
||||
import {
|
||||
createTypeScriptCfgVisitor,
|
||||
TS_FUNCTION_TYPES,
|
||||
} from '../../src/core/ingestion/cfg/visitors/typescript.js';
|
||||
import type { FunctionCfg } from '../../src/core/ingestion/cfg/types.js';
|
||||
import { emitTsScopeCaptures } from '../../src/core/ingestion/languages/typescript/captures.js';
|
||||
import { interpretTsImport } from '../../src/core/ingestion/languages/typescript/interpret.js';
|
||||
|
||||
const visitor = createTypeScriptCfgVisitor();
|
||||
|
||||
export function parse(code: string): SyntaxNode {
|
||||
const parser = new Parser();
|
||||
parser.setLanguage(TypeScript.typescript);
|
||||
return parser.parse(code).rootNode;
|
||||
}
|
||||
|
||||
export function collectFunctions(root: SyntaxNode): SyntaxNode[] {
|
||||
const out: SyntaxNode[] = [];
|
||||
const stack = [root];
|
||||
while (stack.length) {
|
||||
const n = stack.pop() as SyntaxNode;
|
||||
if (TS_FUNCTION_TYPES.has(n.type)) out.push(n);
|
||||
for (let i = n.namedChildCount - 1; i >= 0; i--) {
|
||||
const c = n.namedChild(i);
|
||||
if (c) stack.push(c);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The CFG of the function at `index` (default 0). */
|
||||
export function cfgOf(code: string, index = 0): FunctionCfg {
|
||||
const fns = collectFunctions(parse(code));
|
||||
const fn = fns[index];
|
||||
if (!fn) throw new Error(`no function at index ${index}`);
|
||||
const cfg = visitor.buildFunctionCfg(fn, 'fixture.ts');
|
||||
if (!cfg) throw new Error('buildFunctionCfg returned undefined');
|
||||
return cfg;
|
||||
}
|
||||
|
||||
/** Every function's CFG, in source order. */
|
||||
export function cfgsOf(code: string): FunctionCfg[] {
|
||||
return collectFunctions(parse(code))
|
||||
.map((fn) => visitor.buildFunctionCfg(fn, 'fixture.ts'))
|
||||
.filter((c): c is FunctionCfg => c !== undefined);
|
||||
}
|
||||
|
||||
/** Real ParsedImports via the TS scope-capture + interpreter path. */
|
||||
export function importsFor(src: string): ParsedImport[] {
|
||||
return emitTsScopeCaptures(src, 'fixture.ts')
|
||||
.filter((m) => m['@import.statement'] !== undefined)
|
||||
.map((m) => interpretTsImport(m))
|
||||
.filter((p): p is ParsedImport => p !== null);
|
||||
}
|
||||
|
|
@ -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",
|
||||
},
|
||||
]
|
||||
`;
|
||||
|
|
@ -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;
|
||||
}
|
||||
19
gitnexus/test/integration/cfg/fixtures/pdg-repo/vuln.ts
Normal file
19
gitnexus/test/integration/cfg/fixtures/pdg-repo/vuln.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
// Taint fixture (#2083 M3 U4): one vulnerable source→sink flow and one
|
||||
// sanitized variant. Lives in its OWN file — sample.ts's line numbers anchor
|
||||
// pre-existing REACHING_DEF assertions and must not shift.
|
||||
|
||||
import { exec } from 'child_process';
|
||||
|
||||
// Vulnerable: req.body (remote-input source) flows unsanitized into
|
||||
// child_process.exec (command-injection sink) → one TAINTED edge.
|
||||
export function runUserCommand(req: { body: string }): void {
|
||||
const cmd = req.body;
|
||||
exec(cmd);
|
||||
}
|
||||
|
||||
// Sanitized: encodeURIComponent neutralizes xss before the res.send sink —
|
||||
// the finding is suppressed and the kill persists as a SANITIZES edge.
|
||||
export function sendEncoded(req: { query: string }, res: { send(v: string): void }): void {
|
||||
const value = encodeURIComponent(req.query);
|
||||
res.send(value);
|
||||
}
|
||||
|
|
@ -4,6 +4,8 @@ import os from 'os';
|
|||
import path from 'path';
|
||||
import { runPipelineFromRepo } from '../../../src/core/ingestion/pipeline.js';
|
||||
import type { PipelineResult } from '../../../src/types/pipeline.js';
|
||||
import { decodeTaintPath } from '../../../src/core/ingestion/taint/path-codec.js';
|
||||
import { fixtureTaintTotals } from '../../helpers/taint-fixture.js';
|
||||
|
||||
// U7 — end-to-end proof that the `--pdg` opt-in reaches BOTH sinks: the parse
|
||||
// worker builds a per-function CFG (workerData.pdg) and scope-resolution emits
|
||||
|
|
@ -17,6 +19,8 @@ function counts(result: PipelineResult): {
|
|||
basicBlocks: number;
|
||||
cfgEdges: number;
|
||||
reachingDefs: number;
|
||||
tainted: number;
|
||||
sanitizes: number;
|
||||
} {
|
||||
let basicBlocks = 0;
|
||||
result.graph.forEachNode((n) => {
|
||||
|
|
@ -24,11 +28,15 @@ function counts(result: PipelineResult): {
|
|||
});
|
||||
let cfgEdges = 0;
|
||||
let reachingDefs = 0;
|
||||
let tainted = 0;
|
||||
let sanitizes = 0;
|
||||
for (const rel of result.graph.iterRelationships()) {
|
||||
if (rel.type === 'CFG') cfgEdges++;
|
||||
if (rel.type === 'REACHING_DEF') reachingDefs++;
|
||||
if (rel.type === 'TAINTED') tainted++;
|
||||
if (rel.type === 'SANITIZES') sanitizes++;
|
||||
}
|
||||
return { basicBlocks, cfgEdges, reachingDefs };
|
||||
return { basicBlocks, cfgEdges, reachingDefs, tainted, sanitizes };
|
||||
}
|
||||
|
||||
const tmpDirs: string[] = [];
|
||||
|
|
@ -69,11 +77,74 @@ describe('U7 — end-to-end --pdg pipeline', () => {
|
|||
}
|
||||
}, 60000);
|
||||
|
||||
// M3 (#2083 U4/U7): the taint layer rides the same gate. The fixture's
|
||||
// vuln.ts carries one vulnerable flow (req.body → child_process.exec) and
|
||||
// one sanitized variant (encodeURIComponent before res.send); taint-cases.ts
|
||||
// adds the U7 acceptance battery (direct, multi-hop, conditional-sanitizer,
|
||||
// loop-carried, through-call).
|
||||
it('with --pdg on: emits TAINTED + SANITIZES edges with decodable hop reasons', async () => {
|
||||
const result = await runPipelineFromRepo(freshRepo(), () => {}, { pdg: true });
|
||||
const blockIds = new Set<string>();
|
||||
result.graph.forEachNode((n) => {
|
||||
if (n.label === 'BasicBlock') blockIds.add(n.id);
|
||||
});
|
||||
const { tainted, sanitizes, reachingDefs } = counts(result);
|
||||
expect(tainted).toBeGreaterThan(0);
|
||||
expect(sanitizes).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// AE2 (AC2) — sparse persistence. The load-bearing O(findings) gate is
|
||||
// EXACT equality: one TAINTED row per pure-path finding and one SANITIZES
|
||||
// row per kill over the same fixture, computed through the shared harness
|
||||
// so the worker pipeline and the snapshot suite cannot drift apart. Any
|
||||
// REACHING_DEF-style row multiplication (per-fact, per-block-pair, …)
|
||||
// breaks the equality immediately.
|
||||
const expected = fixtureTaintTotals(FIXTURE);
|
||||
expect(expected.findings).toBeGreaterThan(0);
|
||||
expect(tainted).toBe(expected.findings);
|
||||
expect(sanitizes).toBe(expected.kills);
|
||||
// Ratio sanity vs the dense RD projection on the SAME run. The fixture is
|
||||
// deliberately finding-DENSE (nearly every function is a vulnerable
|
||||
// acceptance case), so the honest measured ratio here is ~22% (8 taint
|
||||
// rows vs 37 RD rows) — the < 0.5 bound still catches any per-fact
|
||||
// explosion (which would multiply taint rows past RD); the representative
|
||||
// ≪-RD posture on realistic density is gated by the bench taint scenario's
|
||||
// absolute boundedness/byte ceilings (bench/cfg).
|
||||
expect(tainted + sanitizes).toBeLessThan(reachingDefs * 0.5);
|
||||
let sawVulnFlow = false;
|
||||
for (const rel of result.graph.iterRelationships()) {
|
||||
if (rel.type !== 'TAINTED' && rel.type !== 'SANITIZES') continue;
|
||||
// Both endpoints are persisted BasicBlock nodes (the shared id template).
|
||||
expect(blockIds.has(rel.sourceId)).toBe(true);
|
||||
expect(blockIds.has(rel.targetId)).toBe(true);
|
||||
if (rel.type === 'TAINTED') {
|
||||
// The reason is the versioned hop encoding — decodable by the SHARED
|
||||
// codec (U6's explain imports the same module), variables carried.
|
||||
const decoded = decodeTaintPath(rel.reason);
|
||||
expect(decoded.ok).toBe(true);
|
||||
if (decoded.ok) {
|
||||
expect(decoded.hops.length).toBeGreaterThan(0);
|
||||
for (const hop of decoded.hops) {
|
||||
expect(hop.variable.length).toBeGreaterThan(0);
|
||||
expect(hop.line).toBeGreaterThan(0);
|
||||
}
|
||||
if (decoded.hops.some((h) => h.variable === 'cmd')) sawVulnFlow = true;
|
||||
}
|
||||
} else {
|
||||
// SANITIZES carries the killed binding's plain name: `value` from
|
||||
// vuln.ts sendEncoded, `text` from taint-cases.ts conditionalSanitizer.
|
||||
expect(['value', 'text']).toContain(rel.reason);
|
||||
}
|
||||
}
|
||||
expect(sawVulnFlow).toBe(true); // the req.body → exec flow, via `cmd`
|
||||
}, 60000);
|
||||
|
||||
it('with --pdg off (default): emits zero BasicBlock nodes and zero CFG edges', async () => {
|
||||
const result = await runPipelineFromRepo(freshRepo(), () => {});
|
||||
const { basicBlocks, cfgEdges, reachingDefs } = counts(result);
|
||||
const { basicBlocks, cfgEdges, reachingDefs, tainted, sanitizes } = counts(result);
|
||||
expect(basicBlocks).toBe(0);
|
||||
expect(cfgEdges).toBe(0);
|
||||
expect(reachingDefs).toBe(0);
|
||||
expect(tainted).toBe(0);
|
||||
expect(sanitizes).toBe(0);
|
||||
}, 60000);
|
||||
});
|
||||
|
|
|
|||
144
gitnexus/test/integration/cfg/taint-snapshot.test.ts
Normal file
144
gitnexus/test/integration/cfg/taint-snapshot.test.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import path from 'path';
|
||||
import {
|
||||
computeFixtureTaint,
|
||||
TAINT_FIXTURE_FILES,
|
||||
type FixtureFunctionTaint,
|
||||
} from '../../helpers/taint-fixture.js';
|
||||
|
||||
// #2083 M3 U7 acceptance: a committed snapshot of the taint findings/kills on
|
||||
// the pdg-repo fixture battery (vuln.ts + taint-cases.ts, with sample.ts as
|
||||
// the zero-match control), mirroring reaching-defs-snapshot. The pure path —
|
||||
// collect → match → computeReachingDefs → computeTaintFlows — is the SAME
|
||||
// per-function pipeline the in-phase emit driver runs, so any model/matcher/
|
||||
// propagation behavior change shows as a reviewable snapshot diff, never
|
||||
// silent drift. The fixture battery covers the plan's six shapes: direct
|
||||
// source→sink (rule-b AND the reassignment form), multi-hop chain, sanitized
|
||||
// variant (must-def kill suppresses), conditional-sanitizer variant (finding
|
||||
// survives), loop-carried taint, and through-call (viaCall hop).
|
||||
|
||||
const FIXTURE = path.join(__dirname, 'fixtures', 'pdg-repo');
|
||||
|
||||
/**
|
||||
* Deterministic rendering. Findings: `var@line[*]->…->var@line[*]:kind`
|
||||
* (source-first hop order; `*` marks a viaCall hop — taint passed through an
|
||||
* unmodeled call). Kills: `binding@defLine<-sanLine:kind[,kind]`.
|
||||
*/
|
||||
function serialize(fn: FixtureFunctionTaint): Record<string, unknown> {
|
||||
const bindings = fn.cfg.bindings ?? [];
|
||||
const bName = (idx: number): string => bindings[idx]?.name ?? `#${idx}`;
|
||||
return {
|
||||
file: fn.file,
|
||||
startLine: fn.startLine,
|
||||
status: fn.status,
|
||||
findings: (fn.flows?.findings ?? []).map(
|
||||
(f) =>
|
||||
f.hops.map((h) => `${h.name}@${h.point.line}${h.viaCall === true ? '*' : ''}`).join('->') +
|
||||
`:${f.sinkKind}` +
|
||||
(f.hopsTruncated === true ? ' (truncated)' : ''),
|
||||
),
|
||||
kills: (fn.flows?.kills ?? []).map(
|
||||
(k) =>
|
||||
`${bName(k.bindingIdx)}@${k.killedDef.line}<-${k.sanitizer.line}:${k.neutralized.join(',')}`,
|
||||
),
|
||||
dropped: fn.flows?.droppedFindings ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
describe('U7 — taint findings/kills snapshot on the pdg-repo fixture battery', () => {
|
||||
const results = computeFixtureTaint(FIXTURE);
|
||||
const blockText = (fn: FixtureFunctionTaint, needle: string): boolean =>
|
||||
fn.cfg.blocks.some((b) => b.text.includes(needle));
|
||||
|
||||
it('matches the committed findings/kills for every fixture function', () => {
|
||||
// Every fixture file contributes at least one function; the battery shape
|
||||
// is pinned so a fixture edit that drops a case fails loudly here, not
|
||||
// silently in the snapshot.
|
||||
for (const file of TAINT_FIXTURE_FILES) {
|
||||
expect(results.some((r) => r.file === file)).toBe(true);
|
||||
}
|
||||
expect(results.map(serialize)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('every matched fixture function computes (no coverage gaps, no unsafe sites)', () => {
|
||||
for (const fn of results) {
|
||||
expect(['computed', 'no-match']).toContain(fn.status);
|
||||
if (fn.flows) expect(fn.flows.droppedFindings).toBe(0);
|
||||
}
|
||||
// sample.ts is the zero-match control: no sources/sinks → fast path.
|
||||
for (const fn of results.filter((r) => r.file === 'sample.ts')) {
|
||||
expect(fn.status).toBe('no-match');
|
||||
}
|
||||
});
|
||||
|
||||
it('AE1 — the source→sink flow IS found; the sanitized variant yields no finding and ≥1 kill', () => {
|
||||
// vuln.ts runUserCommand: req.body → cmd → exec(cmd).
|
||||
const vulnerable = results.find((r) => r.file === 'vuln.ts' && blockText(r, 'exec(cmd)'))!;
|
||||
expect(vulnerable).toBeDefined();
|
||||
expect(vulnerable.status).toBe('computed');
|
||||
expect(vulnerable.flows!.findings).toHaveLength(1);
|
||||
expect(vulnerable.flows!.findings[0].sinkKind).toBe('command-injection');
|
||||
|
||||
// vuln.ts sendEncoded: the must-def encodeURIComponent kill suppresses
|
||||
// the xss finding entirely; the kill IS the persisted safety evidence.
|
||||
const sanitized = results.find(
|
||||
(r) => r.file === 'vuln.ts' && blockText(r, 'encodeURIComponent'),
|
||||
)!;
|
||||
expect(sanitized).toBeDefined();
|
||||
expect(sanitized.status).toBe('computed');
|
||||
expect(sanitized.flows!.findings).toHaveLength(0);
|
||||
expect(sanitized.flows!.kills.length).toBeGreaterThanOrEqual(1);
|
||||
expect(sanitized.flows!.kills[0].neutralized).toContain('xss');
|
||||
});
|
||||
|
||||
it('AE1 — the conditional-sanitizer variant survives (may-def leg) with the kill recorded', () => {
|
||||
const conditional = results.find(
|
||||
(r) => r.file === 'taint-cases.ts' && blockText(r, 'res.send(text)'),
|
||||
)!;
|
||||
expect(conditional).toBeDefined();
|
||||
expect(conditional.flows!.findings).toHaveLength(1);
|
||||
expect(conditional.flows!.findings[0].sinkKind).toBe('xss');
|
||||
expect(conditional.flows!.kills.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('AE3 shape — hops are ordered source-first with a variable on every hop', () => {
|
||||
// Every finding in the battery carries non-empty variables on all hops.
|
||||
for (const fn of results) {
|
||||
for (const f of fn.flows?.findings ?? []) {
|
||||
expect(f.hops.length).toBeGreaterThan(0);
|
||||
for (const h of f.hops) {
|
||||
expect(h.name.length).toBeGreaterThan(0);
|
||||
expect(h.point.line).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The multi-hop chain (a → b → c → exec(c)): 3+ hops, source-first order.
|
||||
const chain = results.find((r) => r.file === 'taint-cases.ts' && blockText(r, 'const c = b'))!;
|
||||
expect(chain).toBeDefined();
|
||||
const hops = chain.flows!.findings[0].hops;
|
||||
expect(hops.length).toBeGreaterThanOrEqual(4);
|
||||
expect(hops.map((h) => h.name)).toEqual(['a', 'b', 'c', 'c']);
|
||||
for (let i = 1; i < hops.length; i++) {
|
||||
expect(hops[i].point.line).toBeGreaterThanOrEqual(hops[i - 1].point.line);
|
||||
}
|
||||
});
|
||||
|
||||
it('loop-carried taint reaches a fixpoint and the sink (terminates, one finding)', () => {
|
||||
const loop = results.find(
|
||||
(r) => r.file === 'taint-cases.ts' && blockText(r, 'cmd = cmd + part'),
|
||||
)!;
|
||||
expect(loop).toBeDefined();
|
||||
expect(loop.status).toBe('computed');
|
||||
expect(loop.flows!.findings).toHaveLength(1);
|
||||
expect(loop.flows!.findings[0].sinkKind).toBe('command-injection');
|
||||
});
|
||||
|
||||
it('through-call taint propagates with the viaCall hop mark (KTD5)', () => {
|
||||
const through = results.find(
|
||||
(r) => r.file === 'taint-cases.ts' && blockText(r, 'decorate(raw)'),
|
||||
)!;
|
||||
expect(through).toBeDefined();
|
||||
expect(through.flows!.findings).toHaveLength(1);
|
||||
expect(through.flows!.findings[0].hops.some((h) => h.viaCall === true)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { createHash } from 'crypto';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import Parser from 'tree-sitter';
|
||||
import TypeScript from 'tree-sitter-typescript';
|
||||
|
|
@ -183,3 +184,89 @@ describe('#2082 M2 — the REACHING_DEF emit cap does NOT perturb the chunk key'
|
|||
expect(withExtra).toBe(base);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#2083 M3 U1 — taint sites cross the worker/store boundary intact', () => {
|
||||
const siteSource = `function handler(req, x) {
|
||||
const cp = require('child_process');
|
||||
const b = req.body;
|
||||
cp.exec(escape(x), b);
|
||||
sql\`select \${x}\`;
|
||||
run(...b);
|
||||
}`;
|
||||
|
||||
function siteCfgs() {
|
||||
const { cfgs } = collectFunctionCfgs(tsRoot(siteSource), tsVisitor(), 'sites.ts');
|
||||
expect(cfgs).toHaveLength(1);
|
||||
return cfgs;
|
||||
}
|
||||
|
||||
function allSites(cfgs: readonly { blocks: readonly { statements?: readonly unknown[] }[] }[]) {
|
||||
return cfgs.flatMap((c) =>
|
||||
c.blocks.flatMap((b) =>
|
||||
(b.statements ?? []).flatMap((s) => (s as { sites?: unknown[] }).sites ?? []),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
it('sites survive the worker JSON boundary (mapReplacer/mapReviver) byte-equal', () => {
|
||||
const cfgs = siteCfgs();
|
||||
expect(allSites(cfgs).length).toBeGreaterThan(0);
|
||||
const round = JSON.parse(JSON.stringify(cfgs, mapReplacer), mapReviver);
|
||||
expect(round).toEqual(cfgs);
|
||||
expect(allSites(round)).toEqual(allSites(cfgs));
|
||||
});
|
||||
|
||||
it('sites survive a frozen re-wrap + the DURABLE store interning reviver (no nodeId-dedup loss)', async () => {
|
||||
const { makeInterningReviver } = await import('../../../src/storage/parsedfile-store.js');
|
||||
const cfgs = siteCfgs();
|
||||
// The pipeline deep-freezes ParsedFiles and re-wraps via spread — the CFG
|
||||
// payload itself rides by reference and must tolerate being frozen.
|
||||
const deepFreeze = (o: unknown): unknown => {
|
||||
if (o && typeof o === 'object') {
|
||||
for (const v of Object.values(o)) deepFreeze(v);
|
||||
Object.freeze(o);
|
||||
}
|
||||
return o;
|
||||
};
|
||||
const frozen = (deepFreeze(cfgs) as typeof cfgs).map((c) => ({ ...c }));
|
||||
const raw = JSON.stringify(frozen, mapReplacer);
|
||||
// The durable parsedfile-cache revives with the interning reviver, which
|
||||
// DEDUPS any object carrying a string `nodeId` field — SiteRecord must
|
||||
// never trip it (the KTD2 "no field named nodeId" obligation).
|
||||
const revived = JSON.parse(raw, makeInterningReviver(new Map(), new Map()));
|
||||
expect(revived).toEqual(frozen);
|
||||
expect(allSites(revived)).toEqual(allSites(cfgs));
|
||||
});
|
||||
});
|
||||
|
||||
describe('#2083 M3 U1 — pdg chunk-key namespace version (flag-off keys untouched)', () => {
|
||||
const entries = [
|
||||
{ filePath: 'b.ts', contentHash: 'h2' },
|
||||
{ filePath: 'a.ts', contentHash: 'h1' },
|
||||
];
|
||||
|
||||
it('flag-off chunk keys are BYTE-IDENTICAL across the M3 namespace bump (pinned hash)', () => {
|
||||
// Independent reconstruction of the pre-namespace key format: pdg-off
|
||||
// keys are sha256 over the sorted filePath:contentHash lines and NOTHING
|
||||
// else. This pin fails if the version token ever leaks into non-pdg keys
|
||||
// (which would force a cold re-parse on every flag-off user).
|
||||
const expected = createHash('sha256').update(Buffer.from('a.ts:h1\nb.ts:h2')).digest('hex');
|
||||
expect(computeChunkHash(entries, false)).toBe(expected);
|
||||
expect(computeChunkHash(entries)).toBe(expected);
|
||||
});
|
||||
|
||||
it('pdg-mode keys CHANGED from the M2-era namespace (v1 chunks invalidate on upgrade)', () => {
|
||||
// The M2-era pdg namespace was `pdg:1;maxFn=<v>` — an M3 binary must not
|
||||
// serve a v1 chunk (its cfgSideChannel lacks `sites`, so taint would
|
||||
// silently no-op on warm caches).
|
||||
const joined = 'a.ts:h1\nb.ts:h2';
|
||||
const m2Key = createHash('sha256')
|
||||
.update(Buffer.from(`pdg:1;maxFn=def\n${joined}`))
|
||||
.digest('hex');
|
||||
expect(computeChunkHash(entries, { pdg: true })).not.toBe(m2Key);
|
||||
// and the v2 key is still deterministic + order-independent
|
||||
expect(computeChunkHash([...entries].reverse(), { pdg: true })).toBe(
|
||||
computeChunkHash(entries, { pdg: true }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
344
gitnexus/test/integration/taint-explain.test.ts
Normal file
344
gitnexus/test/integration/taint-explain.test.ts
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
/**
|
||||
* Integration Tests: MCP `explain` tool (#2083 M3 U6)
|
||||
*
|
||||
* End-to-end against a REAL LadybugDB: the pdg-repo fixture is indexed by the
|
||||
* real pipeline with `--pdg` (workers — requires `node scripts/build.js`), the
|
||||
* resulting BasicBlock nodes + TAINTED/SANITIZES edges and the fixture's
|
||||
* Function symbols are persisted into the test DB, and `explain` is exercised
|
||||
* through the full `callTool` dispatch:
|
||||
*
|
||||
* - anchorless enumerate (≥1 finding, decoded hops, deterministic order)
|
||||
* - anchored by file and by symbol (line-span granularity)
|
||||
* - sanitized-only function → zero TAINTED findings (its safety evidence is
|
||||
* the SANITIZES edge, not part of explain's response)
|
||||
* - unknown symbol → context()-style not-found
|
||||
* - a repo WITHOUT the taint layer → the "no taint layer" note, not an error
|
||||
*
|
||||
* Seeding via the real emit output (not hand-written rows) pins the format
|
||||
* compatibility between U4's write path and U6's read path — id template,
|
||||
* `;<kind>` reason header, hop encoding.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, vi } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
|
||||
import { listRegisteredRepos, loadMeta } from '../../src/storage/repo-manager.js';
|
||||
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
|
||||
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
|
||||
|
||||
vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/storage/repo-manager.js')>();
|
||||
return {
|
||||
...actual,
|
||||
listRegisteredRepos: vi.fn().mockResolvedValue([]),
|
||||
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
|
||||
findSiblingClones: vi.fn().mockResolvedValue([]),
|
||||
// No meta.json exists for the seeded test DB — explain's meta probe must
|
||||
// degrade to the TAINTED-row existence probe (the seeded-DB reality).
|
||||
loadMeta: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
});
|
||||
|
||||
const FIXTURE = path.join(__dirname, 'cfg', 'fixtures', 'pdg-repo');
|
||||
|
||||
// ─── Block 1: a --pdg index with real taint findings ─────────────────
|
||||
|
||||
withTestLbugDB(
|
||||
'taint-explain',
|
||||
(handle) => {
|
||||
describe('explain tool against a --pdg index', () => {
|
||||
let backend: LocalBackend;
|
||||
|
||||
beforeAll(() => {
|
||||
const ext = handle as typeof handle & { _backend?: LocalBackend };
|
||||
if (!ext._backend) {
|
||||
throw new Error(
|
||||
'LocalBackend not initialized — afterSetup did not attach _backend to handle',
|
||||
);
|
||||
}
|
||||
backend = ext._backend;
|
||||
});
|
||||
|
||||
it('anchorless explain enumerates the persisted findings with decoded hops', async () => {
|
||||
const result = await backend.callTool('explain', {});
|
||||
expect(result).not.toHaveProperty('error');
|
||||
expect(result.totalFindings).toBeGreaterThanOrEqual(1);
|
||||
expect(result.findings.length).toBeGreaterThanOrEqual(1);
|
||||
expect(result.truncated).toBeUndefined();
|
||||
// The vulnerable flow: req.body → cmd → exec(cmd) in vuln.ts.
|
||||
const vuln = result.findings.find((f: any) => f.file.endsWith('vuln.ts'));
|
||||
expect(vuln).toBeDefined();
|
||||
expect(vuln.sinkKind).toBe('command-injection');
|
||||
expect(vuln.functionLine).toBe(9); // runUserCommand's start line
|
||||
// Ordered hops with the variable carried on each hop (AC3): seed def
|
||||
// (cmd @ const line 10) → sink use (cmd @ exec line 11).
|
||||
expect(vuln.hops.map((h: any) => `${h.variable}@${h.line}`)).toEqual(['cmd@10', 'cmd@11']);
|
||||
expect(vuln.source).toEqual({ variable: 'cmd', line: 10 });
|
||||
expect(vuln.sink).toEqual({ line: 11 });
|
||||
expect(vuln.pathIncomplete).toBeUndefined();
|
||||
// The intra-procedural contract caveat reaches the consumer.
|
||||
expect(result.note).toMatch(/intra-procedural/i);
|
||||
});
|
||||
|
||||
it('anchorless enumerate is deterministic across calls', async () => {
|
||||
const a = await backend.callTool('explain', {});
|
||||
const b = await backend.callTool('explain', {});
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
|
||||
it('anchored by file path returns the finding (suffix match accepted)', async () => {
|
||||
for (const target of ['vuln.ts']) {
|
||||
const result = await backend.callTool('explain', { target });
|
||||
expect(result).not.toHaveProperty('error');
|
||||
expect(result.anchor).toEqual({ file: target });
|
||||
expect(result.findings.length).toBeGreaterThanOrEqual(1);
|
||||
for (const f of result.findings) expect(f.file.endsWith('vuln.ts')).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('anchored by an unrelated file returns zero findings (repo HAS the layer — no note about it)', async () => {
|
||||
const result = await backend.callTool('explain', { target: 'sample.ts' });
|
||||
expect(result).not.toHaveProperty('error');
|
||||
expect(result.findings).toEqual([]);
|
||||
expect(result.totalFindings).toBe(0);
|
||||
// The repo has TAINTED rows, so the "no taint layer" hint must NOT fire.
|
||||
expect(result.note ?? '').not.toMatch(/no taint layer/i);
|
||||
});
|
||||
|
||||
it('anchored by the vulnerable function name returns full hop detail', async () => {
|
||||
const result = await backend.callTool('explain', { target: 'runUserCommand' });
|
||||
expect(result).not.toHaveProperty('error');
|
||||
expect(result.anchor.symbol).toBe('runUserCommand');
|
||||
expect(result.findings).toHaveLength(1);
|
||||
expect(result.totalFindings).toBe(1);
|
||||
const f = result.findings[0];
|
||||
expect(f.sinkKind).toBe('command-injection');
|
||||
expect(f.hops.map((h: any) => h.variable)).toEqual(['cmd', 'cmd']);
|
||||
});
|
||||
|
||||
it('the sanitized-only function returns no TAINTED finding', async () => {
|
||||
const result = await backend.callTool('explain', { target: 'sendEncoded' });
|
||||
expect(result).not.toHaveProperty('error');
|
||||
expect(result.anchor.symbol).toBe('sendEncoded');
|
||||
expect(result.findings).toEqual([]);
|
||||
expect(result.totalFindings).toBe(0);
|
||||
});
|
||||
|
||||
it('an unknown symbol target mirrors context() not-found semantics', async () => {
|
||||
const result = await backend.callTool('explain', { target: 'nonexistentTaintFn999' });
|
||||
expect(result).toHaveProperty('error');
|
||||
expect(result.error).toMatch(/not found/i);
|
||||
});
|
||||
|
||||
it('a dotted symbol name resolves as a symbol, not a silent file miss', async () => {
|
||||
// Regression: `Class.method` was classified as a file (the `.method`
|
||||
// extension-like suffix) and returned a silent empty file-anchored
|
||||
// result. It must now route to symbol resolution — here, not-found.
|
||||
const result = await backend.callTool('explain', { target: 'UserController.create' });
|
||||
expect(result).toHaveProperty('error');
|
||||
expect(result.error).toMatch(/not found/i);
|
||||
// Must NOT be a silent file-anchored empty result.
|
||||
expect(result.anchor).toBeUndefined();
|
||||
});
|
||||
|
||||
it('a dotted symbol whose tail looks bare still resolves as a symbol', async () => {
|
||||
// `runUserCommand` is a real fixture symbol; a dotted lead-in that does
|
||||
// not match any symbol confirms the symbol branch (not file routing).
|
||||
const result = await backend.callTool('explain', { target: 'Service.runUserCommand' });
|
||||
expect(result).toHaveProperty('error');
|
||||
expect(result.error).toMatch(/not found/i);
|
||||
});
|
||||
|
||||
it('rejects an out-of-bounds limit with a clear error', async () => {
|
||||
// Includes the non-integer / non-finite / non-numeric cases the
|
||||
// interpolated `LIMIT ${limit}` depends on the guard rejecting.
|
||||
for (const limit of [0, -1, 1.5, 10_000, NaN, Infinity, -Infinity, '50']) {
|
||||
const result = await backend.callTool('explain', { limit });
|
||||
expect(result).toHaveProperty('error');
|
||||
expect(result.error).toMatch(/limit/i);
|
||||
}
|
||||
});
|
||||
|
||||
it('limit pages the enumerate and reports truncation honestly', async () => {
|
||||
const all = await backend.callTool('explain', {});
|
||||
const page = await backend.callTool('explain', { limit: 1 });
|
||||
expect(page.findings).toHaveLength(Math.min(1, all.totalFindings));
|
||||
expect(page.totalFindings).toBe(all.totalFindings);
|
||||
if (all.totalFindings > 1) {
|
||||
expect(page.truncated).toBe(true);
|
||||
// Deterministic order: the page is a prefix of the full enumerate.
|
||||
expect(page.findings[0]).toEqual(all.findings[0]);
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
{
|
||||
poolAdapter: true,
|
||||
afterSetup: async (handle) => {
|
||||
// 1. Index the pdg-repo fixture with the REAL pipeline (--pdg on).
|
||||
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-explain-'));
|
||||
try {
|
||||
fs.cpSync(FIXTURE, repoDir, { recursive: true });
|
||||
const pipelineResult = await runPipelineFromRepo(repoDir, () => {}, { pdg: true });
|
||||
|
||||
// 2. Persist the emit output into the test DB: BasicBlock nodes,
|
||||
// TAINTED/SANITIZES edges, and the Function symbols (for the
|
||||
// symbol-anchored path through resolveSymbolCandidates).
|
||||
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
const nodes: Array<{ label: string; props: Record<string, unknown> }> = [];
|
||||
pipelineResult.graph.forEachNode((n) => {
|
||||
if (n.label === 'BasicBlock') {
|
||||
nodes.push({
|
||||
label: 'BasicBlock',
|
||||
props: {
|
||||
id: n.id,
|
||||
filePath: n.properties.filePath ?? '',
|
||||
startLine: n.properties.startLine ?? 0,
|
||||
endLine: n.properties.endLine ?? 0,
|
||||
text: n.properties.text ?? '',
|
||||
},
|
||||
});
|
||||
} else if (n.label === 'Function') {
|
||||
nodes.push({
|
||||
label: 'Function',
|
||||
props: {
|
||||
id: n.id,
|
||||
name: n.properties.name ?? '',
|
||||
filePath: n.properties.filePath ?? '',
|
||||
startLine: n.properties.startLine ?? 0,
|
||||
endLine: n.properties.endLine ?? 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
for (const node of nodes) {
|
||||
const assignments = Object.keys(node.props)
|
||||
.map((k) => `${k}: $${k}`)
|
||||
.join(', ');
|
||||
await adapter.executePrepared(
|
||||
`CREATE (n:${node.label} {${assignments}})`,
|
||||
node.props as Record<string, any>,
|
||||
);
|
||||
}
|
||||
let taintEdges = 0;
|
||||
for (const rel of pipelineResult.graph.iterRelationships()) {
|
||||
if (rel.type !== 'TAINTED' && rel.type !== 'SANITIZES') continue;
|
||||
await adapter.executePrepared(
|
||||
`MATCH (a:BasicBlock {id: $src}), (b:BasicBlock {id: $dst})
|
||||
CREATE (a)-[:CodeRelation {type: '${rel.type}', confidence: $confidence, reason: $reason, step: 0}]->(b)`,
|
||||
{
|
||||
src: rel.sourceId,
|
||||
dst: rel.targetId,
|
||||
confidence: rel.confidence ?? 1.0,
|
||||
reason: rel.reason ?? '',
|
||||
},
|
||||
);
|
||||
taintEdges++;
|
||||
}
|
||||
if (taintEdges === 0) {
|
||||
throw new Error('fixture produced no TAINTED/SANITIZES edges — taint emit regressed?');
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(repoDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// 3. Register the test DB and boot the backend (calltool harness shape).
|
||||
vi.mocked(listRegisteredRepos).mockResolvedValue([
|
||||
{
|
||||
name: 'taint-repo',
|
||||
path: '/taint/repo',
|
||||
storagePath: handle.tmpHandle.dbPath,
|
||||
indexedAt: new Date().toISOString(),
|
||||
lastCommit: 'abc123',
|
||||
stats: { files: 2, nodes: 4, communities: 0, processes: 0 },
|
||||
},
|
||||
]);
|
||||
const backend = new LocalBackend();
|
||||
await backend.init();
|
||||
(handle as any)._backend = backend;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// ─── Block 2: a repo indexed WITHOUT --pdg ───────────────────────────
|
||||
|
||||
withTestLbugDB(
|
||||
'taint-explain-nopdg',
|
||||
(handle) => {
|
||||
describe('explain tool without a taint layer', () => {
|
||||
let backend: LocalBackend;
|
||||
|
||||
beforeAll(() => {
|
||||
const ext = handle as typeof handle & { _backend?: LocalBackend };
|
||||
if (!ext._backend) {
|
||||
throw new Error(
|
||||
'LocalBackend not initialized — afterSetup did not attach _backend to handle',
|
||||
);
|
||||
}
|
||||
backend = ext._backend;
|
||||
});
|
||||
|
||||
it('returns the no-taint-layer note via the row-existence probe (meta unreadable)', async () => {
|
||||
const result = await backend.callTool('explain', {});
|
||||
expect(result).not.toHaveProperty('error');
|
||||
expect(result.findings).toEqual([]);
|
||||
expect(result.totalFindings).toBe(0);
|
||||
expect(result.note).toMatch(/no taint layer/i);
|
||||
expect(result.note).toContain('--pdg');
|
||||
});
|
||||
|
||||
it('an anchored call also reports the missing layer, not a bogus empty result', async () => {
|
||||
const result = await backend.callTool('explain', { target: 'plain.ts' });
|
||||
expect(result).not.toHaveProperty('error');
|
||||
expect(result.findings).toEqual([]);
|
||||
expect(result.note).toMatch(/no taint layer/i);
|
||||
});
|
||||
|
||||
it('returns the note via the RepoMeta.pdg probe when meta is readable but unstamped', async () => {
|
||||
// A readable meta WITHOUT a pdg stamp short-circuits before any
|
||||
// block-space query (the #2099 F1 presence ≡ layer-exists contract).
|
||||
vi.mocked(loadMeta).mockResolvedValueOnce({} as any);
|
||||
const result = await backend.callTool('explain', {});
|
||||
expect(result.findings).toEqual([]);
|
||||
expect(result.totalFindings).toBe(0);
|
||||
expect(result.note).toMatch(/no taint layer/i);
|
||||
});
|
||||
|
||||
it('an M1/M2-era pdg stamp (no taintModelVersion) reports the missing taint layer', async () => {
|
||||
// The pdg stamp exists (BasicBlock/REACHING_DEF were recorded) but
|
||||
// taint never ran — no taintModelVersion. The taint-layer probe must
|
||||
// gate on taintModelVersion, not generic pdg presence, so this surfaces
|
||||
// the actionable "run analyze" hint instead of a bare empty result.
|
||||
vi.mocked(loadMeta).mockResolvedValueOnce({
|
||||
pdg: { mode: 'on', maxFunctionLines: 2000 },
|
||||
} as any);
|
||||
const result = await backend.callTool('explain', {});
|
||||
expect(result.findings).toEqual([]);
|
||||
expect(result.totalFindings).toBe(0);
|
||||
expect(result.note).toMatch(/no taint layer/i);
|
||||
});
|
||||
});
|
||||
},
|
||||
{
|
||||
seed: [
|
||||
`CREATE (fn:Function {id: 'func:plainFn', name: 'plainFn', filePath: 'src/plain.ts', startLine: 1, endLine: 5, isExported: true, content: 'function plainFn() {}', description: 'no taint layer here'})`,
|
||||
],
|
||||
poolAdapter: true,
|
||||
afterSetup: async (handle) => {
|
||||
vi.mocked(listRegisteredRepos).mockResolvedValue([
|
||||
{
|
||||
name: 'plain-repo',
|
||||
path: '/plain/repo',
|
||||
storagePath: handle.tmpHandle.dbPath,
|
||||
indexedAt: new Date().toISOString(),
|
||||
lastCommit: 'def456',
|
||||
stats: { files: 1, nodes: 1, communities: 0, processes: 0 },
|
||||
},
|
||||
]);
|
||||
const backend = new LocalBackend();
|
||||
await backend.init();
|
||||
(handle as any)._backend = backend;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
@ -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')]]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────────────
|
||||
|
|
|
|||
315
gitnexus/test/unit/taint/model-match.test.ts
Normal file
315
gitnexus/test/unit/taint/model-match.test.ts
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
/**
|
||||
* U2 (#2083 M3) — built-in TS/JS taint model + import-aware site matcher.
|
||||
*
|
||||
* Fixtures parse REAL source: CFGs (and therefore SiteRecords) come from the
|
||||
* worker-side TS CFG visitor (the harvest.test.ts harness pattern), and
|
||||
* ParsedImports come from the real scope-capture + interpretTsImport path
|
||||
* (the typescript-imports.test.ts harness pattern) — matches run against the
|
||||
* exact structures U4 will feed the matcher, never hand-built mocks.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { cfgOf, importsFor } from '../../helpers/ts-cfg-harness.js';
|
||||
import { hasTaintSafeSites } from '../../../src/core/ingestion/taint/site-safety.js';
|
||||
import type { SourceSinkSanitizerSpec } from '../../../src/core/ingestion/taint/source-sink-config.js';
|
||||
import {
|
||||
TS_JS_TAINT_MODEL,
|
||||
computeTaintModelVersion,
|
||||
registerBuiltinTaintModels,
|
||||
taintModelVersion,
|
||||
} from '../../../src/core/ingestion/taint/typescript-model.js';
|
||||
import {
|
||||
buildTaintImportIndex,
|
||||
matchFunctionSites,
|
||||
type FunctionSiteMatches,
|
||||
type MatchedSanitizerCall,
|
||||
type MatchedSinkCall,
|
||||
type MatchedSourceRead,
|
||||
} from '../../../src/core/ingestion/taint/match.js';
|
||||
import {
|
||||
getSourceSinkConfig,
|
||||
clearSourceSinkRegistry,
|
||||
registeredTaintLanguages,
|
||||
} from '../../../src/core/ingestion/taint/source-sink-registry.js';
|
||||
|
||||
/** Match function #`fnIndex` of `code` against the built-in model (or `spec`). */
|
||||
function matchesOf(
|
||||
code: string,
|
||||
fnIndex = 0,
|
||||
spec: SourceSinkSanitizerSpec = TS_JS_TAINT_MODEL,
|
||||
): FunctionSiteMatches {
|
||||
const cfg = cfgOf(code, fnIndex);
|
||||
// The matcher's documented precondition — real harvests must always pass.
|
||||
expect(hasTaintSafeSites(cfg)).toBe(true);
|
||||
return matchFunctionSites(cfg, spec, buildTaintImportIndex(importsFor(code)));
|
||||
}
|
||||
|
||||
const allSinks = (m: FunctionSiteMatches): MatchedSinkCall[] =>
|
||||
m.statements.flatMap((s) => [...s.sinks]);
|
||||
const allSources = (m: FunctionSiteMatches): MatchedSourceRead[] =>
|
||||
m.statements.flatMap((s) => [...s.sources]);
|
||||
const allSanitizers = (m: FunctionSiteMatches): MatchedSanitizerCall[] =>
|
||||
m.statements.flatMap((s) => [...s.sanitizers]);
|
||||
|
||||
describe('sink resolution — ESM import joins', () => {
|
||||
it('named import: `import { exec } from "child_process"; exec(c)` matches command-injection', () => {
|
||||
const m = matchesOf(`import { exec } from 'child_process';
|
||||
function f(c) { exec(c); }`);
|
||||
const sinks = allSinks(m);
|
||||
expect(sinks).toHaveLength(1);
|
||||
expect(sinks[0].entry.kind).toBe('command-injection');
|
||||
expect(sinks[0].entry.name).toBe('exec');
|
||||
expect([...sinks[0].argPositions]).toEqual([0]);
|
||||
expect(m.hasSink).toBe(true);
|
||||
});
|
||||
|
||||
it('alias import: `import { exec as run }` — run(c) resolves to child_process.exec', () => {
|
||||
const m = matchesOf(`import { exec as run } from 'child_process';
|
||||
function f(c) { run(c); }`);
|
||||
const sinks = allSinks(m);
|
||||
expect(sinks).toHaveLength(1);
|
||||
expect(sinks[0].entry.name).toBe('exec');
|
||||
});
|
||||
|
||||
it('namespace import: `import * as cp` — cp.exec(c) resolves via the receiver binding', () => {
|
||||
const m = matchesOf(`import * as cp from 'child_process';
|
||||
function f(c) { cp.exec(c); }`);
|
||||
expect(allSinks(m).map((s) => s.entry.name)).toEqual(['exec']);
|
||||
});
|
||||
|
||||
it('node: scheme prefix is normalized — `from "node:child_process"` matches too', () => {
|
||||
const m = matchesOf(`import { execSync } from 'node:child_process';
|
||||
function f(c) { execSync(c); }`);
|
||||
expect(allSinks(m).map((s) => s.entry.name)).toEqual(['execSync']);
|
||||
});
|
||||
|
||||
it('an in-FUNCTION local `exec` shadows the import — no match', () => {
|
||||
const m = matchesOf(`import { exec } from 'child_process';
|
||||
function f(c) { function exec(x) { return x; } exec(c); }`);
|
||||
expect(allSinks(m)).toHaveLength(0);
|
||||
expect(m.hasSink).toBe(false);
|
||||
});
|
||||
|
||||
it('a module-level local `exec` (no import) does NOT match — synthetic binding, no import entry', () => {
|
||||
const m = matchesOf(
|
||||
`function exec(x) { return x; }
|
||||
function g(c) { exec(c); }`,
|
||||
1, // g — index 0 is the local exec itself
|
||||
);
|
||||
expect(allSinks(m)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sink resolution — globals and require joins', () => {
|
||||
it('eval(x) matches code-injection via the synthetic-global fallback', () => {
|
||||
const m = matchesOf(`function f(x) { eval(x); }`);
|
||||
const sinks = allSinks(m);
|
||||
expect(sinks).toHaveLength(1);
|
||||
expect(sinks[0].entry.kind).toBe('code-injection');
|
||||
});
|
||||
|
||||
it('`new Function(x)` matches; a bare `Function(x)` CALL does not (newOnly)', () => {
|
||||
const withNew = matchesOf(`function f(x) { const fn = new Function(x); }`);
|
||||
expect(allSinks(withNew).map((s) => s.entry.name)).toEqual(['Function']);
|
||||
const bareCall = matchesOf(`function f(x) { const fn = Function(x); }`);
|
||||
expect(allSinks(bareCall)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('require literal join: `const cp = require("child_process"); cp.exec(c)` matches', () => {
|
||||
const m = matchesOf(`function f(c) { const cp = require('child_process'); cp.exec(c); }`);
|
||||
const sinks = allSinks(m);
|
||||
expect(sinks).toHaveLength(1);
|
||||
expect(sinks[0].entry.name).toBe('exec');
|
||||
expect(sinks[0].entry.kind).toBe('command-injection');
|
||||
});
|
||||
|
||||
it('a require’d local utility named exec does NOT match (no bare-name fallback for non-globals)', () => {
|
||||
const m = matchesOf(`function f(c) { const exec = require('./my-utils'); exec(c); }`);
|
||||
expect(allSinks(m)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('non-renamed destructured require resolves via the dual interpretation', () => {
|
||||
const m = matchesOf(`function f(c) { const { exec } = require('child_process'); exec(c); }`);
|
||||
expect(allSinks(m).map((s) => s.entry.name)).toEqual(['exec']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sources — conventional member reads', () => {
|
||||
it('req.body matches remote-input; request.body matches; myObj.body does not', () => {
|
||||
const m = matchesOf(`function f(req, request, myObj) {
|
||||
const a = req.body;
|
||||
const b = request.body;
|
||||
const c = myObj.body;
|
||||
}`);
|
||||
const sources = allSources(m);
|
||||
expect(sources).toHaveLength(2);
|
||||
expect(sources.every((s) => s.entry.kind === 'remote-input')).toBe(true);
|
||||
expect(m.hasSource).toBe(true);
|
||||
});
|
||||
|
||||
it('all five conventional properties match; an unlisted property does not', () => {
|
||||
const m = matchesOf(`function f(req) {
|
||||
const a = req.body, b = req.query, c = req.params, d = req.headers, e = req.cookies;
|
||||
const z = req.socket;
|
||||
}`);
|
||||
expect(allSources(m)).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sink argument-position discipline', () => {
|
||||
it('exec(safe, tainted): only the registered position 0 is a sink position', () => {
|
||||
const m = matchesOf(`import { exec } from 'child_process';
|
||||
function f(safe, tainted) { exec(safe, tainted); }`);
|
||||
const [sink] = allSinks(m);
|
||||
expect([...sink.argPositions]).toEqual([0]); // position 1 carries an occurrence but is not registered
|
||||
});
|
||||
|
||||
it('spread: exec(...args) matches — recorded position ≥ spread index degrades soundly', () => {
|
||||
const m = matchesOf(`import { exec } from 'child_process';
|
||||
function f(args) { exec(...args); }`);
|
||||
const [sink] = allSinks(m);
|
||||
expect([...sink.argPositions]).toEqual([0]);
|
||||
});
|
||||
|
||||
it('spread precision: a pre-spread position stays exact; post-spread matches any q ≥ spread', () => {
|
||||
// Custom spec: sink position 1 only. `s2(a, ...rest)` — recorded 0 is
|
||||
// BEFORE the spread (exact: no match); recorded 1 is the spread (match).
|
||||
const spec: SourceSinkSanitizerSpec = {
|
||||
sources: [],
|
||||
sinks: [{ name: 's2', kind: 'command-injection', args: [1], global: true }],
|
||||
sanitizers: [],
|
||||
};
|
||||
const m = matchesOf(`function f(a, rest) { s2(a, ...rest); }`, 0, spec);
|
||||
const [sink] = allSinks(m);
|
||||
expect([...sink.argPositions]).toEqual([1]);
|
||||
});
|
||||
|
||||
it('tagged template: substitutions aggregate at position 0 and match any registered position', () => {
|
||||
const spec: SourceSinkSanitizerSpec = {
|
||||
sources: [],
|
||||
sinks: [{ name: 'sql', kind: 'sql-injection', args: [1], global: true }],
|
||||
sanitizers: [],
|
||||
};
|
||||
const m = matchesOf(`function f(id) { sql\`select \${id}\`; }`, 0, spec);
|
||||
const [sink] = allSinks(m);
|
||||
expect([...sink.argPositions]).toEqual([0]);
|
||||
});
|
||||
|
||||
it('a sink whose dangerous positions carry no occurrences is not reported', () => {
|
||||
const m = matchesOf(`import { exec } from 'child_process';
|
||||
function f(t) { exec('ls -la', t); }`);
|
||||
expect(allSinks(m)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('receiver-conventional sinks', () => {
|
||||
it('res.send / res.write match xss; out.send does not', () => {
|
||||
const m = matchesOf(`function f(res, out, x) { res.send(x); res.write(x); out.send(x); }`);
|
||||
expect(allSinks(m).map((s) => s.entry.name)).toEqual(['send', 'write']);
|
||||
expect(allSinks(m).every((s) => s.entry.kind === 'xss')).toBe(true);
|
||||
});
|
||||
|
||||
it('.query/.execute match sql-injection on ANY receiver', () => {
|
||||
const m = matchesOf(`function f(db, pool, x) { db.query(x); pool.execute(x); }`);
|
||||
expect(allSinks(m).map((s) => s.entry.kind)).toEqual(['sql-injection', 'sql-injection']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizers — import-aware only, kind-scoped', () => {
|
||||
it('path.basename matches and neutralizes path-traversal, NOT command-injection', () => {
|
||||
const m = matchesOf(`import path from 'path';
|
||||
function f(p) { const safe = path.basename(p); }`);
|
||||
const sans = allSanitizers(m);
|
||||
expect(sans).toHaveLength(1);
|
||||
expect(sans[0].entry.neutralizes).toContain('path-traversal');
|
||||
expect(sans[0].entry.neutralizes).not.toContain('command-injection');
|
||||
// resultDefs carries the kill target (KTD4b)
|
||||
expect(sans[0].resultDefs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('validator.escape via named import matches xss', () => {
|
||||
const m = matchesOf(`import { escape } from 'validator';
|
||||
function f(x) { const safe = escape(x); }`);
|
||||
const sans = allSanitizers(m);
|
||||
expect(sans).toHaveLength(1);
|
||||
expect([...sans[0].entry.neutralizes]).toEqual(['xss']);
|
||||
});
|
||||
|
||||
it('default-imported escape-html matches via the default pseudo-name', () => {
|
||||
const m = matchesOf(`import escapeHtml from 'escape-html';
|
||||
function f(x) { const safe = escapeHtml(x); }`);
|
||||
expect(allSanitizers(m)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('encodeURIComponent matches as a true global (xss + path-traversal)', () => {
|
||||
const m = matchesOf(`function f(x) { const safe = encodeURIComponent(x); }`);
|
||||
const sans = allSanitizers(m);
|
||||
expect(sans).toHaveLength(1);
|
||||
expect([...sans[0].entry.neutralizes]).toEqual(['xss', 'path-traversal']);
|
||||
});
|
||||
|
||||
it('a user-defined in-file `escape` is NEVER a sanitizer (no bare-name resolution)', () => {
|
||||
const m = matchesOf(
|
||||
`function escape(s) { return s; }
|
||||
function f(x) { const safe = escape(x); }`,
|
||||
1, // f
|
||||
);
|
||||
expect(allSanitizers(m)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('value-position sanitizer (exec(escape(x))) matches with EMPTY resultDefs — interposition substrate', () => {
|
||||
const m = matchesOf(`import { exec } from 'child_process';
|
||||
import { escape } from 'validator';
|
||||
function f(x) { exec(escape(x)); }`);
|
||||
const sans = allSanitizers(m);
|
||||
expect(sans).toHaveLength(1);
|
||||
expect(sans[0].resultDefs).toHaveLength(0);
|
||||
// The sink still matches — interposition is U3's call, not the matcher's.
|
||||
expect(allSinks(m)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registry + model identity', () => {
|
||||
beforeEach(() => clearSourceSinkRegistry());
|
||||
|
||||
it('registerBuiltinTaintModels registers typescript AND javascript (idempotent); others stay undefined', () => {
|
||||
registerBuiltinTaintModels();
|
||||
registerBuiltinTaintModels(); // idempotent — last-write-wins on the same ids
|
||||
expect(registeredTaintLanguages().sort()).toEqual(['javascript', 'typescript']);
|
||||
expect(getSourceSinkConfig('typescript')).toBe(TS_JS_TAINT_MODEL);
|
||||
expect(getSourceSinkConfig('javascript')).toBe(TS_JS_TAINT_MODEL);
|
||||
expect(getSourceSinkConfig('python')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('taintModelVersion is the digest of the full built-in model', () => {
|
||||
expect(taintModelVersion).toBe(computeTaintModelVersion(TS_JS_TAINT_MODEL));
|
||||
expect(taintModelVersion).toMatch(/^[0-9a-f]{12}$/);
|
||||
});
|
||||
|
||||
it('adding an entry changes the version', () => {
|
||||
const added: SourceSinkSanitizerSpec = {
|
||||
...TS_JS_TAINT_MODEL,
|
||||
sinks: [...TS_JS_TAINT_MODEL.sinks, { name: 'load', kind: 'code-injection', module: 'vm' }],
|
||||
};
|
||||
expect(computeTaintModelVersion(added)).not.toBe(taintModelVersion);
|
||||
});
|
||||
|
||||
it('changing only a kind label changes the version', () => {
|
||||
const relabeled: SourceSinkSanitizerSpec = {
|
||||
...TS_JS_TAINT_MODEL,
|
||||
sinks: TS_JS_TAINT_MODEL.sinks.map((s) =>
|
||||
s.name === 'exec' ? { ...s, kind: 'xss' as const } : s,
|
||||
),
|
||||
};
|
||||
expect(computeTaintModelVersion(relabeled)).not.toBe(taintModelVersion);
|
||||
});
|
||||
|
||||
it('the version is content-derived: key order does not matter, entry order does', () => {
|
||||
const reordered: SourceSinkSanitizerSpec = {
|
||||
sanitizers: TS_JS_TAINT_MODEL.sanitizers,
|
||||
sinks: TS_JS_TAINT_MODEL.sinks,
|
||||
sources: TS_JS_TAINT_MODEL.sources,
|
||||
};
|
||||
expect(computeTaintModelVersion(reordered)).toBe(taintModelVersion);
|
||||
});
|
||||
});
|
||||
305
gitnexus/test/unit/taint/path-codec.test.ts
Normal file
305
gitnexus/test/unit/taint/path-codec.test.ts
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
/**
|
||||
* U4 (#2083 M3) — the shared taint-path reason codec (plan KTD6).
|
||||
*
|
||||
* The wire format must round-trip BYTE-EXACT through the CSV persistence
|
||||
* layer (`escapeCSVField ∘ sanitizeUTF8`, csv-generator.ts) — that
|
||||
* composition is exercised here verbatim, including the un-escape a DB load
|
||||
* performs. Truncation (hop cap, byte cap, unencodable hop) must decode as
|
||||
* "path incomplete", never as an error; malformed input must produce a typed
|
||||
* failure, never a throw.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
TAINT_PATH_CODEC_VERSION,
|
||||
TAINT_REASON_MAX_BYTES,
|
||||
TAINT_PATH_TRUNCATION_MARKER,
|
||||
encodeTaintPath,
|
||||
decodeTaintPath,
|
||||
type TaintPathHopInput,
|
||||
} from '../../../src/core/ingestion/taint/path-codec.js';
|
||||
import { escapeCSVField, sanitizeUTF8 } from '../../../src/core/lbug/csv-generator.js';
|
||||
|
||||
/** Inverse of escapeCSVField — what a CSV/DB load applies to the stored cell. */
|
||||
function unescapeCSVField(cell: string): string {
|
||||
expect(cell.startsWith('"') && cell.endsWith('"')).toBe(true);
|
||||
return cell.slice(1, -1).replace(/""/g, '"');
|
||||
}
|
||||
|
||||
const roundTrip = (hops: readonly TaintPathHopInput[]) => {
|
||||
const { reason } = encodeTaintPath(hops);
|
||||
const decoded = decodeTaintPath(reason);
|
||||
if (!decoded.ok) throw new Error(`decode failed: ${decoded.error}`);
|
||||
return { reason, decoded };
|
||||
};
|
||||
|
||||
describe('encodeTaintPath / decodeTaintPath round trip', () => {
|
||||
it('round-trips an ordered multi-hop path with variables, lines, and viaCall', () => {
|
||||
const hops: TaintPathHopInput[] = [
|
||||
{ name: 'req', line: 3 },
|
||||
{ name: 'cmd', line: 4, viaCall: true },
|
||||
{ name: 'cmd', line: 7 },
|
||||
];
|
||||
const { reason, decoded } = roundTrip(hops);
|
||||
expect(reason).toBe('1|req:3|cmd:4:c|cmd:7');
|
||||
expect(decoded.version).toBe(TAINT_PATH_CODEC_VERSION);
|
||||
expect(decoded.truncated).toBe(false);
|
||||
expect(decoded.hops).toEqual([
|
||||
{ variable: 'req', line: 3, viaCall: false },
|
||||
{ variable: 'cmd', line: 4, viaCall: true },
|
||||
{ variable: 'cmd', line: 7, viaCall: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('round-trips the empty path (version prefix only)', () => {
|
||||
const { reason, decoded } = roundTrip([]);
|
||||
expect(reason).toBe('1');
|
||||
expect(decoded.hops).toEqual([]);
|
||||
expect(decoded.truncated).toBe(false);
|
||||
});
|
||||
|
||||
it('round-trips identifier-charset names: $, _, #, digits, case', () => {
|
||||
const names = ['$jq', '_private', '#3', 'CONST_99', 'aB$_#z', 'x'];
|
||||
const hops = names.map((name, i) => ({ name, line: i + 1, viaCall: i % 2 === 0 }));
|
||||
const { decoded } = roundTrip(hops);
|
||||
expect(decoded.hops.map((h) => h.variable)).toEqual(names);
|
||||
});
|
||||
|
||||
it('fuzz-ish sweep: random identifier-charset names of varied length survive', () => {
|
||||
const CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_$#';
|
||||
// Deterministic LCG so a failure is reproducible.
|
||||
let seed = 0xc0ffee;
|
||||
const next = (): number => {
|
||||
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
|
||||
return seed;
|
||||
};
|
||||
for (let trial = 0; trial < 200; trial++) {
|
||||
const hops: TaintPathHopInput[] = [];
|
||||
const count = (next() % 8) + 1;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const len = (next() % 24) + 1;
|
||||
let name = '';
|
||||
for (let j = 0; j < len; j++) name += CHARSET[next() % CHARSET.length];
|
||||
hops.push({ name, line: next() % 100000, viaCall: next() % 2 === 0 });
|
||||
}
|
||||
const { reason, decoded } = roundTrip(hops);
|
||||
expect(decoded.truncated).toBe(false);
|
||||
expect(decoded.hops).toEqual(
|
||||
hops.map((h) => ({ variable: h.name, line: h.line, viaCall: h.viaCall === true })),
|
||||
);
|
||||
// The whole wire string is printable ASCII (the CSV-survival invariant).
|
||||
expect(/^[\x20-\x7e]+$/.test(reason)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('kind header (;<kind> — U6, the only persisted channel for sinkKind)', () => {
|
||||
it('round-trips a kind header with hops', () => {
|
||||
const { reason } = encodeTaintPath(
|
||||
[
|
||||
{ name: 'req', line: 3 },
|
||||
{ name: 'cmd', line: 4 },
|
||||
],
|
||||
{ kind: 'command-injection' },
|
||||
);
|
||||
expect(reason).toBe('1;command-injection|req:3|cmd:4');
|
||||
const decoded = decodeTaintPath(reason);
|
||||
expect(decoded.ok).toBe(true);
|
||||
if (decoded.ok) {
|
||||
expect(decoded.kind).toBe('command-injection');
|
||||
expect(decoded.hops.map((h) => h.variable)).toEqual(['req', 'cmd']);
|
||||
}
|
||||
});
|
||||
|
||||
it('round-trips a kind header on the hop-less path', () => {
|
||||
const { reason } = encodeTaintPath([], { kind: 'xss' });
|
||||
expect(reason).toBe('1;xss');
|
||||
const decoded = decodeTaintPath(reason);
|
||||
expect(decoded.ok).toBe(true);
|
||||
if (decoded.ok) {
|
||||
expect(decoded.kind).toBe('xss');
|
||||
expect(decoded.hops).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('kind + truncation marker coexist; the header is never sacrificed to the byte cap', () => {
|
||||
const { reason, truncated } = encodeTaintPath([{ name: 'longVariableName', line: 12345 }], {
|
||||
kind: 'sql-injection',
|
||||
maxBytes: 8, // far below the header size — floor lifts it, hops drop
|
||||
});
|
||||
expect(truncated).toBe(true);
|
||||
expect(reason).toBe(`1;sql-injection|${TAINT_PATH_TRUNCATION_MARKER}`);
|
||||
const decoded = decodeTaintPath(reason);
|
||||
expect(decoded.ok).toBe(true);
|
||||
if (decoded.ok) {
|
||||
expect(decoded.kind).toBe('sql-injection');
|
||||
expect(decoded.truncated).toBe(true);
|
||||
expect(decoded.hops).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('a kind outside [a-z0-9-] is dropped (header omitted), never corrupted into the wire', () => {
|
||||
for (const bad of ['Command-Injection', 'a;b', 'k|x', 'café', '']) {
|
||||
const { reason } = encodeTaintPath([{ name: 'x', line: 1 }], { kind: bad });
|
||||
expect(reason).toBe('1|x:1');
|
||||
const decoded = decodeTaintPath(reason);
|
||||
expect(decoded.ok).toBe(true);
|
||||
if (decoded.ok) expect(decoded.kind).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('a header-less version-1 string still decodes (kind undefined)', () => {
|
||||
const decoded = decodeTaintPath('1|a:1');
|
||||
expect(decoded.ok).toBe(true);
|
||||
if (decoded.ok) expect(decoded.kind).toBeUndefined();
|
||||
});
|
||||
|
||||
it('the kind header survives the CSV persistence transform byte-exact', () => {
|
||||
const { reason } = encodeTaintPath([{ name: 'req', line: 2 }], { kind: 'path-traversal' });
|
||||
const loaded = unescapeCSVField(escapeCSVField(sanitizeUTF8(reason)));
|
||||
expect(loaded).toBe(reason);
|
||||
const decoded = decodeTaintPath(loaded);
|
||||
expect(decoded.ok).toBe(true);
|
||||
if (decoded.ok) expect(decoded.kind).toBe('path-traversal');
|
||||
});
|
||||
|
||||
it('fails typed on a malformed kind header', () => {
|
||||
for (const bad of ['1;|a:1', '1;BAD|a:1', '1;', '1;a;b|x:1']) {
|
||||
const decoded = decodeTaintPath(bad);
|
||||
expect(decoded.ok, bad).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('CSV persistence composition (escapeCSVField ∘ sanitizeUTF8)', () => {
|
||||
it('the encoding survives the exact persistence transform byte-exact', () => {
|
||||
const hops: TaintPathHopInput[] = [
|
||||
{ name: 'req', line: 12 },
|
||||
{ name: '$tmp_2', line: 13, viaCall: true },
|
||||
{ name: '#7', line: 99 },
|
||||
];
|
||||
const { reason } = encodeTaintPath(hops);
|
||||
// csv-generator applies sanitizeUTF8 INSIDE escapeCSVField; compose both
|
||||
// explicitly anyway so the test pins each layer.
|
||||
const stored = escapeCSVField(sanitizeUTF8(reason));
|
||||
const loaded = unescapeCSVField(stored);
|
||||
expect(loaded).toBe(reason); // byte-exact
|
||||
const decoded = decodeTaintPath(loaded);
|
||||
expect(decoded.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('a truncated encoding also survives the persistence transform', () => {
|
||||
const { reason, truncated } = encodeTaintPath([{ name: 'x', line: 1 }], { truncated: true });
|
||||
expect(truncated).toBe(true);
|
||||
const loaded = unescapeCSVField(escapeCSVField(sanitizeUTF8(reason)));
|
||||
expect(loaded).toBe(reason);
|
||||
});
|
||||
});
|
||||
|
||||
describe('truncation', () => {
|
||||
it('caller-flagged truncation (hop cap upstream) emits the marker; decode reports path-incomplete', () => {
|
||||
const { reason, truncated } = encodeTaintPath([{ name: 'a', line: 1 }], { truncated: true });
|
||||
expect(truncated).toBe(true);
|
||||
expect(reason).toBe(`1|a:1|${TAINT_PATH_TRUNCATION_MARKER}`);
|
||||
const decoded = decodeTaintPath(reason);
|
||||
expect(decoded.ok).toBe(true);
|
||||
if (decoded.ok) {
|
||||
expect(decoded.truncated).toBe(true); // informational — NOT an error
|
||||
expect(decoded.hops).toEqual([{ variable: 'a', line: 1, viaCall: false }]);
|
||||
}
|
||||
});
|
||||
|
||||
it('byte-cap overflow drops TRAILING hops (source-side prefix kept) and sets the marker', () => {
|
||||
const hops: TaintPathHopInput[] = [];
|
||||
for (let i = 0; i < 1000; i++) hops.push({ name: `variable_${i}`, line: i });
|
||||
const { reason, truncated } = encodeTaintPath(hops);
|
||||
expect(truncated).toBe(true);
|
||||
expect(reason.length).toBeLessThanOrEqual(TAINT_REASON_MAX_BYTES);
|
||||
const decoded = decodeTaintPath(reason);
|
||||
expect(decoded.ok).toBe(true);
|
||||
if (decoded.ok) {
|
||||
expect(decoded.truncated).toBe(true);
|
||||
expect(decoded.hops.length).toBeGreaterThan(0);
|
||||
expect(decoded.hops.length).toBeLessThan(hops.length);
|
||||
// Prefix discipline: hop k decodes hop k of the input, in order.
|
||||
decoded.hops.forEach((h, i) => {
|
||||
expect(h.variable).toBe(`variable_${i}`);
|
||||
expect(h.line).toBe(i);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('a tiny maxBytes still yields a well-formed (possibly hop-less) truncated path', () => {
|
||||
const { reason, truncated } = encodeTaintPath([{ name: 'longVariableName', line: 123 }], {
|
||||
maxBytes: 8,
|
||||
});
|
||||
expect(truncated).toBe(true);
|
||||
expect(reason).toBe(`1|${TAINT_PATH_TRUNCATION_MARKER}`);
|
||||
const decoded = decodeTaintPath(reason);
|
||||
expect(decoded.ok).toBe(true);
|
||||
if (decoded.ok) {
|
||||
expect(decoded.hops).toEqual([]);
|
||||
expect(decoded.truncated).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('an unencodable hop name stops encoding at that hop and marks truncation (defend, never corrupt)', () => {
|
||||
const cases = ['a|b', 'a:b', 'café', 'name~x', '', 'a b'];
|
||||
for (const bad of cases) {
|
||||
const { reason, truncated } = encodeTaintPath([
|
||||
{ name: 'ok1', line: 1 },
|
||||
{ name: bad, line: 2 },
|
||||
{ name: 'ok2', line: 3 }, // dropped too — prefix discipline
|
||||
]);
|
||||
expect(truncated).toBe(true);
|
||||
const decoded = decodeTaintPath(reason);
|
||||
expect(decoded.ok).toBe(true);
|
||||
if (decoded.ok) {
|
||||
expect(decoded.truncated).toBe(true);
|
||||
expect(decoded.hops).toEqual([{ variable: 'ok1', line: 1, viaCall: false }]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('a non-integer or negative line is unencodable the same way', () => {
|
||||
for (const line of [1.5, -1, NaN, Infinity]) {
|
||||
const { truncated, reason } = encodeTaintPath([{ name: 'x', line }]);
|
||||
expect(truncated).toBe(true);
|
||||
const decoded = decodeTaintPath(reason);
|
||||
expect(decoded.ok).toBe(true);
|
||||
if (decoded.ok) expect(decoded.hops).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('typed parse failures (never a throw)', () => {
|
||||
const failing: Array<[string, unknown]> = [
|
||||
['empty string', ''],
|
||||
['non-string', 42],
|
||||
['undefined', undefined],
|
||||
['unknown version', '2|a:1'],
|
||||
['missing separator after version', '1a:1'],
|
||||
['hop with no line', '1|a'],
|
||||
['hop with too many fields', '1|a:1:c:d'],
|
||||
['non-numeric line', '1|a:x'],
|
||||
['negative line', '1|a:-1'],
|
||||
['invalid variable charset', '1|a b:1'],
|
||||
['empty variable', '1|:1'],
|
||||
['uppercase flag (reserved charset is lowercase)', '1|a:1:C'],
|
||||
['marker not trailing', `1|${TAINT_PATH_TRUNCATION_MARKER}|a:1`],
|
||||
['empty hop segment', '1|a:1||b:2'],
|
||||
];
|
||||
for (const [label, input] of failing) {
|
||||
it(`fails typed on ${label}`, () => {
|
||||
const decoded = decodeTaintPath(input);
|
||||
expect(decoded.ok).toBe(false);
|
||||
if (!decoded.ok) expect(decoded.error.length).toBeGreaterThan(0);
|
||||
});
|
||||
}
|
||||
|
||||
it('accepts unknown RESERVED lowercase flag letters (forward compatibility)', () => {
|
||||
const decoded = decodeTaintPath('1|a:1:cz');
|
||||
expect(decoded.ok).toBe(true);
|
||||
if (decoded.ok) expect(decoded.hops[0]).toEqual({ variable: 'a', line: 1, viaCall: true });
|
||||
});
|
||||
});
|
||||
686
gitnexus/test/unit/taint/propagate.test.ts
Normal file
686
gitnexus/test/unit/taint/propagate.test.ts
Normal file
|
|
@ -0,0 +1,686 @@
|
|||
/**
|
||||
* U3 (#2083 M3) — pure taint propagation engine.
|
||||
*
|
||||
* Fixtures parse REAL source through the harvest + match harness (the
|
||||
* model-match.test.ts pattern): CFGs and SiteRecords come from the worker-side
|
||||
* TS CFG visitor, def→use facts from the real reaching-defs solver, and
|
||||
* matches from the real import-aware matcher — `computeTaintFlows` consumes
|
||||
* the exact structures U4 will feed it, never hand-built mocks.
|
||||
*
|
||||
* Sanitizer semantics under test (the KIND-SET exclusion model, which
|
||||
* subsumes the plan's binary kill — see propagate.ts module doc): a def
|
||||
* produced through a sanitizer is tainted-with-exclusions; a sink fires
|
||||
* unless its kind is in the taint's accumulated neutralized set. Mechanics
|
||||
* tests therefore use a custom spec whose `escape` neutralizes the test
|
||||
* sink's own kind (so "killed" scenarios read like the plan's binary
|
||||
* scenarios); the kind-set tests at the bottom exercise the real built-in
|
||||
* model where neutralization is deliberately kind-scoped.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { cfgOf, importsFor } from '../../helpers/ts-cfg-harness.js';
|
||||
import type { FunctionCfg } from '../../../src/core/ingestion/cfg/types.js';
|
||||
import {
|
||||
computeReachingDefs,
|
||||
type FunctionDefUse,
|
||||
type ReachingDefsLimits,
|
||||
} from '../../../src/core/ingestion/cfg/reaching-defs.js';
|
||||
import { hasTaintSafeSites } from '../../../src/core/ingestion/taint/site-safety.js';
|
||||
import type { SourceSinkSanitizerSpec } from '../../../src/core/ingestion/taint/source-sink-config.js';
|
||||
import { TS_JS_TAINT_MODEL } from '../../../src/core/ingestion/taint/typescript-model.js';
|
||||
import {
|
||||
buildTaintImportIndex,
|
||||
matchFunctionSites,
|
||||
} from '../../../src/core/ingestion/taint/match.js';
|
||||
import {
|
||||
computeTaintFlows,
|
||||
type FunctionTaintResult,
|
||||
type TaintLimits,
|
||||
} from '../../../src/core/ingestion/taint/propagate.js';
|
||||
|
||||
/** The single binding index for `name` (throws when shadowed/ambiguous). */
|
||||
function bindingIdx(cfg: FunctionCfg, name: string): number {
|
||||
const idxs = (cfg.bindings ?? []).map((b, i) => (b.name === name ? i : -1)).filter((i) => i >= 0);
|
||||
if (idxs.length !== 1) throw new Error(`expected 1 binding for ${name}, got ${idxs.length}`);
|
||||
return idxs[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mechanics spec: a global `exec` sink and a global `escape` sanitizer that
|
||||
* neutralizes the SAME kind — so interposition/kill mechanics behave like the
|
||||
* plan's binary-kill scenarios while still flowing through the kind-set model.
|
||||
*/
|
||||
const MECH: SourceSinkSanitizerSpec = {
|
||||
sources: [
|
||||
{
|
||||
kind: 'remote-input',
|
||||
objects: ['req'],
|
||||
properties: ['body', 'query', 'params', 'headers'],
|
||||
},
|
||||
],
|
||||
sinks: [{ name: 'exec', kind: 'command-injection', args: [0], global: true }],
|
||||
sanitizers: [{ name: 'escape', neutralizes: ['command-injection'], global: true }],
|
||||
};
|
||||
|
||||
interface AnalyzeOptions {
|
||||
fnIndex?: number;
|
||||
spec?: SourceSinkSanitizerSpec;
|
||||
limits?: TaintLimits;
|
||||
factLimits?: ReachingDefsLimits;
|
||||
}
|
||||
|
||||
function analyze(code: string, opts: AnalyzeOptions = {}): FunctionTaintResult {
|
||||
const cfg = cfgOf(code, opts.fnIndex ?? 0);
|
||||
expect(hasTaintSafeSites(cfg)).toBe(true);
|
||||
const defUse = computeReachingDefs(cfg, opts.factLimits);
|
||||
const matches = matchFunctionSites(
|
||||
cfg,
|
||||
opts.spec ?? MECH,
|
||||
buildTaintImportIndex(importsFor(code)),
|
||||
);
|
||||
return computeTaintFlows(cfg, defUse, matches, opts.limits);
|
||||
}
|
||||
|
||||
/** Hop summaries `name@line` (`name@line*` when viaCall) for readable asserts. */
|
||||
function hopSummary(r: FunctionTaintResult, findingIdx = 0): string[] {
|
||||
const f = r.findings[findingIdx];
|
||||
if (!f) throw new Error(`no finding at index ${findingIdx}`);
|
||||
return f.hops.map((h) => `${h.name}@${h.point.line}${h.viaCall ? '*' : ''}`);
|
||||
}
|
||||
|
||||
// ── statuses and the empty case ──────────────────────────────────────────────
|
||||
|
||||
describe('coverage-gap statuses (R4)', () => {
|
||||
it('a truncated FunctionDefUse yields a coverage-gap result with zero findings', () => {
|
||||
const r = analyze(
|
||||
`function f(req) {
|
||||
const b = req.body;
|
||||
const c = b;
|
||||
exec(c);
|
||||
}`,
|
||||
{ factLimits: { maxFacts: 1 } },
|
||||
);
|
||||
expect(r.status).toBe('coverage-gap');
|
||||
expect(r.gapReason).toBe('truncated');
|
||||
expect(r.findings).toHaveLength(0);
|
||||
expect(r.kills).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('a no-facts FunctionDefUse (no binding table) yields a coverage-gap result', () => {
|
||||
const cfg = cfgOf(`function f(req) { exec(req.body); }`);
|
||||
const { bindings: _bindings, ...noBindings } = cfg;
|
||||
const defUse = computeReachingDefs(noBindings as FunctionCfg);
|
||||
expect(defUse.status).toBe('no-facts');
|
||||
const matches = matchFunctionSites(cfg, MECH, buildTaintImportIndex([]));
|
||||
const r = computeTaintFlows(cfg, defUse, matches);
|
||||
expect(r.status).toBe('coverage-gap');
|
||||
expect(r.gapReason).toBe('no-facts');
|
||||
expect(r.findings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('an overflow FunctionDefUse yields a coverage-gap result (contract input shape)', () => {
|
||||
const cfg = cfgOf(`function f(req) { exec(req.body); }`);
|
||||
const overflow: FunctionDefUse = {
|
||||
status: 'overflow',
|
||||
bindings: cfg.bindings ?? [],
|
||||
facts: [],
|
||||
defCount: 0,
|
||||
useCount: 0,
|
||||
};
|
||||
const matches = matchFunctionSites(cfg, MECH, buildTaintImportIndex([]));
|
||||
const r = computeTaintFlows(cfg, overflow, matches);
|
||||
expect(r.status).toBe('coverage-gap');
|
||||
expect(r.gapReason).toBe('overflow');
|
||||
});
|
||||
|
||||
it('no sources and no sinks → computed, empty result', () => {
|
||||
const r = analyze(`function f(x) { const y = x; return y; }`);
|
||||
expect(r.status).toBe('computed');
|
||||
expect(r.findings).toHaveLength(0);
|
||||
expect(r.kills).toHaveLength(0);
|
||||
expect(r.droppedFindings).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── rule (b): statement-local source→sink ────────────────────────────────────
|
||||
|
||||
describe('rule (b) — statement-local findings', () => {
|
||||
it('exec(req.body) → one finding with a single hop', () => {
|
||||
const r = analyze(`function f(req) {
|
||||
exec(req.body);
|
||||
}`);
|
||||
expect(r.status).toBe('computed');
|
||||
expect(r.findings).toHaveLength(1);
|
||||
const f = r.findings[0];
|
||||
expect(f.sinkKind).toBe('command-injection');
|
||||
expect(f.source.property).toBe('body');
|
||||
expect(f.source.point.line).toBe(2);
|
||||
expect(f.sink.point.line).toBe(2);
|
||||
expect(f.sink.argIndex).toBe(0);
|
||||
expect(f.hops).toHaveLength(1);
|
||||
expect(f.hops[0].name).toBe('req');
|
||||
});
|
||||
|
||||
it('exec(req.body, req.query) → TWO findings distinguished by occurrence (KTD6 identity)', () => {
|
||||
const spec: SourceSinkSanitizerSpec = {
|
||||
...MECH,
|
||||
sinks: [{ name: 'exec', kind: 'command-injection', args: [0, 1], global: true }],
|
||||
};
|
||||
const r = analyze(`function f(req) { exec(req.body, req.query); }`, { spec });
|
||||
expect(r.findings).toHaveLength(2);
|
||||
const ids = r.findings.map((f) => `${f.source.property}@arg${f.sink.argIndex}`);
|
||||
expect(ids).toEqual(['body@arg0', 'query@arg1']);
|
||||
});
|
||||
|
||||
it('exec(req.body.toString()) → finding via the statement-local rule', () => {
|
||||
const r = analyze(`function f(req) { exec(req.body.toString()); }`);
|
||||
expect(r.findings).toHaveLength(1);
|
||||
expect(r.findings[0].source.property).toBe('body');
|
||||
});
|
||||
|
||||
it('a source read at an UNREGISTERED sink position produces no finding', () => {
|
||||
const r = analyze(`function f(req) { exec('ls', req.body); }`);
|
||||
expect(r.findings).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── rule (a): happy path and chains ──────────────────────────────────────────
|
||||
|
||||
describe('rule (a) — worklist over def→use facts', () => {
|
||||
it('same-block flow: const b = req.body; exec(b) → finding with hop chain', () => {
|
||||
const r = analyze(`function f(req) {
|
||||
const b = req.body;
|
||||
exec(b);
|
||||
}`);
|
||||
expect(r.findings).toHaveLength(1);
|
||||
expect(hopSummary(r)).toEqual(['b@2', 'b@3']);
|
||||
expect(r.findings[0].source.property).toBe('body');
|
||||
expect(r.findings[0].sink.point.line).toBe(3);
|
||||
});
|
||||
|
||||
it('reassignment chain carries variables per hop: b@L2 → c@L3 → sink@L4', () => {
|
||||
const r = analyze(`function f(req) {
|
||||
const b = req.body;
|
||||
const c = b;
|
||||
exec(c);
|
||||
}`);
|
||||
expect(r.findings).toHaveLength(1);
|
||||
expect(hopSummary(r)).toEqual(['b@2', 'c@3', 'c@4']);
|
||||
});
|
||||
|
||||
it('cross-block flow through a branch reaches the sink', () => {
|
||||
const r = analyze(`function f(req, cond) {
|
||||
const b = req.body;
|
||||
let c = '';
|
||||
if (cond) {
|
||||
c = b;
|
||||
}
|
||||
exec(c);
|
||||
}`);
|
||||
expect(r.findings).toHaveLength(1);
|
||||
expect(hopSummary(r)).toEqual(['b@2', 'c@5', 'c@7']);
|
||||
});
|
||||
});
|
||||
|
||||
// ── sanitizer interposition and kills (KTD4 both clauses) ───────────────────
|
||||
|
||||
describe('sanitizers — interposition, kill locality, kind sets (mechanics spec)', () => {
|
||||
it('seed interposition: const b = escape(req.body) → no finding, SANITIZES kill on b', () => {
|
||||
const r = analyze(`function f(req) {
|
||||
const b = escape(req.body);
|
||||
exec(b);
|
||||
}`);
|
||||
expect(r.findings).toHaveLength(0);
|
||||
expect(r.kills).toHaveLength(1);
|
||||
const cfg = cfgOf(`function f(req) {
|
||||
const b = escape(req.body);
|
||||
exec(b);
|
||||
}`);
|
||||
expect(r.kills[0].bindingIdx).toBe(bindingIdx(cfg, 'b'));
|
||||
expect(r.kills[0].sanitizer.line).toBe(2);
|
||||
expect([...r.kills[0].neutralized]).toEqual(['command-injection']);
|
||||
});
|
||||
|
||||
it('sink interposition: exec(escape(x)) with x tainted → no finding, kill recorded', () => {
|
||||
const r = analyze(`function f(req) {
|
||||
const x = req.body;
|
||||
exec(escape(x));
|
||||
}`);
|
||||
expect(r.findings).toHaveLength(0);
|
||||
expect(r.kills).toHaveLength(1);
|
||||
expect(r.kills[0].sanitizer.line).toBe(3);
|
||||
expect([...r.kills[0].neutralized]).toEqual(['command-injection']);
|
||||
});
|
||||
|
||||
it('bypass occurrence: const c = cond ? escape(x) : x → finding (direct path bypasses)', () => {
|
||||
// The ternary's escape call deliberately gets NO resultDefs (U1) — c is
|
||||
// floor-tainted with the EMPTY exclusion set (intersection over paths:
|
||||
// the direct `x` arm contributes ∅, so ∅ ∩ {command-injection} = ∅).
|
||||
const r = analyze(`function f(req, cond) {
|
||||
const x = req.body;
|
||||
const c = cond ? escape(x) : x;
|
||||
exec(c);
|
||||
}`);
|
||||
expect(r.findings).toHaveLength(1);
|
||||
expect(r.kills).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('intra-statement bypass at the sink: exec(x + escape(x)) → finding (plain occurrence wins)', () => {
|
||||
const r = analyze(`function f(req) {
|
||||
const x = req.body;
|
||||
exec(x + escape(x));
|
||||
}`);
|
||||
expect(r.findings).toHaveLength(1);
|
||||
// a BYPASSED sanitizer killed nothing — no SANITIZES record
|
||||
expect(r.kills).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('kill locality (KTD4b): const c = escape(b); exec(b) → finding on b AND a kill on c', () => {
|
||||
const code = `function f(req) {
|
||||
const b = req.body;
|
||||
const c = escape(b);
|
||||
exec(b);
|
||||
}`;
|
||||
const r = analyze(code);
|
||||
expect(r.findings).toHaveLength(1);
|
||||
expect(hopSummary(r)).toEqual(['b@2', 'b@4']);
|
||||
expect(r.kills).toHaveLength(1);
|
||||
expect(r.kills[0].bindingIdx).toBe(bindingIdx(cfgOf(code), 'c'));
|
||||
});
|
||||
|
||||
it('sanitizer self-assign: b = escape(b); exec(b) → no finding, one kill', () => {
|
||||
const r = analyze(`function f(req) {
|
||||
let b = req.body;
|
||||
b = escape(b);
|
||||
exec(b);
|
||||
}`);
|
||||
expect(r.findings).toHaveLength(0);
|
||||
expect(r.kills).toHaveLength(1);
|
||||
expect(r.kills[0].sanitizer.line).toBe(3);
|
||||
});
|
||||
|
||||
it('a sanitizer reached only through a SPREAD argument does not neutralize (position unprovable)', () => {
|
||||
// `escape(...arr)` — the runtime argument positions are unknowable, so
|
||||
// claiming the sanitized position received the taint would risk a false
|
||||
// kill. Sound direction: taint flows through un-neutralized.
|
||||
const r = analyze(`function f(req) {
|
||||
const arr = [req.body];
|
||||
const b = escape(...arr);
|
||||
exec(b);
|
||||
}`);
|
||||
expect(r.findings).toHaveLength(1);
|
||||
expect(r.kills).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('conditional sanitizer does NOT suppress: if (cond) { b = escape(b) } exec(b) → finding', () => {
|
||||
const r = analyze(`function f(req, cond) {
|
||||
let b = req.body;
|
||||
if (cond) {
|
||||
b = escape(b);
|
||||
}
|
||||
exec(b);
|
||||
}`);
|
||||
expect(r.findings).toHaveLength(1);
|
||||
// the seed def's flow survives the may-path; the sanitized def is killed
|
||||
expect(hopSummary(r)).toEqual(['b@2', 'b@6']);
|
||||
expect(r.kills).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── loop semantics: zero-iteration pair, fixpoint termination ───────────────
|
||||
|
||||
describe('loops — kill keyed on the def point, monotone termination (R3)', () => {
|
||||
it('zero-iteration while: the cond-false exit carries the seed def → finding SURVIVES', () => {
|
||||
const r = analyze(`function f(req, c) {
|
||||
let x = req.body;
|
||||
while (c) {
|
||||
x = escape(x);
|
||||
}
|
||||
exec(x);
|
||||
}`);
|
||||
expect(r.findings).toHaveLength(1);
|
||||
expect(hopSummary(r)).toEqual(['x@2', 'x@6']);
|
||||
expect(r.kills).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('do-while: the body always runs → no finding (and escape-in-loop does not re-taint)', () => {
|
||||
const r = analyze(`function f(req, c) {
|
||||
let x = req.body;
|
||||
do {
|
||||
x = escape(x);
|
||||
} while (c);
|
||||
exec(x);
|
||||
}`);
|
||||
expect(r.findings).toHaveLength(0);
|
||||
expect(r.kills).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('loop self-taint terminates: x = x + t in a for loop → finding, bounded', () => {
|
||||
const r = analyze(`function f(req) {
|
||||
const t = req.body;
|
||||
let x = '';
|
||||
for (let i = 0; i < 3; i++) {
|
||||
x = x + t;
|
||||
}
|
||||
exec(x);
|
||||
}`);
|
||||
expect(r.status).toBe('computed');
|
||||
expect(r.findings).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('assign-and-test: if ((m = re.exec(s)) && m) exec(m) → finding (self-fact handled)', () => {
|
||||
const r = analyze(`function f(req, re) {
|
||||
const s = req.body;
|
||||
let m;
|
||||
if ((m = re.exec(s)) && m) {
|
||||
exec(m);
|
||||
}
|
||||
}`);
|
||||
expect(r.findings).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── propagate-through unmodeled calls (KTD5) ─────────────────────────────────
|
||||
|
||||
describe('propagate-through — unmodeled calls and receivers, viaCall marks', () => {
|
||||
it('const y = helper(t); exec(y) → finding with a viaCall hop', () => {
|
||||
const r = analyze(`function f(req) {
|
||||
const t = req.body;
|
||||
const y = helper(t);
|
||||
exec(y);
|
||||
}`);
|
||||
expect(r.findings).toHaveLength(1);
|
||||
expect(hopSummary(r)).toEqual(['t@2', 'y@3*', 'y@4']);
|
||||
});
|
||||
|
||||
it('receiver propagation: const cmd = t.trim(); exec(cmd) → finding (TITO)', () => {
|
||||
const r = analyze(`function f(req) {
|
||||
const t = req.body;
|
||||
const cmd = t.trim();
|
||||
exec(cmd);
|
||||
}`);
|
||||
expect(r.findings).toHaveLength(1);
|
||||
expect(hopSummary(r)).toEqual(['t@2', 'cmd@3*', 'cmd@4']);
|
||||
});
|
||||
|
||||
it('tainted occurrence nested in an unmodeled call AT the sink fires: exec(helper(t))', () => {
|
||||
const r = analyze(`function f(req) {
|
||||
const t = req.body;
|
||||
exec(helper(t));
|
||||
}`);
|
||||
expect(r.findings).toHaveLength(1);
|
||||
// the sink hop records that the occurrence flowed through a call
|
||||
const sinkHop = r.findings[0].hops.at(-1);
|
||||
expect(sinkHop?.viaCall).toBe(true);
|
||||
});
|
||||
|
||||
it('sanitized through-call: const y = helper(escape(x)); exec(y) → NO finding', () => {
|
||||
// Deliberate precision choice over flat-conservative (plan KTD5): the only
|
||||
// occurrence path into the unmodeled call traverses the sanitizer, so the
|
||||
// neutralization rides through the call into y's exclusion set.
|
||||
const r = analyze(`function f(req) {
|
||||
const x = req.body;
|
||||
const y = helper(escape(x));
|
||||
exec(y);
|
||||
}`);
|
||||
expect(r.findings).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── statement-coalescing precision floor ─────────────────────────────────────
|
||||
|
||||
describe('precision floor — multi-declarator conflation (documented FP)', () => {
|
||||
it('const a = clean(z), b = g(t); exec(a) → finding EXISTS (expected false positive)', () => {
|
||||
// PINNED FP (plan risk table): statement facts conflate declarators — `t`
|
||||
// is used by the statement and `a` is def'd by it, so `a` is floor-tainted
|
||||
// even though `t` flows only into `g(...)`. The per-declarator resultDefs
|
||||
// precision powers KILLS only (U1 note); widening it to taint attribution
|
||||
// would be an unsound narrowing of the substrate's statement granularity.
|
||||
const r = analyze(`function f(req, z) {
|
||||
const t = req.body;
|
||||
const a = clean(z), b = g(t);
|
||||
exec(a);
|
||||
}`);
|
||||
expect(r.findings).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('the floor never KILLS: a def in a sanitizer resultDefs with no taint inflow is floor-tainted', () => {
|
||||
// `a = escape(z)` — the tainted `t` never flows into escape, so no kill is
|
||||
// recorded for it and `a` is floor-tainted with NO exclusions (sound: a
|
||||
// kill requires evidence of flow through the sanitizer).
|
||||
const r = analyze(`function f(req, z) {
|
||||
const t = req.body;
|
||||
const a = escape(z), b = g(t);
|
||||
exec(a);
|
||||
}`);
|
||||
expect(r.findings).toHaveLength(1);
|
||||
expect(r.kills).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── sequence-expression value semantics (review fix) ────────────────────────
|
||||
|
||||
describe('sequence expressions — only the final operand carries taint', () => {
|
||||
it('exec((log(x), "safe")) with tainted x → NO finding (safe operand flows)', () => {
|
||||
const r = analyze(`function f(req) { const x = req.body; exec((log(x), 'safe')); }`);
|
||||
expect(r.findings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('exec((log("a"), x)) with tainted x → finding (tainted final operand)', () => {
|
||||
const r = analyze(`function f(req) { const x = req.body; exec((log('a'), x)); }`);
|
||||
expect(r.findings).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── source-discriminated taint state (review fix: multi-source merge) ───────
|
||||
|
||||
describe('multi-source identity — distinct sources do not merge at one def', () => {
|
||||
// A spec with two source properties and a sql sanitizer, so the same-source
|
||||
// ∅-intersection case has a kind to neutralize.
|
||||
const MULTI: SourceSinkSanitizerSpec = {
|
||||
sources: [{ kind: 'remote-input', objects: ['req'], properties: ['body', 'query'] }],
|
||||
sinks: [
|
||||
{ name: 'exec', kind: 'command-injection', args: [0], global: true },
|
||||
{ name: 'query', kind: 'sql-injection', args: [0], anyReceiver: true },
|
||||
],
|
||||
sanitizers: [{ name: 'escape', neutralizes: ['sql-injection'], global: true }],
|
||||
};
|
||||
|
||||
it('cond ? req.body : req.query into one var → TWO findings (one per source)', () => {
|
||||
const r = analyze(`function f(req, cond) { const x = cond ? req.body : req.query; exec(x); }`, {
|
||||
spec: MULTI,
|
||||
});
|
||||
expect(r.findings).toHaveLength(2);
|
||||
const props = r.findings.map((f) => f.source.property).sort();
|
||||
expect(props).toEqual(['body', 'query']);
|
||||
});
|
||||
|
||||
it('req.body + req.query into one var → TWO findings', () => {
|
||||
const r = analyze(`function f(req) { const x = req.body + req.query; exec(x); }`, {
|
||||
spec: MULTI,
|
||||
});
|
||||
expect(r.findings).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('same-source two-path flow → ONE finding (one root source occurrence)', () => {
|
||||
// `req.body` is read ONCE (one source occurrence → one root identity); the
|
||||
// single tainted `x` then flows two ways into `c`. Both paths share the
|
||||
// same source discriminator, so they converge on one state for `c` and
|
||||
// produce exactly one finding — the source dimension must not split a
|
||||
// single source occurrence by downstream path.
|
||||
const r = analyze(
|
||||
`function f(req, cond) { const x = req.body; const c = cond ? id(x) : x; db.query(c); }`,
|
||||
{ spec: MULTI },
|
||||
);
|
||||
expect(r.findings).toHaveLength(1);
|
||||
expect(r.findings[0].sinkKind).toBe('sql-injection');
|
||||
});
|
||||
|
||||
it('one source re-derived into one sink var → ONE finding, no doubling', () => {
|
||||
// A single source reaching a single sink binding via a re-derived path
|
||||
// dedups to one finding (findingsByIdentity); the source dimension in the
|
||||
// state key must not split this same-source flow.
|
||||
const r = analyze(`function f(req, cond) { let x = req.body; if (cond) { x = x; } exec(x); }`, {
|
||||
spec: MULTI,
|
||||
});
|
||||
expect(r.findings).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('two sources through a loop back-edge terminate with TWO findings', () => {
|
||||
const r = analyze(
|
||||
`function f(req, n) { let x = req.body; for (let i = 0; i < n; i++) { x = x + req.query; } exec(x); }`,
|
||||
{ spec: MULTI },
|
||||
);
|
||||
expect(r.status).toBe('computed');
|
||||
expect(r.findings).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── kind-set exclusion model (real built-in model) ──────────────────────────
|
||||
|
||||
describe('kind-set exclusions — sanitizers neutralize their kinds only', () => {
|
||||
it('escape(req.body) → res.send(b) suppressed (xss neutralized) BUT db.query(b) fires (sql not)', () => {
|
||||
const r = analyze(
|
||||
`import { escape } from 'validator';
|
||||
function f(req, db, res) {
|
||||
const b = escape(req.body);
|
||||
db.query(b);
|
||||
res.send(b);
|
||||
}`,
|
||||
{ spec: TS_JS_TAINT_MODEL },
|
||||
);
|
||||
expect(r.findings).toHaveLength(1);
|
||||
expect(r.findings[0].sinkKind).toBe('sql-injection');
|
||||
expect(r.findings[0].sink.point.line).toBe(4);
|
||||
// the seed kill is still recorded with the kinds escape neutralizes
|
||||
expect(r.kills).toHaveLength(1);
|
||||
expect([...r.kills[0].neutralized]).toEqual(['xss']);
|
||||
});
|
||||
|
||||
it('kind-incompatible sink interposition: exec(path.basename(t)) → finding SURVIVES', () => {
|
||||
// basename strips directories, not shell metacharacters — a kind-blind
|
||||
// kill here would be a suppressed live command injection (the forbidden
|
||||
// false-negative direction).
|
||||
const r = analyze(
|
||||
`import { exec } from 'child_process';
|
||||
import path from 'path';
|
||||
function f(req) {
|
||||
const t = req.body;
|
||||
exec(path.basename(t));
|
||||
}`,
|
||||
{ spec: TS_JS_TAINT_MODEL },
|
||||
);
|
||||
expect(r.findings).toHaveLength(1);
|
||||
expect(r.findings[0].sinkKind).toBe('command-injection');
|
||||
});
|
||||
|
||||
it('kind-compatible def interposition: const safe = path.basename(t); readFileSync(safe) → suppressed', () => {
|
||||
const r = analyze(
|
||||
`import path from 'path';
|
||||
import { readFileSync } from 'fs';
|
||||
function f(req) {
|
||||
const t = req.body;
|
||||
const safe = path.basename(t);
|
||||
readFileSync(safe);
|
||||
}`,
|
||||
{ spec: TS_JS_TAINT_MODEL },
|
||||
);
|
||||
expect(r.findings).toHaveLength(0);
|
||||
expect(r.kills).toHaveLength(1);
|
||||
expect([...r.kills[0].neutralized]).toEqual(['path-traversal']);
|
||||
});
|
||||
|
||||
it('the same basename-cleaned def still fires a command-injection sink', () => {
|
||||
const r = analyze(
|
||||
`import { exec } from 'child_process';
|
||||
import path from 'path';
|
||||
function f(req) {
|
||||
const t = req.body;
|
||||
const safe = path.basename(t);
|
||||
exec(safe);
|
||||
}`,
|
||||
{ spec: TS_JS_TAINT_MODEL },
|
||||
);
|
||||
expect(r.findings).toHaveLength(1);
|
||||
expect(r.findings[0].sinkKind).toBe('command-injection');
|
||||
});
|
||||
|
||||
it('exclusions intersect over derivations: a less-neutralized re-derivation re-opens the sink', () => {
|
||||
// y is first derived through escape ({xss} excluded), then re-derived
|
||||
// through the direct assignment (∅) — the intersection is ∅, so the
|
||||
// xss sink must fire. Guards the monotone shrink/re-enqueue discipline.
|
||||
const r = analyze(
|
||||
`import { escape } from 'validator';
|
||||
function f(req, cond, res) {
|
||||
const b = req.body;
|
||||
let y = escape(b);
|
||||
if (cond) {
|
||||
y = b;
|
||||
}
|
||||
res.send(y);
|
||||
}`,
|
||||
{ spec: TS_JS_TAINT_MODEL },
|
||||
);
|
||||
// two defs of y reach the sink: the escaped one (suppressed for xss) and
|
||||
// the raw one (fires) — one finding from the raw def's flow
|
||||
expect(r.findings).toHaveLength(1);
|
||||
expect(r.findings[0].sinkKind).toBe('xss');
|
||||
});
|
||||
});
|
||||
|
||||
// ── caps and determinism ─────────────────────────────────────────────────────
|
||||
|
||||
describe('caps — deterministic truncation (R6 substrate)', () => {
|
||||
const manyFindings = `function f(req) {
|
||||
exec(req.body);
|
||||
exec(req.query);
|
||||
exec(req.params);
|
||||
exec(req.headers);
|
||||
}`;
|
||||
|
||||
it('maxFindingsPerFunction truncates deterministically and counts the drop', () => {
|
||||
const r = analyze(manyFindings, { limits: { maxFindingsPerFunction: 2 } });
|
||||
expect(r.findings).toHaveLength(2);
|
||||
expect(r.droppedFindings).toBe(2);
|
||||
// deterministic prefix of the sorted order: statement order
|
||||
expect(r.findings.map((f) => f.source.property)).toEqual(['body', 'query']);
|
||||
});
|
||||
|
||||
it('without a cap all findings emit and droppedFindings is 0', () => {
|
||||
const r = analyze(manyFindings);
|
||||
expect(r.findings).toHaveLength(4);
|
||||
expect(r.droppedFindings).toBe(0);
|
||||
});
|
||||
|
||||
it('maxHops truncates the hop chain and flags it', () => {
|
||||
const r = analyze(
|
||||
`function f(req) {
|
||||
const a = req.body;
|
||||
const b = a;
|
||||
const c = b;
|
||||
exec(c);
|
||||
}`,
|
||||
{ limits: { maxHops: 2 } },
|
||||
);
|
||||
expect(r.findings).toHaveLength(1);
|
||||
expect(r.findings[0].hops).toHaveLength(2);
|
||||
expect(r.findings[0].hopsTruncated).toBe(true);
|
||||
expect(hopSummary(r)).toEqual(['a@2', 'b@3']);
|
||||
});
|
||||
|
||||
it('results are deterministic across repeated runs', () => {
|
||||
const code = `function f(req, cond) {
|
||||
let x = req.body;
|
||||
const y = helper(x);
|
||||
if (cond) {
|
||||
x = escape(x);
|
||||
}
|
||||
exec(x);
|
||||
exec(y);
|
||||
exec(req.query);
|
||||
}`;
|
||||
const a = analyze(code);
|
||||
const b = analyze(code);
|
||||
expect(JSON.parse(JSON.stringify(a))).toEqual(JSON.parse(JSON.stringify(b)));
|
||||
});
|
||||
});
|
||||
172
gitnexus/test/unit/taint/site-safety.test.ts
Normal file
172
gitnexus/test/unit/taint/site-safety.test.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import Parser from 'tree-sitter';
|
||||
import TypeScript from 'tree-sitter-typescript';
|
||||
import {
|
||||
createTypeScriptCfgVisitor,
|
||||
TS_FUNCTION_TYPES,
|
||||
} from '../../../src/core/ingestion/cfg/visitors/typescript.js';
|
||||
import { hasTaintSafeSites } from '../../../src/core/ingestion/taint/site-safety.js';
|
||||
import { isEmitSafeCfg, hasEmitSafeFacts } from '../../../src/core/ingestion/cfg/emit.js';
|
||||
import type {
|
||||
FunctionCfg,
|
||||
SiteRecord,
|
||||
StatementFacts,
|
||||
} from '../../../src/core/ingestion/cfg/types.js';
|
||||
import type { SyntaxNode } from '../../../src/core/ingestion/utils/ast-helpers.js';
|
||||
|
||||
// #2083 M3 U1 — `hasTaintSafeSites` mirrors `hasEmitSafeFacts`'s contract:
|
||||
// out-of-range indices from a corrupted durable store must degrade to
|
||||
// "skip taint for this function", never crash or fabricate matches. These
|
||||
// tests build a REAL harvested CFG, then surgically corrupt the `sites`
|
||||
// payload field-by-field — and pin that corrupt sites do NOT trip the
|
||||
// CFG/REACHING_DEF guards (the degradation is taint-local).
|
||||
|
||||
const visitor = createTypeScriptCfgVisitor();
|
||||
|
||||
function cfgOf(code: string): FunctionCfg {
|
||||
const parser = new Parser();
|
||||
parser.setLanguage(TypeScript.typescript);
|
||||
const root = parser.parse(code).rootNode as SyntaxNode;
|
||||
const stack = [root];
|
||||
while (stack.length) {
|
||||
const n = stack.pop() as SyntaxNode;
|
||||
if (TS_FUNCTION_TYPES.has(n.type)) {
|
||||
const cfg = visitor.buildFunctionCfg(n, 'fixture.ts');
|
||||
if (cfg) return cfg;
|
||||
}
|
||||
for (let i = n.namedChildCount - 1; i >= 0; i--) {
|
||||
const c = n.namedChild(i);
|
||||
if (c) stack.push(c);
|
||||
}
|
||||
}
|
||||
throw new Error('no function found');
|
||||
}
|
||||
|
||||
const BASE = cfgOf(`function f(req, x) { const b = req.body; exec(escape(x), b); }`);
|
||||
|
||||
/** Deep-copy and rewrite the FIRST site of the FIRST site-bearing statement. */
|
||||
function mutateFirstSite(patch: (site: Record<string, unknown>) => void): FunctionCfg {
|
||||
const copy = JSON.parse(JSON.stringify(BASE)) as FunctionCfg;
|
||||
for (const block of copy.blocks) {
|
||||
for (const s of block.statements ?? []) {
|
||||
if (s.sites && s.sites.length > 0) {
|
||||
patch(s.sites[0] as unknown as Record<string, unknown>);
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error('no site-bearing statement in fixture');
|
||||
}
|
||||
|
||||
describe('hasTaintSafeSites — valid shapes pass', () => {
|
||||
it('a real harvested CFG passes', () => {
|
||||
expect(hasTaintSafeSites(BASE)).toBe(true);
|
||||
});
|
||||
|
||||
it('a CFG with facts but no sites passes (absence is the well-formed empty case)', () => {
|
||||
const cfg = cfgOf(`function f() { let a = 1; a = 2; }`);
|
||||
expect(hasTaintSafeSites(cfg)).toBe(true);
|
||||
});
|
||||
|
||||
it('a pre-M2 CFG with no statements at all passes', () => {
|
||||
const copy = JSON.parse(JSON.stringify(BASE)) as FunctionCfg;
|
||||
const stripped = {
|
||||
...copy,
|
||||
bindings: undefined,
|
||||
blocks: copy.blocks.map((b) => ({ ...b, statements: undefined })),
|
||||
} as FunctionCfg;
|
||||
expect(hasTaintSafeSites(stripped)).toBe(true);
|
||||
});
|
||||
|
||||
it('the full M3 surface validates on a JSON round-trip (durable-store shape)', () => {
|
||||
const cfg = cfgOf(
|
||||
'function f(req, dir, t) { const cp = require("child_process"); ' +
|
||||
'cp.exec(`ls ${dir}`); sql`q ${t}`; new Function(t); exec(...dir); }',
|
||||
);
|
||||
expect(hasTaintSafeSites(JSON.parse(JSON.stringify(cfg)) as FunctionCfg)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasTaintSafeSites — malformed indices reject', () => {
|
||||
it('out-of-range receiver', () => {
|
||||
expect(hasTaintSafeSites(mutateFirstSite((s) => (s.receiver = 999)))).toBe(false);
|
||||
expect(hasTaintSafeSites(mutateFirstSite((s) => (s.receiver = -1)))).toBe(false);
|
||||
expect(hasTaintSafeSites(mutateFirstSite((s) => (s.receiver = 1.5)))).toBe(false);
|
||||
});
|
||||
|
||||
it('out-of-range member-read object / missing property', () => {
|
||||
const cfg = cfgOf(`function f(req) { const b = req.body; }`);
|
||||
const corrupt = JSON.parse(JSON.stringify(cfg)) as FunctionCfg;
|
||||
const site = corrupt.blocks.flatMap((b) => [...(b.statements ?? [])]).find((s) => s.sites)!
|
||||
.sites![0] as unknown as Record<string, unknown>;
|
||||
site.object = 999;
|
||||
expect(hasTaintSafeSites(corrupt)).toBe(false);
|
||||
site.object = 0;
|
||||
delete site.property;
|
||||
expect(hasTaintSafeSites(corrupt)).toBe(false);
|
||||
});
|
||||
|
||||
it('out-of-range arg binding entry and via-site tag', () => {
|
||||
expect(hasTaintSafeSites(mutateFirstSite((s) => (s.args = [[999]])))).toBe(false);
|
||||
// via-tag site index must be in range of the SAME statement's sites array
|
||||
expect(hasTaintSafeSites(mutateFirstSite((s) => (s.args = [[[0, 999]]])))).toBe(false);
|
||||
expect(hasTaintSafeSites(mutateFirstSite((s) => (s.args = [[[0, -1]]])))).toBe(false);
|
||||
// tuple arity is exact
|
||||
expect(hasTaintSafeSites(mutateFirstSite((s) => (s.args = [[[0, 1, 2]]])))).toBe(false);
|
||||
expect(hasTaintSafeSites(mutateFirstSite((s) => (s.args = [['x']])))).toBe(false);
|
||||
expect(hasTaintSafeSites(mutateFirstSite((s) => (s.args = [0])))).toBe(false);
|
||||
});
|
||||
|
||||
it('out-of-range resultDefs / parent / spread / kind / callee', () => {
|
||||
expect(hasTaintSafeSites(mutateFirstSite((s) => (s.resultDefs = [999])))).toBe(false);
|
||||
expect(hasTaintSafeSites(mutateFirstSite((s) => (s.parent = [999, 0])))).toBe(false);
|
||||
expect(hasTaintSafeSites(mutateFirstSite((s) => (s.parent = [0, -1])))).toBe(false);
|
||||
expect(hasTaintSafeSites(mutateFirstSite((s) => (s.parent = [0])))).toBe(false);
|
||||
expect(hasTaintSafeSites(mutateFirstSite((s) => (s.spread = -1)))).toBe(false);
|
||||
expect(hasTaintSafeSites(mutateFirstSite((s) => (s.kind = 'evil')))).toBe(false);
|
||||
expect(hasTaintSafeSites(mutateFirstSite((s) => (s.callee = 42)))).toBe(false);
|
||||
expect(hasTaintSafeSites(mutateFirstSite((s) => (s.requireArg = 42)))).toBe(false);
|
||||
});
|
||||
|
||||
it('sites without a binding table reject (nothing to range-check against)', () => {
|
||||
const copy = JSON.parse(JSON.stringify(BASE)) as { bindings?: unknown };
|
||||
delete copy.bindings;
|
||||
expect(hasTaintSafeSites(copy as FunctionCfg)).toBe(false);
|
||||
});
|
||||
|
||||
it('non-array sites and null site entries reject', () => {
|
||||
const corrupt = JSON.parse(JSON.stringify(BASE)) as FunctionCfg;
|
||||
const stmt = corrupt.blocks.flatMap((b) => [...(b.statements ?? [])]).find((s) => s.sites) as {
|
||||
sites: unknown;
|
||||
};
|
||||
stmt.sites = { not: 'an array' };
|
||||
expect(hasTaintSafeSites(corrupt)).toBe(false);
|
||||
stmt.sites = [null];
|
||||
expect(hasTaintSafeSites(corrupt)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasTaintSafeSites — degradation is taint-local (KTD2)', () => {
|
||||
it('corrupt sites do NOT trip the CFG or REACHING_DEF guards', () => {
|
||||
const corrupt = mutateFirstSite((s) => (s.receiver = 999));
|
||||
expect(hasTaintSafeSites(corrupt)).toBe(false);
|
||||
// The CFG layer and the facts layer keep their own guards green — the
|
||||
// function degrades to "no taint", never to "no CFG"/"no REACHING_DEF".
|
||||
expect(isEmitSafeCfg(corrupt)).toBe(true);
|
||||
expect(hasEmitSafeFacts(corrupt)).toBe(true);
|
||||
});
|
||||
|
||||
it('and the inverse: corrupt FACTS are not a sites problem (separate guards)', () => {
|
||||
const corrupt = JSON.parse(JSON.stringify(BASE)) as FunctionCfg;
|
||||
const stmt = corrupt.blocks.flatMap((b) => [...(b.statements ?? [])])[1] as StatementFacts & {
|
||||
defs: number[];
|
||||
};
|
||||
stmt.defs.push(999);
|
||||
expect(hasEmitSafeFacts(corrupt)).toBe(false);
|
||||
const sites: readonly SiteRecord[] | undefined = corrupt.blocks
|
||||
.flatMap((b) => [...(b.statements ?? [])])
|
||||
.find((s) => s.sites)?.sites;
|
||||
expect(sites).toBeDefined();
|
||||
expect(hasTaintSafeSites(corrupt)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
338
gitnexus/test/unit/taint/taint-emit.test.ts
Normal file
338
gitnexus/test/unit/taint/taint-emit.test.ts
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
/**
|
||||
* U4 (#2083 M3) — `emitFileTaint` over REAL harvested CFGs.
|
||||
*
|
||||
* Fixtures parse real source through the worker-side TS CFG visitor (the
|
||||
* propagate.test.ts harness): CFGs and sites come from the harvest, imports
|
||||
* from the real TS capture+interpret path — the emit driver consumes exactly
|
||||
* the structures the run.ts pdg window feeds it, never hand-built mocks
|
||||
* (except the deliberate corrupted-store mutation below).
|
||||
*
|
||||
* Pinned here: KTD6 statement-level finding identity (occurrence-distinct
|
||||
* rows for `exec(req.body, req.query)`; variable-distinct rows on a shared
|
||||
* block pair), dedup-before-budget with truncate-and-warn, the zero-match
|
||||
* fast path (no solver call — asserted via the result counters), the
|
||||
* unsafe-sites skip-taint-keep-RD degradation, kills-without-findings, and
|
||||
* the decodability of every persisted `reason` via the shared path codec.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { cfgsOf, importsFor } from '../../helpers/ts-cfg-harness.js';
|
||||
import { emitFileCfgs } from '../../../src/core/ingestion/cfg/emit.js';
|
||||
import type { SourceSinkSanitizerSpec } from '../../../src/core/ingestion/taint/source-sink-config.js';
|
||||
import {
|
||||
emitFileTaint,
|
||||
type TaintEmitLimits,
|
||||
type TaintEmitResult,
|
||||
} from '../../../src/core/ingestion/taint/emit.js';
|
||||
import { decodeTaintPath } from '../../../src/core/ingestion/taint/path-codec.js';
|
||||
import { createKnowledgeGraph } from '../../../src/core/graph/graph.js';
|
||||
import type { KnowledgeGraph } from '../../../src/core/graph/types.js';
|
||||
import type { GraphRelationship } from 'gitnexus-shared';
|
||||
|
||||
/** Mechanics spec (propagate.test.ts MECH): global exec sink, global escape sanitizer. */
|
||||
const MECH: SourceSinkSanitizerSpec = {
|
||||
sources: [
|
||||
{ kind: 'remote-input', objects: ['req'], properties: ['body', 'query', 'params', 'headers'] },
|
||||
],
|
||||
sinks: [{ name: 'exec', kind: 'command-injection', args: [0], global: true }],
|
||||
sanitizers: [{ name: 'escape', neutralizes: ['command-injection'], global: true }],
|
||||
};
|
||||
|
||||
/** MECH with the sink dangerous at EVERY position (`args` omitted). */
|
||||
const MECH_ALL_ARGS: SourceSinkSanitizerSpec = {
|
||||
...MECH,
|
||||
sinks: [{ name: 'exec', kind: 'command-injection', global: true }],
|
||||
};
|
||||
|
||||
interface RunResult {
|
||||
graph: KnowledgeGraph;
|
||||
result: TaintEmitResult;
|
||||
tainted: GraphRelationship[];
|
||||
sanitizes: GraphRelationship[];
|
||||
warns: string[];
|
||||
}
|
||||
|
||||
function run(
|
||||
code: string,
|
||||
opts: { spec?: SourceSinkSanitizerSpec; limits?: TaintEmitLimits; cfgs?: FunctionCfg[] } = {},
|
||||
): RunResult {
|
||||
const graph = createKnowledgeGraph();
|
||||
const cfgs = opts.cfgs ?? cfgsOf(code);
|
||||
// Emit the M1 layer first so taint endpoints can be checked against REAL
|
||||
// persisted BasicBlock nodes (run.ts ordering).
|
||||
emitFileCfgs(graph, cfgs);
|
||||
const warns: string[] = [];
|
||||
const result = emitFileTaint(graph, cfgs, importsFor(code), opts.spec ?? MECH, opts.limits, (m) =>
|
||||
warns.push(m),
|
||||
);
|
||||
const tainted: GraphRelationship[] = [];
|
||||
const sanitizes: GraphRelationship[] = [];
|
||||
for (const rel of graph.iterRelationships()) {
|
||||
if (rel.type === 'TAINTED') tainted.push(rel);
|
||||
if (rel.type === 'SANITIZES') sanitizes.push(rel);
|
||||
}
|
||||
return { graph, result, tainted, sanitizes, warns };
|
||||
}
|
||||
|
||||
function blockIds(graph: KnowledgeGraph): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
graph.forEachNode((n) => {
|
||||
if (n.label === 'BasicBlock') ids.add(n.id);
|
||||
});
|
||||
return ids;
|
||||
}
|
||||
|
||||
describe('emitFileTaint — happy path', () => {
|
||||
const CODE = `
|
||||
function handler(req: { body: string }) {
|
||||
const cmd = req.body;
|
||||
exec(cmd);
|
||||
}`;
|
||||
|
||||
it('persists one TAINTED edge whose endpoints are real BasicBlock nodes', () => {
|
||||
const { graph, result, tainted } = run(CODE);
|
||||
expect(result.functionsAnalyzed).toBe(1);
|
||||
expect(result.findingsEmitted).toBe(1);
|
||||
expect(tainted).toHaveLength(1);
|
||||
const ids = blockIds(graph);
|
||||
expect(ids.has(tainted[0].sourceId)).toBe(true);
|
||||
expect(ids.has(tainted[0].targetId)).toBe(true);
|
||||
expect(tainted[0].id.startsWith('TAINTED:fixture.ts:')).toBe(true);
|
||||
});
|
||||
|
||||
it('the persisted reason decodes via the shared codec with ordered hops + variables', () => {
|
||||
const { tainted } = run(CODE);
|
||||
const decoded = decodeTaintPath(tainted[0].reason);
|
||||
expect(decoded.ok).toBe(true);
|
||||
if (decoded.ok) {
|
||||
expect(decoded.truncated).toBe(false);
|
||||
// The finding's sinkKind rides the `;<kind>` header (the only persisted
|
||||
// channel — the edge id embedding it is not a stored column; U6 reads it).
|
||||
expect(decoded.kind).toBe('command-injection');
|
||||
// seed def (cmd @ const line) → sink use (cmd @ exec line)
|
||||
expect(decoded.hops.map((h) => `${h.variable}@${h.line}`)).toEqual(['cmd@3', 'cmd@4']);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('KTD6 statement-level finding identity', () => {
|
||||
it('exec(req.body, req.query) → TWO rows distinguished by occurrence', () => {
|
||||
const { result, tainted } = run(
|
||||
`
|
||||
function handler(req: { body: string; query: string }) {
|
||||
exec(req.body, req.query);
|
||||
}`,
|
||||
{ spec: MECH_ALL_ARGS },
|
||||
);
|
||||
expect(result.findingsEmitted).toBe(2);
|
||||
expect(tainted).toHaveLength(2);
|
||||
// Same block pair — the identity (and the id) differ ONLY by occurrence.
|
||||
expect(tainted[0].sourceId).toBe(tainted[1].sourceId);
|
||||
expect(tainted[0].targetId).toBe(tainted[1].targetId);
|
||||
expect(tainted[0].id).not.toBe(tainted[1].id);
|
||||
});
|
||||
|
||||
it('two findings on the same block pair with different variables → two rows', () => {
|
||||
const { result, tainted } = run(`
|
||||
function handler(req: { body: string; query: string }) {
|
||||
const a = req.body;
|
||||
const b = req.query;
|
||||
exec(a);
|
||||
exec(b);
|
||||
}`);
|
||||
expect(result.findingsEmitted).toBe(2);
|
||||
expect(tainted).toHaveLength(2);
|
||||
expect(new Set(tainted.map((t) => t.id)).size).toBe(2);
|
||||
const variables = tainted.map((t) => {
|
||||
const d = decodeTaintPath(t.reason);
|
||||
if (!d.ok) throw new Error(d.error);
|
||||
return d.hops[d.hops.length - 1].variable;
|
||||
});
|
||||
expect(variables.sort()).toEqual(['a', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dedup-before-budget and the findings cap', () => {
|
||||
const FOUR_FINDINGS = `
|
||||
function handler(req: { body: string; query: string }) {
|
||||
const a = req.body;
|
||||
const b = req.query;
|
||||
exec(a);
|
||||
exec(b);
|
||||
exec(a);
|
||||
exec(b);
|
||||
}`;
|
||||
|
||||
it('uncapped: identical (deduped) flows collapse; distinct ones all emit', () => {
|
||||
const { result, tainted } = run(FOUR_FINDINGS);
|
||||
// 4 sink statements × 1 variable each — all distinct sink points → 4 rows.
|
||||
expect(result.findingsEmitted).toBe(4);
|
||||
expect(result.findingsDropped).toBe(0);
|
||||
expect(new Set(tainted.map((t) => t.id)).size).toBe(4);
|
||||
});
|
||||
|
||||
it('capped: truncates deterministically and warns naming the drop count', () => {
|
||||
const { result, tainted, warns } = run(FOUR_FINDINGS, {
|
||||
limits: { maxFindingsPerFunction: 1 },
|
||||
});
|
||||
expect(result.findingsEmitted).toBe(1);
|
||||
expect(result.findingsDropped).toBe(3);
|
||||
expect(tainted).toHaveLength(1);
|
||||
const capWarn = warns.find((w) => w.includes('findings cap'));
|
||||
expect(capWarn).toBeDefined();
|
||||
expect(capWarn).toContain('dropped 3 of 4');
|
||||
expect(result.droppedExamples).toEqual(['fixture.ts:2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('zero-match fast path', () => {
|
||||
it('no source AND no sink: function skipped without a solver call', () => {
|
||||
const { result, tainted, sanitizes } = run(`
|
||||
function pure(x: number) {
|
||||
const y = x + 1;
|
||||
return y;
|
||||
}`);
|
||||
expect(result.functionsSkippedNoMatch).toBe(1);
|
||||
expect(result.functionsAnalyzed).toBe(0);
|
||||
expect(tainted).toHaveLength(0);
|
||||
expect(sanitizes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('sink without source (and vice versa) also short-circuits', () => {
|
||||
const { result } = run(`
|
||||
function sinkOnly(cmd: string) {
|
||||
exec(cmd);
|
||||
}
|
||||
function sourceOnly(req: { body: string }) {
|
||||
return req.body;
|
||||
}`);
|
||||
expect(result.functionsSkippedNoMatch).toBe(2);
|
||||
expect(result.functionsAnalyzed).toBe(0);
|
||||
expect(result.findingsEmitted).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unsafe-sites degradation (skip-taint-keep-RD)', () => {
|
||||
const TWO_FNS = `
|
||||
function corrupted(req: { body: string }) {
|
||||
exec(req.body);
|
||||
}
|
||||
function stillVulnerable(req: { body: string }) {
|
||||
exec(req.body);
|
||||
}`;
|
||||
|
||||
it('a corrupted-store site skips ONLY that function; siblings still emit', () => {
|
||||
const cfgs = JSON.parse(JSON.stringify(cfgsOf(TWO_FNS))) as FunctionCfg[];
|
||||
// Corrupt the first function's first site: out-of-range binding index.
|
||||
let mutated = false;
|
||||
outer: for (const block of cfgs[0].blocks) {
|
||||
for (const stmt of block.statements ?? []) {
|
||||
if (stmt.sites !== undefined && stmt.sites.length > 0) {
|
||||
(stmt.sites[0] as { object?: number }).object = 9999;
|
||||
mutated = true;
|
||||
break outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(mutated).toBe(true);
|
||||
|
||||
const { result, tainted, warns } = run(TWO_FNS, { cfgs });
|
||||
expect(result.functionsSkippedUnsafeSites).toBe(1);
|
||||
expect(result.functionsAnalyzed).toBe(1);
|
||||
expect(result.findingsEmitted).toBe(1); // the sibling's finding survives
|
||||
expect(tainted).toHaveLength(1);
|
||||
expect(tainted[0].id).toContain('fixture.ts:5'); // the SECOND function
|
||||
expect(warns.some((w) => w.includes('malformed site annotations'))).toBe(true);
|
||||
expect(result.coverageGapExamples).toEqual(['fixture.ts:2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('kills without findings', () => {
|
||||
it('a fully-sanitized flow emits SANITIZES and zero TAINTED', () => {
|
||||
const { graph, result, tainted, sanitizes } = run(`
|
||||
function safe(req: { body: string }) {
|
||||
const b = escape(req.body);
|
||||
exec(b);
|
||||
}`);
|
||||
expect(result.functionsAnalyzed).toBe(1);
|
||||
expect(result.findingsEmitted).toBe(0);
|
||||
expect(tainted).toHaveLength(0);
|
||||
expect(result.killsEmitted).toBe(1);
|
||||
expect(sanitizes).toHaveLength(1);
|
||||
// reason = the killed binding's plain name; endpoints are real blocks.
|
||||
expect(sanitizes[0].reason).toBe('b');
|
||||
expect(sanitizes[0].id.startsWith('SANITIZES:fixture.ts:')).toBe(true);
|
||||
const ids = blockIds(graph);
|
||||
expect(ids.has(sanitizes[0].sourceId)).toBe(true);
|
||||
expect(ids.has(sanitizes[0].targetId)).toBe(true);
|
||||
});
|
||||
|
||||
it('a kill alongside a finding emits both edge kinds', () => {
|
||||
const { result } = run(`
|
||||
function mixed(req: { body: string; query: string }) {
|
||||
const safe = escape(req.body);
|
||||
exec(safe);
|
||||
exec(req.query);
|
||||
}`);
|
||||
expect(result.killsEmitted).toBe(1);
|
||||
expect(result.findingsEmitted).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hop truncation accounting', () => {
|
||||
it('maxHops=1 truncates the persisted path and counts the finding', () => {
|
||||
const { result, tainted } = run(
|
||||
`
|
||||
function handler(req: { body: string }) {
|
||||
const a = req.body;
|
||||
const b = a;
|
||||
exec(b);
|
||||
}`,
|
||||
{ limits: { maxHops: 1 } },
|
||||
);
|
||||
expect(result.findingsEmitted).toBe(1);
|
||||
expect(result.hopsTruncatedFindings).toBe(1);
|
||||
const decoded = decodeTaintPath(tainted[0].reason);
|
||||
expect(decoded.ok).toBe(true);
|
||||
if (decoded.ok) {
|
||||
expect(decoded.truncated).toBe(true); // path-incomplete, not an error
|
||||
expect(decoded.hops).toHaveLength(1); // source-side prefix
|
||||
expect(decoded.hops[0].variable).toBe('a');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('telemetry completeness', () => {
|
||||
it('every counter field is present (nothing dropped on the floor — the M2 lesson)', () => {
|
||||
const { result } = run(`function noop() { return 1; }`);
|
||||
expect(result).toEqual({
|
||||
functionsAnalyzed: 0,
|
||||
functionsSkippedNoMatch: 1,
|
||||
functionsSkippedUnsafeSites: 0,
|
||||
functionsCoverageGap: { truncated: 0, overflow: 0, 'no-facts': 0 },
|
||||
findingsEmitted: 0,
|
||||
killsEmitted: 0,
|
||||
findingsDropped: 0,
|
||||
hopsTruncatedFindings: 0,
|
||||
coverageGapExamples: [],
|
||||
droppedExamples: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('a solver coverage gap (fact limit) is counted by reason, with an example anchor', () => {
|
||||
const { result, tainted } = run(
|
||||
`
|
||||
function gap(req: { body: string }) {
|
||||
const a = req.body;
|
||||
const b = a;
|
||||
const c = b;
|
||||
exec(c);
|
||||
}`,
|
||||
{ limits: { maxFacts: 1 } },
|
||||
);
|
||||
expect(result.functionsCoverageGap.truncated).toBe(1);
|
||||
expect(result.functionsAnalyzed).toBe(0);
|
||||
expect(tainted).toHaveLength(0); // R4: never partially analyzed
|
||||
expect(result.coverageGapExamples).toEqual(['fixture.ts:2']);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue