From 7b4c7b33bda52037f8000fcf74f2755252c4b136 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Fri, 23 Oct 2026 22:07:16 +0000 Subject: [PATCH] fix(taint): refine values after accepted guards Preserve bounded value restrictions through local flows and function summaries. Check the complete command against the modeled sink use before suppressing a finding. Unknown paths and exhausted budgets retain conservative findings. Refs #3490 --- gitnexus/bench/cfg/baselines.json | 50 ++- gitnexus/bench/cfg/measure.mjs | 165 ++++++- gitnexus/bench/tool-accuracy/README.md | 7 +- gitnexus/bench/tool-accuracy/known-gaps.json | 9 +- gitnexus/src/core/ingestion/cfg/collect.ts | 1 + gitnexus/src/core/ingestion/cfg/types.ts | 10 + .../cfg/visitors/typescript-harvest.ts | 169 ++++++- .../cfg/visitors/typescript-refinement.ts | 298 +++++++++++++ .../core/ingestion/cfg/visitors/typescript.ts | 27 +- .../scope-resolution/pipeline/run.ts | 12 + gitnexus/src/core/ingestion/taint/emit.ts | 11 +- .../core/ingestion/taint/guard-refinement.ts | 413 ++++++++++++++++++ .../core/ingestion/taint/interproc-solver.ts | 77 +++- .../src/core/ingestion/taint/propagate.ts | 42 ++ .../ingestion/taint/source-sink-config.ts | 2 + .../ingestion/taint/summary-harvest-driver.ts | 5 +- .../core/ingestion/taint/summary-harvest.ts | 85 +++- .../src/core/ingestion/taint/summary-model.ts | 18 +- .../core/ingestion/taint/typescript-model.ts | 16 +- .../core/ingestion/taint/value-refinement.ts | 245 +++++++++++ gitnexus/src/mcp/local/local-backend.ts | 6 +- gitnexus/src/mcp/taint-limitations.ts | 2 + gitnexus/src/mcp/tools.ts | 4 +- gitnexus/test/helpers/cfg-harness.ts | 15 +- .../test/integration/cfg/guard-taint.test.ts | 125 ++++++ .../test/integration/taint-explain.test.ts | 2 + .../unit/cfg/typescript-refinement.test.ts | 132 ++++++ .../test/unit/parse-cache-identity.test.ts | 2 + gitnexus/test/unit/parsedfile-store.test.ts | 22 + gitnexus/test/unit/taint/disk-scope.test.ts | 7 +- .../test/unit/taint/guard-refinement.test.ts | 131 ++++++ .../test/unit/taint/interproc-solver.test.ts | 138 ++++++ .../test/unit/taint/lexical-imports.test.ts | 7 +- .../test/unit/taint/summary-model.test.ts | 25 ++ .../test/unit/taint/value-refinement.test.ts | 98 +++++ gitnexus/test/unit/tools.test.ts | 9 +- 36 files changed, 2328 insertions(+), 59 deletions(-) create mode 100644 gitnexus/src/core/ingestion/cfg/visitors/typescript-refinement.ts create mode 100644 gitnexus/src/core/ingestion/taint/guard-refinement.ts create mode 100644 gitnexus/src/core/ingestion/taint/value-refinement.ts create mode 100644 gitnexus/src/mcp/taint-limitations.ts create mode 100644 gitnexus/test/integration/cfg/guard-taint.test.ts create mode 100644 gitnexus/test/unit/cfg/typescript-refinement.test.ts create mode 100644 gitnexus/test/unit/taint/guard-refinement.test.ts create mode 100644 gitnexus/test/unit/taint/value-refinement.test.ts diff --git a/gitnexus/bench/cfg/baselines.json b/gitnexus/bench/cfg/baselines.json index af5f9a800..fa172726f 100644 --- a/gitnexus/bench/cfg/baselines.json +++ b/gitnexus/bench/cfg/baselines.json @@ -38,25 +38,25 @@ "disk_bytes_budget": 1.2, "rd_scaling_budget": 2.0, "facts_large_min": 150, - "_note": "#2201: N nested loops carrying ONE variable end-to-end (depth 40->160) -- the pathology the dense worklist is superlinear on and whose block-visit total drives it past the blocks×64 ceiling (it would TRUNCATE to empty). rd is measured under the PRODUCTION blocks×64 budget (rdProductionBudget) to prove the ceiling stops firing: the depth-INDEPENDENT SSA solver (phi-nodes capture loop merges statically; no fixpoint iteration) computes the full facts (measured 164 at large) with rd_scaling ~0.68 (linear in depth; measured ~0.57ms at depth 160). facts_large_min tightened 100->150 (#2201 review R7): a partial-truncation regression that still cleared the old floor of 100 (but lost facts of the measured 164) now fails, with ~9% headroom under 164 for noise; the companion rd_all_computed gate also catches any non-'computed' status. rd_scaling_budget 2.0 catches a regression back to superlinear. No heap_budget -- the deep-nest CFG payload is tiny and the retained-heap delta is GC-noise-dominated. Re-baseline the fingerprint only on an intentional CFG/visitor change. FU-C re-fingerprinted: BindingEntry.formalIndex joins the binding facts canon (CALL_SUMMARY formal-position keying); CFG topology unchanged." + "_note": "#2201: N nested loops carrying ONE variable end-to-end (depth 40->160) -- the pathology the dense worklist is superlinear on and whose block-visit total drives it past the blocks\u00d764 ceiling (it would TRUNCATE to empty). rd is measured under the PRODUCTION blocks\u00d764 budget (rdProductionBudget) to prove the ceiling stops firing: the depth-INDEPENDENT SSA solver (phi-nodes capture loop merges statically; no fixpoint iteration) computes the full facts (measured 164 at large) with rd_scaling ~0.68 (linear in depth; measured ~0.57ms at depth 160). facts_large_min tightened 100->150 (#2201 review R7): a partial-truncation regression that still cleared the old floor of 100 (but lost facts of the measured 164) now fails, with ~9% headroom under 164 for noise; the companion rd_all_computed gate also catches any non-'computed' status. rd_scaling_budget 2.0 catches a regression back to superlinear. No heap_budget -- the deep-nest CFG payload is tiny and the retained-heap delta is GC-noise-dominated. Re-baseline the fingerprint only on an intentional CFG/visitor change. FU-C re-fingerprinted: BindingEntry.formalIndex joins the binding facts canon (CALL_SUMMARY formal-position keying); CFG topology unchanged." }, "wide-merge": { - "fingerprint": "4135a740376068c3f2dedc4751c6588cbe9ae36026ddc9f2ae9112b188980cda", + "fingerprint": "1b724ea967c2805d643cea6511628e8d248f47431e1db55591a23da3e2808df1", "scaling_budget": 1.8, "disk_bytes_budget": 1.2, "heap_budget": 1.3, "rd_scaling_budget": 2.0, "facts_large_min": 24000, - "_note": "#2201 review R7: N bindings, EACH assigned in a 3-way branch (a wide multi-operand phi per binding) inside a loop, then all used after the merge. Distinct from dense-bindings (one CHAINED redef per `if`): every binding fans into its OWN wide phi, so this exercises phi-placement + renaming + the reachByScc condensation across MANY independent wide merges. N bindings x constant arms => O(N) facts (measured 26008 at the large size), so the gate is rd_scaling LINEARITY: measured ~1.07 (time 9.3->39.8ms over the 4x size step); budget 2.0 catches a regression to the per-binding-rescan O(N^2) class -- the recurring solver antipattern the reachByScc alias fast path (review R2) guards against. rd is measured under the PRODUCTION blocks×64 budget (rdProductionBudget): all functions report 'computed' (the SSA path does not truncate here), and facts_large_min 24000 (measured 26008, ~7% headroom) + the rd_all_computed gate assert the wide merges compute fully. fp_blocks 82 / fp_edges 112 at FP_SIZE=15. Re-baseline the fingerprint only on an intentional CFG/harvest-shape change. #2227 U1 re-fingerprinted: SiteRecord.at call-site anchor [line,col] joins the statement-facts canon (resolved-callee-id stack); CFG topology unchanged. FU-C re-fingerprinted: BindingEntry.formalIndex joins the binding facts canon (CALL_SUMMARY formal-position keying); CFG topology unchanged." + "_note": "#2201 review R7: N bindings, EACH assigned in a 3-way branch (a wide multi-operand phi per binding) inside a loop, then all used after the merge. Distinct from dense-bindings (one CHAINED redef per `if`): every binding fans into its OWN wide phi, so this exercises phi-placement + renaming + the reachByScc condensation across MANY independent wide merges. N bindings x constant arms => O(N) facts (measured 26008 at the large size), so the gate is rd_scaling LINEARITY: measured ~1.07 (time 9.3->39.8ms over the 4x size step); budget 2.0 catches a regression to the per-binding-rescan O(N^2) class -- the recurring solver antipattern the reachByScc alias fast path (review R2) guards against. rd is measured under the PRODUCTION blocks\u00d764 budget (rdProductionBudget): all functions report 'computed' (the SSA path does not truncate here), and facts_large_min 24000 (measured 26008, ~7% headroom) + the rd_all_computed gate assert the wide merges compute fully. fp_blocks 82 / fp_edges 112 at FP_SIZE=15. Re-baseline the fingerprint only on an intentional CFG/harvest-shape change. #2227 U1 re-fingerprinted: SiteRecord.at call-site anchor [line,col] joins the statement-facts canon (resolved-callee-id stack); CFG topology unchanged. FU-C re-fingerprinted: BindingEntry.formalIndex joins the binding facts canon (CALL_SUMMARY formal-position keying); CFG topology unchanged. #3490: additive normalized argument-value facts; CFG topology unchanged; budgets retained." }, "fact-fanout": { - "fingerprint": "57fa834df795d8dba99947cdfba2bb86b3f9111d8bf86d38b77c155273ae5c93", + "fingerprint": "f06409f4c6b3f9ef8caeb04d702ffa3ca3b387fba1a86aaab0419b9695e78de8", "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 / #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%). #2227 U1 re-fingerprinted: SiteRecord.at call-site anchor [line,col] joins the statement-facts canon (resolved-callee-id stack); CFG topology unchanged. FU-C re-fingerprinted: BindingEntry.formalIndex joins the binding facts canon (CALL_SUMMARY formal-position keying); CFG topology unchanged." + "_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%). #2227 U1 re-fingerprinted: SiteRecord.at call-site anchor [line,col] joins the statement-facts canon (resolved-callee-id stack); CFG topology unchanged. FU-C re-fingerprinted: BindingEntry.formalIndex joins the binding facts canon (CALL_SUMMARY formal-position keying); CFG topology unchanged. #3490: additive normalized argument-value facts; CFG topology unchanged; budgets retained." }, "taint-dense": { "fingerprint": "f4570c7ae8fde4b7e63b51b4ea641417f911b4267f1f9f2eafadc520020dbf15", @@ -78,5 +78,45 @@ "heap_budget": 1.3, "rd_scaling_budget": 2.0, "_note": "#2195 U7: the first NON-TS scaling scenario -- the C-family analogue of `branchy`, driven through the Go grammar + Go CFG visitor (lang:'go'). ONE Go function with N sequential `if`s (block/edge growth in a single CFG). The `go:` key namespace keeps it out of the TS baseline keyspace (no collision/re-baseline of a TS scenario). Measured time ~1.08, disk ~1.03, heap ~1.0, rd ~1.06 (budgets mirror the TS `branchy` scenario: scaling 1.8 absorbs single-CFG noise + catches a ~4.0 quadratic). Cross-check: fp_blocks 32 / fp_edges 46 are IDENTICAL to the TS branchy fingerprint shape -- the Go visitor builds the same per-`if` block/edge topology. CFG-only (Go has no registered taint model), so no taint gates. Re-baseline the fingerprint only on an intentional Go CFG/harvest-shape change. #2227 U1 re-fingerprinted: SiteRecord.at call-site anchor [line,col] joins the statement-facts canon (resolved-callee-id stack); Go CFG topology unchanged." + }, + "guarded-commands": { + "fingerprint": "02fe82d392ab2e6d842a47a4d9b982cf9a99e9a6199f15e9c09185b715186727", + "scaling_budget": 1.8, + "disk_bytes_budget": 1.2, + "heap_budget": 1.3, + "rd_scaling_budget": 2.0, + "_note": "#3490: bounded guard/value refinement. Production analysis asserts expected findings, forward effects, fallback at caps, and JSON replay equivalence; normalized 4x-size ratios guard linear scaling." + }, + "guard-bypass": { + "fingerprint": "8a47f79c6d8a6fcb3a47cc05dc79936a7f4daf8b0e8947a852522a4def619225", + "scaling_budget": 1.8, + "disk_bytes_budget": 1.2, + "heap_budget": 1.3, + "rd_scaling_budget": 2.0, + "_note": "#3490: bounded guard/value refinement. Production analysis asserts expected findings, forward effects, fallback at caps, and JSON replay equivalence; normalized 4x-size ratios guard linear scaling." + }, + "guard-forwarders": { + "fingerprint": "5121f44d067c58c6b4e40df6306fd237c0ea4edf9f860e8cb4d666aa5502e773", + "scaling_budget": 1.8, + "disk_bytes_budget": 1.2, + "heap_budget": 1.3, + "rd_scaling_budget": 2.0, + "_note": "#3490: bounded guard/value refinement. Production analysis asserts expected findings, forward effects, fallback at caps, and JSON replay equivalence; normalized 4x-size ratios guard linear scaling." + }, + "guard-capped": { + "fingerprint": "c7694d17d006ff15db094d588726bbbb9776de1181d3bf0f1e769d876a423de9", + "scaling_budget": 1.8, + "disk_bytes_budget": 1.2, + "heap_budget": 1.3, + "rd_scaling_budget": 2.0, + "_note": "#3490: bounded guard/value refinement. Production analysis asserts expected findings, forward effects, fallback at caps, and JSON replay equivalence; normalized 4x-size ratios guard linear scaling." + }, + "guard-discriminants": { + "fingerprint": "2d04158a9c8eae54f567ffad7f585b5e84b059da2386756e9b703ac8c4e3a0f1", + "scaling_budget": 1.8, + "disk_bytes_budget": 1.2, + "heap_budget": 1.3, + "rd_scaling_budget": 2.0, + "_note": "#3490: bounded guard/value refinement. Production analysis asserts expected findings, forward effects, fallback at caps, and JSON replay equivalence; normalized 4x-size ratios guard linear scaling." } } diff --git a/gitnexus/bench/cfg/measure.mjs b/gitnexus/bench/cfg/measure.mjs index 209afae55..800ea9d26 100644 --- a/gitnexus/bench/cfg/measure.mjs +++ b/gitnexus/bench/cfg/measure.mjs @@ -59,6 +59,8 @@ import { DEFAULT_PDG_MAX_TAINT_HOPS, } from '../../src/core/ingestion/taint/propagate.ts'; import { encodeTaintPath } from '../../src/core/ingestion/taint/path-codec.ts'; +import { createMemoizedRefinement } from '../../src/core/ingestion/taint/guard-refinement.ts'; +import { harvestFunctionSummary } from '../../src/core/ingestion/taint/summary-harvest.ts'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const BASELINE_PATH = path.resolve(__dirname, 'baselines.json'); @@ -114,6 +116,73 @@ function langToolkit(langId) { // ---- synthetic generators (one cost dimension each) ---- const SCENARIOS = [ + { + name: 'guarded-commands', + small: 60, + large: 240, + guard: { findingsPerFunction: 0 }, + gen: (n) => + Array.from( + { length: n }, + (_, i) => `function guarded${i}(req: any) { + const value: string = req.query.ref; + if (!/^[0-9a-f]{7,40}$/.test(value)) return; + execSync('git show ' + value); + }`, + ).join('\n'), + }, + { + name: 'guard-bypass', + small: 60, + large: 240, + guard: { findingsPerFunction: 1 }, + gen: (n) => + Array.from( + { length: n }, + (_, i) => `function bypass${i}(req: any, flag: boolean) { + const value: string = req.query.ref; + if (flag) { if (!/^[0-9a-f]{7,40}$/.test(value)) return; } + execSync('git show ' + value); + }`, + ).join('\n'), + }, + { + name: 'guard-forwarders', + small: 60, + large: 240, + guard: { findingsPerFunction: 0, forwarding: true }, + gen: (n) => + Array.from( + { length: n }, + (_, i) => `function forwarder${i}(value: string) { + if (!/^[0-9a-f]{7,40}$/.test(value)) return; + forward(value); + }`, + ).join('\n'), + }, + { + name: 'guard-capped', + small: 80, + large: 320, + guard: { findingsPerFunction: 1, fallback: true }, + gen: (n) => + `function capped(req: any) { const value: string = req.query.ref;\n` + + Array.from({ length: n }, () => 'if (!/^[0-9a-f]{7,40}$/.test(value)) return;').join('\n') + + `\nexecSync('git show ' + value); }`, + }, + { + name: 'guard-discriminants', + small: 100, + large: 400, + guard: { findingsPerFunction: 0 }, + gen: (n) => + Array.from( + { length: n }, + (_, i) => `function discriminant${i}(kind: string) { + if (kind === 'read') return 1; return 2; + }`, + ).join('\n'), + }, { name: 'straight-line', // One function, N coalescing simple statements → all fold into one basic @@ -388,6 +457,7 @@ function measureReachingDefs(cfgs, reps, maxFacts, blockVisitsMul = 0) { function measureTaint(cfgs, reps, cap, taintModel) { const importIndex = buildTaintImportIndex([]); // bench callees are globals const pass = () => { + const refine = createMemoizedRefinement(); let analyzed = 0; let kept = 0; let dropped = 0; @@ -398,10 +468,16 @@ function measureTaint(cfgs, reps, cap, taintModel) { 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, - }); + const flows = computeTaintFlows( + c, + du, + matches, + { + maxFindingsPerFunction: cap, + maxHops: DEFAULT_PDG_MAX_TAINT_HOPS, + }, + refine(c), + ); if (flows.status !== 'computed') continue; analyzed++; kept += flows.findings.length; @@ -428,6 +504,61 @@ function measureTaint(cfgs, reps, cap, taintModel) { return { ms: median(samples), ...out }; } +// Use the production policy and matching/solver path; only the fixture's global +// spelling replaces import resolution, which is covered by integration tests. +const GUARD_MODEL = { + ...TS_JS_TAINT_MODEL, + sinks: TS_JS_TAINT_MODEL.sinks + .filter((entry) => entry.name === 'execSync') + .map((entry) => ({ ...entry, module: undefined, global: true })), +}; + +function measureGuards(cfgs, reps) { + const imports = buildTaintImportIndex([]); + const pass = (input) => { + const refine = createMemoizedRefinement(); + let visits = 0, + fallbacks = 0, + findings = 0, + forwardEffects = 0; + for (const cfg of input) { + const refinement = refine(cfg); + visits += refinement.guards.visits; + if (refinement.guards.status === 'fallback') fallbacks++; + const matches = matchFunctionSites(cfg, GUARD_MODEL, imports); + const du = computeReachingDefs(cfg); + if (matches.hasSource && matches.hasSink) + findings += computeTaintFlows(cfg, du, matches, undefined, refinement).findings.length; + forwardEffects += harvestFunctionSummary( + cfg, + du, + matches, + refinement, + ).facts.paramToCallArg.filter( + (edge) => edge.calleeName === 'forward' && edge.valueEffect?.kind === 'restrict', + ).length; + } + return { visits, fallbacks, findings, forwardEffects }; + }; + pass(cfgs); + const samples = []; + let result; + for (let i = 0; i < reps; i++) { + const start = process.hrtime.bigint(); + result = pass(cfgs); + samples.push(Number(process.hrtime.bigint() - start) / 1e6); + } + const replay = JSON.parse(JSON.stringify(cfgs)); + const start = process.hrtime.bigint(); + const replayResult = pass(replay); + return { + ms: median(samples), + ...result, + replayMs: Number(process.hrtime.bigint() - start) / 1e6, + replayEqual: JSON.stringify(result) === JSON.stringify(replayResult), + }; +} + // ---- memory growth: retained heap of the cfgSideChannel payload ---- // Needs `node --expose-gc` to force collection for a clean delta; without it the @@ -559,7 +690,33 @@ function measureScenario(scenario) { }; } + let guardMetrics = {}; + if (scenario.guard) { + const guarded = measureGuards(large.cfgs, REPS); + guardMetrics = { + guard_ms_large: Number(guarded.ms.toFixed(3)), + guard_visits: guarded.visits, + guard_fallbacks: guarded.fallbacks, + guard_findings: guarded.findings, + guard_forward_effects: guarded.forwardEffects, + guard_replay_ms: Number(guarded.replayMs.toFixed(3)), + guard_replay_equal: guarded.replayEqual, + guard_functions: large.cfgs.length, + }; + if ( + guarded.findings !== large.cfgs.length * scenario.guard.findingsPerFunction || + !guarded.replayEqual || + (scenario.guard.fallback ? guarded.fallbacks !== large.cfgs.length : guarded.visits === 0) || + (scenario.guard.forwarding && guarded.forwardEffects < large.cfgs.length) || + guarded.visits > large.cfgs.length * 50_000 + ) { + throw new Error( + `${scenario.name}: refinement correctness/budget/replay gate failed: ${JSON.stringify(guarded)}`, + ); + } + } return { + ...guardMetrics, ...taintMetrics, scenario: scenario.name, elapsed_ms_small: Number(small.ms.toFixed(3)), diff --git a/gitnexus/bench/tool-accuracy/README.md b/gitnexus/bench/tool-accuracy/README.md index 8cab47191..ad415d060 100644 --- a/gitnexus/bench/tool-accuracy/README.md +++ b/gitnexus/bench/tool-accuracy/README.md @@ -30,7 +30,7 @@ errors always exit nonzero. `--check` additionally enforces the regression gate. | Fixture / checks | Public repro | Fixed-answer contract | | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `rename/` | [#3486](https://github.com/abhigyanpatwari/GitNexus/issues/3486) | All three Writer.close references; zero edits to comments, strings or unrelated homonyms; typed DeferredWriter.flush parameters refuse without edits or source changes. | +| `rename/` | [#3486](https://github.com/abhigyanpatwari/GitNexus/issues/3486) | All three Writer.close references; zero edits to comments, strings or unrelated homonyms; typed DeferredWriter.flush parameters refuse without edits or source changes. | | `src/server/`, `test/stub.test.ts` | [#3487](https://github.com/abhigyanpatwari/GitNexus/issues/3487) | Server beats a same-path test stub; `app.all` has method `*`; Map.get is absent; helper-registered literal route is present. | | `src/web/` | [#3488](https://github.com/abhigyanpatwari/GitNexus/issues/3488) | Both the base-URL template passed to a configured wrapper and a direct literal fetch link their consumer files. | | `src/security.ts`, `src/server/inline.ts` | [#3489](https://github.com/abhigyanpatwari/GitNexus/issues/3489), [#3490](https://github.com/abhigyanpatwari/GitNexus/issues/3490), [#3491](https://github.com/abhigyanpatwari/GitNexus/issues/3491) | Named and inline unsafe handlers reach the sink; guarded and constant commands have zero taint; statement and function-hop lines are 1-based. | @@ -66,7 +66,10 @@ checks and all four #3499 checks, and their allowances were removed: **18/31 pass, 13 fail**. The JSON report records every desired and actual answer independently of gate status. Inline-callback taint now passes and its allowance is removed. Guard -taint remains a documented limitation and a visible known gap. +taint now passes for the modeled immutable SHA predicate and Git data-argument +use (#3490), and its allowance is removed. The current corpus passes **32/32** +fixed answers with no known-gap allowances. Unsupported guard forms remain +conservative findings; this does not claim complete guard coverage. `known-gaps.json` is an explicit, reviewed list of failing check IDs with issue, reason and observed baseline answers. There is no wildcard, skip, automatic diff --git a/gitnexus/bench/tool-accuracy/known-gaps.json b/gitnexus/bench/tool-accuracy/known-gaps.json index 5e9b867b7..5080dff24 100644 --- a/gitnexus/bench/tool-accuracy/known-gaps.json +++ b/gitnexus/bench/tool-accuracy/known-gaps.json @@ -1,12 +1,5 @@ { "schemaVersion": 1, "baselineSourceSha": "dd096e690c06e5d54ab3595c63b83f15e04b6191", - "gaps": [ - { - "id": "explain.guard", - "issue": 3490, - "reason": "Guard-style sanitizers are a documented taint limitation (#3490).", - "baselineActual": ["handleGuarded -> runGit command-injection"] - } - ] + "gaps": [] } diff --git a/gitnexus/src/core/ingestion/cfg/collect.ts b/gitnexus/src/core/ingestion/cfg/collect.ts index 0c4e0ce35..a629d9a49 100644 --- a/gitnexus/src/core/ingestion/cfg/collect.ts +++ b/gitnexus/src/core/ingestion/cfg/collect.ts @@ -124,6 +124,7 @@ export function collectFunctionCfgs( // Site anchors must match the coordinate space of scope reference captures. siteCoordinates: 'source' | 'parse-buffer' = 'source', ): CollectedCfgs { + visitor = visitor.forFile?.(root, filePath) ?? visitor; const cfgs: FunctionCfg[] = []; let tooManyLines = 0; let tooDeeplyNested = 0; diff --git a/gitnexus/src/core/ingestion/cfg/types.ts b/gitnexus/src/core/ingestion/cfg/types.ts index abd50cc04..07bb8bd61 100644 --- a/gitnexus/src/core/ingestion/cfg/types.ts +++ b/gitnexus/src/core/ingestion/cfg/types.ts @@ -1,3 +1,5 @@ +import type { StatementValueFacts } from '../taint/value-refinement.js'; + /** * CFG data model — plain, JSON-serializable types (issue #2081, M1). * @@ -199,6 +201,8 @@ export interface StatementFacts { readonly uses: readonly number[]; readonly mayDefs?: readonly number[]; readonly sites?: readonly SiteRecord[]; + /** Optional normalized value/predicate structure; absent on legacy channels. */ + readonly refinement?: StatementValueFacts; } /** A basic block: a maximal straight-line run of statements between leaders. */ @@ -278,6 +282,10 @@ export interface FunctionCfg { * {@link BasicBlockData.statements}. Present iff statement facts are. */ readonly bindings?: readonly BindingEntry[]; + /** Provider checked unsupported effects, including invisible closure writes. */ + readonly refinementSafe?: boolean; + /** Exact primitive-string declaration contracts, never assertion-derived. */ + readonly primitiveBindings?: readonly number[]; } /** @@ -287,6 +295,8 @@ export interface FunctionCfg { * `undefined` when the node is not a CFG-bearing function (the caller skips it). */ export interface CfgVisitor { + /** File-bound worker context, released after collection. Existing providers need no hook. */ + forFile?(root: TNode, filePath: string): CfgVisitor; buildFunctionCfg(fnNode: TNode, filePath: string): FunctionCfg | undefined; /** diff --git a/gitnexus/src/core/ingestion/cfg/visitors/typescript-harvest.ts b/gitnexus/src/core/ingestion/cfg/visitors/typescript-harvest.ts index 82fdd9d09..00861a20d 100644 --- a/gitnexus/src/core/ingestion/cfg/visitors/typescript-harvest.ts +++ b/gitnexus/src/core/ingestion/cfg/visitors/typescript-harvest.ts @@ -38,6 +38,14 @@ * NOTE: nothing serialized here may carry a field named `nodeId` — the durable * parsedfile-store reviver dedups objects keyed on that field name. */ +import { + hasDefaultShellArguments, + readPredicate, + readValueExpression, + type RefinementFileContext, +} from './typescript-refinement.js'; +import type { FunctionCfg } from '../types.js'; +import type { StatementValueFacts } from '../../taint/value-refinement.js'; import type { SyntaxNode } from '../../utils/ast-helpers.js'; import type { BindingEntry, SiteArgOccurrence, SiteRecord, StatementFacts } from '../types.js'; @@ -114,7 +122,34 @@ interface Scope { readonly table: Map; } +const UNSAFE_REFINEMENT_NODES = new Set([ + 'try_statement', + 'await_expression', + 'yield_expression', + 'with_statement', +]); +const PREDICATE_NODES = new Set([ + 'parenthesized_expression', + 'unary_expression', + 'binary_expression', + 'call_expression', +]); + export class TsHarvester { + private refinementSafe = true; + private primitiveBindings?: Set; + private hasRefinementFacts = false; + private needsValueFacts = false; + private shapeCount = 0; + private predicateCount = 0; + private captureShapes = false; + private readonly checkDynamicNames: boolean; + private readonly hasGuardCandidate: boolean; + private activeShapes?: { + sites?: Map; + definitions?: NonNullable[number][]; + calls?: NonNullable[number][]; + }; private readonly bindings: BindingEntry[] = []; /** Scope-opening node id → its scope. */ private readonly scopeByNode = new Map(); @@ -148,7 +183,20 @@ export class TsHarvester { */ private readonly resultDefTargets = new Map(); - constructor(private readonly fnNode: SyntaxNode) { + constructor( + private readonly fnNode: SyntaxNode, + private readonly refinementContext?: RefinementFileContext, + ) { + const source = fnNode.text; + this.checkDynamicNames = + source.includes('eval') || source.includes('arguments') || source.includes('\\'); + this.hasGuardCandidate = + source.includes('test') || source.includes('===') || source.includes('!=='); + if ( + fnNode.type.includes('generator') || + (source.includes('async') && fnNode.children.some((child) => child.type === 'async')) + ) + this.refinementSafe = false; this.fnId = fnNode.id; this.scopeByNode.set(fnNode.id, this.root); this.declareParams(fnNode); @@ -162,6 +210,21 @@ export class TsHarvester { return this.bindings; } + decorate(cfg: FunctionCfg): FunctionCfg { + if (!this.refinementSafe) return { ...cfg, refinementSafe: false }; + if (!this.hasRefinementFacts) return cfg; + return { + ...cfg, + refinementSafe: this.refinementSafe, + ...(this.primitiveBindings?.size ? { primitiveBindings: [...this.primitiveBindings] } : {}), + }; + } + + private addPrimitiveBinding(binding: number): void { + if ((this.primitiveBindings?.size ?? 0) >= 256) this.refinementSafe = false; + else (this.primitiveBindings ??= new Set()).add(binding); + } + // ── phase 1: declaration pre-scan ──────────────────────────────────────── private openScope(node: SyntaxNode): Scope { @@ -227,6 +290,15 @@ export class TsHarvester { // `pattern`); plain JS puts the pattern directly in formal_parameters. const pattern = p.childForFieldName('pattern') ?? p; this.declarePattern(pattern, 'param', this.root, true, formalIndex); + if ( + this.hasGuardCandidate && + pattern.type === 'identifier' && + p.type === 'required_parameter' && + p.childForFieldName('type')?.text.replace(/\s/g, '') === ':string' + ) { + this.addPrimitiveBinding(this.root.table.get(pattern.text)!); + } + if (p.childForFieldName('value')) this.refinementSafe = false; formalIndex++; } } @@ -282,7 +354,15 @@ export class TsHarvester { private prescan(node: SyntaxNode, scope: Scope): void { this.nearestScopeCache.set(node.id, scope); const t = node.type; + if (UNSAFE_REFINEMENT_NODES.has(t)) this.refinementSafe = false; + if (this.checkDynamicNames && t === 'identifier') { + const name = node.text; + // The legacy binding table does not canonicalize escaped identifier spellings. + if (name === 'arguments' || name === 'eval' || name.includes('\\')) + this.refinementSafe = false; + } if (NESTED_FUNCTION_TYPES.has(t) && node.id !== this.fnId) { + this.refinementSafe = false; // A nested function's NAME binds in the enclosing scope; its body is opaque. if (FUNCTION_DECL_TYPES.has(t)) { const name = node.childForFieldName('name'); @@ -297,6 +377,7 @@ export class TsHarvester { switch (t) { case 'lexical_declaration': { const kind = node.child(0)?.type === 'const' ? 'const' : 'let'; + if (kind === 'const') this.needsValueFacts = true; this.declareDeclarators(node, kind, childScope, false); break; } @@ -329,7 +410,9 @@ export class TsHarvester { break; } - for (let i = 0; i < node.namedChildCount; i++) { + const childCount = node.namedChildCount; + if (t === 'arguments' && childCount > 0) this.needsValueFacts = true; + for (let i = 0; i < childCount; i++) { const c = node.namedChild(i); if (c) this.prescan(c, childScope); } @@ -357,8 +440,42 @@ export class TsHarvester { */ facts(node: SyntaxNode): StatementFacts { const acc = new FactAccumulator(node.startPosition.row + 1); - this.walkValue(node, acc); - return acc.finish(); + if (!this.refinementSafe || (!this.hasGuardCandidate && !this.needsValueFacts)) { + this.walkValue(node, acc); + return acc.finish(); + } + const prior = this.activeShapes; + const priorCapture = this.captureShapes; + this.activeShapes = undefined; + this.captureShapes = true; + try { + this.walkValue(node, acc); + const shapes = this.activeShapes; + let predicate = + this.hasGuardCandidate && PREDICATE_NODES.has(node.type) + ? readPredicate( + node, + (identifier) => this.resolve(identifier), + this.refinementContext, + (call) => shapes?.sites?.get(call.id), + ) + : undefined; + if (predicate && ++this.predicateCount > 64) { + this.refinementSafe = false; + predicate = undefined; + } + if (!predicate && !shapes?.definitions?.length && !shapes?.calls?.length) return acc.finish(); + this.hasRefinementFacts = true; + const refinement: StatementValueFacts = { + ...(predicate ? { predicate } : {}), + ...(shapes?.definitions?.length ? { definitions: shapes.definitions } : {}), + ...(shapes?.calls?.length ? { calls: shapes.calls } : {}), + }; + return { ...acc.finish(), ...(Object.keys(refinement).length ? { refinement } : {}) }; + } finally { + this.activeShapes = prior; + this.captureShapes = priorCapture; + } } /** @@ -505,6 +622,7 @@ export class TsHarvester { if (d?.type !== 'variable_declarator') continue; const name = d.childForFieldName('name'); const value = d.childForFieldName('value'); + let declaredBinding: number | undefined; // A bare `var x;` mid-function is hoisted and writes NOTHING at // runtime — harvesting it as a def would fabricate a kill of the // live def (`x = source(); var x; sink(x)` must keep source→sink; @@ -512,9 +630,33 @@ export class TsHarvester { if (name && (value || t === 'lexical_declaration')) { const snap = acc.defSnapshot(); this.walkDefPattern(name, acc); - if (value) this.registerResultDefs(value, acc.defsSince(snap)); + if (value) { + const defs = acc.defsSince(snap); + this.registerResultDefs(value, defs); + if (defs.length === 1) declaredBinding = defs[0]; + } } if (value) this.walkValue(value, acc); + if ( + this.captureShapes && + value && + declaredBinding !== undefined && + this.bindings[declaredBinding]?.kind === 'const' && + name?.type === 'identifier' + ) { + const shapes = (this.activeShapes ??= {}); + if (++this.shapeCount > 256) this.refinementSafe = false; + else + (shapes.definitions ??= []).push({ + binding: declaredBinding, + value: readValueExpression(value, (identifier) => this.resolve(identifier)), + }); + if ( + this.hasGuardCandidate && + d.childForFieldName('type')?.text.replace(/\s/g, '') === ':string' + ) + this.addPrimitiveBinding(declaredBinding); + } } return; case 'assignment_expression': { @@ -736,6 +878,23 @@ export class TsHarvester { // `node` IS the call_expression/new_expression — the SAME node the // scope-extractor anchors `@reference.call.*` (its `atRange`) on (KTD7). const siteIdx = acc.openCallSite(kind, [node.startPosition.row + 1, node.startPosition.column]); + if (argsNode?.type === 'arguments' && argsNode.namedChildCount > 0 && this.captureShapes) { + const shapes = (this.activeShapes ??= {}); + (shapes.sites ??= new Map()).set(node.id, siteIdx); + if (++this.shapeCount > 256) this.refinementSafe = false; + else { + const args = argsNode.namedChildren.filter((argument) => argument.type !== 'comment'); + if (args.length > 32) this.refinementSafe = false; + else + (shapes.calls ??= []).push({ + site: siteIdx, + arguments: args.map((argument) => + readValueExpression(argument, (identifier) => this.resolve(identifier)), + ), + ...(hasDefaultShellArguments(args) ? { defaultShell: true } : {}), + }); + } + } acc.pushFrame(siteIdx); let calleePath: string | undefined; if (calleeNode) { diff --git a/gitnexus/src/core/ingestion/cfg/visitors/typescript-refinement.ts b/gitnexus/src/core/ingestion/cfg/visitors/typescript-refinement.ts new file mode 100644 index 000000000..2cb8d0aa7 --- /dev/null +++ b/gitnexus/src/core/ingestion/cfg/visitors/typescript-refinement.ts @@ -0,0 +1,298 @@ +import type { SyntaxNode } from '../../utils/ast-helpers.js'; +import { + MAX_VALUE_BYTES, + MAX_VALUE_NODES, + parseAnchoredAsciiPattern, + type StringRestriction, + type ValueExpression, +} from '../../taint/value-refinement.js'; + +export interface PredicateFact { + readonly site?: number; + readonly subject: number; + readonly accepts: boolean; + readonly restriction: StringRestriction; + readonly establishesPrimitive?: boolean; +} + +export interface RefinementFileContext { + readonly validators: ReadonlyMap; + readonly enabled: boolean; +} + +const UNKNOWN_EXPRESSION: ValueExpression = Object.freeze({ kind: 'unknown' }); +const UNCERTAIN_RUNTIME_ROOTS = new Set([ + 'RegExp', + 'eval', + 'Function', + 'globalThis', + 'global', + 'window', + 'self', +]); + +function regexRestriction(node: SyntaxNode): StringRestriction | undefined { + if (node.type !== 'regex' || node.text.length > MAX_VALUE_BYTES) return; + const pattern = node.childForFieldName('pattern'); + const flags = + node.childForFieldName('flags')?.text ?? node.text.slice(node.text.lastIndexOf('/') + 1); + return pattern ? parseAnchoredAsciiPattern(pattern.text, flags) : undefined; +} + +/** One bounded file scan; every non-test reference invalidates a mutable RegExp object. */ +export function createRefinementFileContext(root: SyntaxNode): RefinementFileContext { + if (!/\.\s*test\b/.test(root.text)) return { validators: new Map(), enabled: false }; + const candidates = new Map(); + const identifiers: SyntaxNode[] = []; + const pending = [root]; + let visits = 0; + let bytes = 0; + let enabled = true; + while (pending.length) { + if (++visits > 100_000) return { validators: new Map(), enabled: false }; + const node = pending.pop()!; + if (node.type.includes('identifier')) identifiers.push(node); + if (node.type === 'regex') { + const declaration = node.parent; + const lexical = declaration?.parent; + const indexedInitializer = + declaration?.type === 'variable_declarator' && + declaration.childForFieldName('value')?.id === node.id && + declaration.childForFieldName('name')?.type === 'identifier' && + lexical?.type === 'lexical_declaration' && + lexical.parent?.type === 'program' && + lexical.firstChild?.text === 'const'; + const member = node.parent; + const call = member?.parent; + const directTest = + member?.type === 'member_expression' && + member.childForFieldName('object')?.id === node.id && + member.childForFieldName('property')?.text === 'test' && + call?.type === 'call_expression' && + call.childForFieldName('function')?.id === member.id; + if (!indexedInitializer && !directTest) enabled = false; + } + if (node.type === 'identifier') { + const name = node.text; + if (name.includes('\\') || UNCERTAIN_RUNTIME_ROOTS.has(name)) enabled = false; + } + if ( + node.type === 'variable_declarator' && + node.parent?.type === 'lexical_declaration' && + node.parent.parent?.type === 'program' && + node.parent.firstChild?.text === 'const' + ) { + const name = node.childForFieldName('name'); + const value = node.childForFieldName('value'); + const restriction = value && regexRestriction(value); + if (name?.type === 'identifier' && value?.type === 'regex') { + bytes += value!.text.length; + if (candidates.size >= 256 || bytes > 8192) + return { validators: new Map(), enabled: false }; + if (candidates.has(name.text)) enabled = false; + candidates.set(name.text, { declaration: name.id, restriction }); + } + } + for (let i = node.namedChildCount - 1; i >= 0; i--) { + const child = node.namedChild(i); + if (child) pending.push(child); + } + } + for (const node of identifiers) { + if (++visits > 100_000) return { validators: new Map(), enabled: false }; + const candidate = candidates.get(node.text); + if (!candidate || node.id === candidate.declaration) continue; + const member = node.parent; + const call = member?.parent; + if ( + node.type !== 'identifier' || + member?.type !== 'member_expression' || + member.childForFieldName('object')?.id !== node.id || + member.childForFieldName('property')?.text !== 'test' || + call?.type !== 'call_expression' || + call.childForFieldName('function')?.id !== member.id + ) + enabled = false; + } + const validators = new Map(); + if (enabled) + for (const [name, candidate] of candidates) { + if (candidate.restriction) validators.set(name, candidate.restriction); + } + return { validators, enabled }; +} + +function unwrap(node: SyntaxNode): SyntaxNode { + while ( + ['parenthesized_expression', 'as_expression', 'non_null_expression', 'type_assertion'].includes( + node.type, + ) + ) { + const inner = node.childForFieldName('expression') ?? node.firstNamedChild; + if (!inner || inner.id === node.id) break; + node = inner; + } + return node; +} + +/** Preserve only exact string/binding composition, with a fixed traversal budget. */ +export function readValueExpression( + node: SyntaxNode, + resolve: (identifier: SyntaxNode) => number, +): ValueExpression { + let remaining = MAX_VALUE_NODES; + let bytes = 0; + let exhausted = false; + const visit = (raw: SyntaxNode): ValueExpression => { + if (--remaining < 0) { + exhausted = true; + return UNKNOWN_EXPRESSION; + } + const value = unwrap(raw); + if (value.type === 'identifier') return { kind: 'binding', binding: resolve(value) }; + if (value.type === 'string') { + const text = value.text; + bytes += text.length; + if (bytes > MAX_VALUE_BYTES) { + exhausted = true; + return UNKNOWN_EXPRESSION; + } + return text.length <= MAX_VALUE_BYTES && !text.includes('\\') + ? { kind: 'literal', value: text.slice(1, -1) } + : UNKNOWN_EXPRESSION; + } + if (value.type === 'binary_expression' && value.childForFieldName('operator')?.text === '+') { + const left = value.childForFieldName('left'); + const right = value.childForFieldName('right'); + return left && right + ? { kind: 'concat', parts: [visit(left), visit(right)] } + : UNKNOWN_EXPRESSION; + } + if ( + value.type === 'template_string' && + value.text.length <= MAX_VALUE_BYTES && + !value.text.includes('\\') + ) { + const parts: ValueExpression[] = []; + for (const child of value.namedChildren) { + if (child.type === 'string_fragment') { + bytes += child.text.length; + if (bytes > MAX_VALUE_BYTES) { + exhausted = true; + return UNKNOWN_EXPRESSION; + } + parts.push({ kind: 'literal', value: child.text }); + } else if (child.type === 'template_substitution' && child.firstNamedChild) + parts.push(visit(child.firstNamedChild)); + else return UNKNOWN_EXPRESSION; + } + return { kind: 'concat', parts }; + } + if ( + [ + 'object', + 'new_expression', + 'array', + 'number', + 'true', + 'false', + 'null', + 'regex', + 'function_expression', + 'arrow_function', + ].includes(value.type) + ) + return { kind: 'non-string' }; + return UNKNOWN_EXPRESSION; + }; + const result = visit(node); + return exhausted ? UNKNOWN_EXPRESSION : result; +} + +export function readPredicate( + raw: SyntaxNode, + resolve: (identifier: SyntaxNode) => number, + context: RefinementFileContext | undefined, + siteOf?: (call: SyntaxNode) => number | undefined, +): PredicateFact | undefined { + let node = unwrap(raw); + let accepts = true; + let remaining = 8; + while (node.type === 'unary_expression' && node.firstChild?.text === '!' && remaining-- > 0) { + const argument = node.childForFieldName('argument'); + if (!argument) return; + accepts = !accepts; + node = unwrap(argument); + } + if (remaining <= 0) return; + if (node.type === 'call_expression') { + if (!context?.enabled) return; + const callee = node.childForFieldName('function'); + const object = callee?.childForFieldName('object'); + const args = node.childForFieldName('arguments')?.namedChildren; + if ( + callee?.type !== 'member_expression' || + callee.childForFieldName('property')?.text !== 'test' || + !object || + !args || + args.length !== 1 || + args[0].type !== 'identifier' || + node.text.includes('?.') + ) + return; + const restriction = + object.type === 'regex' + ? regexRestriction(object) + : object.type === 'identifier' + ? context?.validators.get(object.text) + : undefined; + if (restriction) { + const site = siteOf?.(node); + return { + subject: resolve(args[0]), + accepts, + restriction, + ...(site !== undefined ? { site } : {}), + }; + } + } + if (node.type === 'binary_expression') { + const operator = node.childForFieldName('operator')?.text; + const left = node.childForFieldName('left'); + const right = node.childForFieldName('right'); + if (!left || !right || (operator !== '===' && operator !== '!==')) return; + const subject = + left.type === 'identifier' ? left : right.type === 'identifier' ? right : undefined; + const literal = subject?.id === left.id ? right : left; + if (!subject || literal.type !== 'string') return; + const expression = readValueExpression(literal, resolve); + if ( + expression.kind !== 'literal' || + !expression.value || + expression.value.length > MAX_VALUE_BYTES + ) + return; + return { + subject: resolve(subject), + accepts: operator === '===' ? accepts : !accepts, + restriction: { + chars: [...new Set(expression.value)].sort().join(''), + min: expression.value.length, + max: expression.value.length, + }, + establishesPrimitive: true, + }; + } +} + +/** Known ordinary options only; inherited shell settings and spread/getters are unknown. */ +export function hasDefaultShellArguments(args: readonly SyntaxNode[]): boolean { + if (args.length === 1) return true; + if (args.length !== 2 || args[1].type !== 'object') return false; + return args[1].namedChildren.every( + (pair) => + pair.type === 'pair' && + pair.childForFieldName('key')?.text === 'encoding' && + pair.childForFieldName('value')?.type === 'string', + ); +} diff --git a/gitnexus/src/core/ingestion/cfg/visitors/typescript.ts b/gitnexus/src/core/ingestion/cfg/visitors/typescript.ts index 43676591b..6aa91f739 100644 --- a/gitnexus/src/core/ingestion/cfg/visitors/typescript.ts +++ b/gitnexus/src/core/ingestion/cfg/visitors/typescript.ts @@ -67,6 +67,10 @@ import { import type { TraversalResult } from '../traversal-result.js'; import type { CfgVisitor, FunctionCfg } from '../types.js'; import { TsHarvester } from './typescript-harvest.js'; +import { + createRefinementFileContext, + type RefinementFileContext, +} from './typescript-refinement.js'; /** TS/JS node types that own a CFG-bearing function body. */ const TS_FUNCTION_TYPES = new Set([ @@ -723,7 +727,11 @@ class TsCfgWalk { } /** Build the CFG for one TS/JS function node (or `undefined` if not a function). */ -function buildFunctionCfg(fnNode: SyntaxNode, filePath: string): FunctionCfg | undefined { +function buildFunctionCfg( + fnNode: SyntaxNode, + filePath: string, + refinementContext?: RefinementFileContext, +): FunctionCfg | undefined { if (!TS_FUNCTION_TYPES.has(fnNode.type)) return undefined; const startLine = startLineOf(fnNode); const endLine = endLineOf(fnNode); @@ -735,7 +743,7 @@ function buildFunctionCfg(fnNode: SyntaxNode, filePath: string): FunctionCfg | u // Phase-1 declaration pre-scan (#2082 M2 U1) — must complete before any // facts are extracted; the CFG walk below is not source-order. - const harvest = new TsHarvester(fnNode); + const harvest = new TsHarvester(fnNode, refinementContext); // Parameters define at ENTRY (facts only — never touch the entry block's // text or span: bench fingerprints and CFG snapshots include block text). @@ -754,18 +762,18 @@ function buildFunctionCfg(fnNode: SyntaxNode, filePath: string): FunctionCfg | u ); builder.edge(builder.entryIndex, blk, 'seq'); builder.edge(blk, builder.exitIndex, 'return'); - return builder.finish(harvest.table()); + return harvest.decorate(builder.finish(harvest.table())); } const walk = new TsCfgWalk(builder, harvest); const res = walk.visitSeq(body.namedChildren.filter((c) => c.type !== 'comment')); if (!res) { builder.edge(builder.entryIndex, builder.exitIndex, 'seq'); // empty body - return builder.finish(harvest.table()); + return harvest.decorate(builder.finish(harvest.table())); } builder.edge(builder.entryIndex, res.entry, 'seq'); builder.connect(res.exits, builder.exitIndex, 'seq'); // normal fall-off → EXIT - return builder.finish(harvest.table()); + return harvest.decorate(builder.finish(harvest.table())); } /** Whether a node is a TS/JS function this visitor builds a CFG for. */ @@ -775,7 +783,14 @@ function isFunction(node: SyntaxNode): boolean { /** The TS/JS CFG visitor (shared by TypeScript and JavaScript). */ export function createTypeScriptCfgVisitor(): CfgVisitor { - return { buildFunctionCfg, isFunction }; + return { + buildFunctionCfg, + isFunction, + forFile(root) { + const context = createRefinementFileContext(root); + return { buildFunctionCfg: (fn, path) => buildFunctionCfg(fn, path, context), isFunction }; + }, + }; } export { TS_FUNCTION_TYPES }; diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index c5a87e887..f45f84cf7 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -67,6 +67,7 @@ import { type TaintEmitLimits, } from '../../taint/emit.js'; import { registerBuiltinTaintModels } from '../../taint/typescript-model.js'; +import { createMemoizedRefinement } from '../../taint/guard-refinement.js'; import { getSourceSinkConfig } from '../../taint/source-sink-registry.js'; import { buildFunctionNodeIndex, @@ -1796,6 +1797,9 @@ export function runScopeResolution( kills: 0, dropped: 0, hopsTruncated: 0, + guardFunctions: 0, + guardFallbacks: 0, + guardVisits: 0, gapExamples: [] as string[], dropExamples: [] as string[], }; @@ -1878,6 +1882,11 @@ export function runScopeResolution( // per (limits) bucket instead of 3–4× (#2227 tri-review). File-scoped: it // is re-created each iteration, so its per-function facts drop with the file. const rdSolve = createMemoizedReachingDefs(); + const refine = createMemoizedRefinement((result) => { + if (result.guards.status === 'computed') taintTotals.guardFunctions++; + if (result.guards.status === 'fallback') taintTotals.guardFallbacks++; + taintTotals.guardVisits += result.guards.visits; + }); const t0 = PROF ? performance.now() : 0; const rd = emitFileReachingDefs( pdgTarget, @@ -1956,6 +1965,7 @@ export function runScopeResolution( (message) => logger.warn(message), // unconditional — R4/R6 rdSolve, taintParsed, + refine, ); if (PROF) taintMs += performance.now() - t1; taintTotals.analyzed += taint.functionsAnalyzed; @@ -1991,6 +2001,7 @@ export function runScopeResolution( : DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION, rdSolve, taintParsed, + refine, ); harvestedSummaries.push(...harvest.summaries); summaryUnresolved += harvest.unresolved; @@ -2028,6 +2039,7 @@ export function runScopeResolution( ? `; taint: ${taintTotals.findings} TAINTED, ${taintTotals.kills} SANITIZES ` + `(${taintTotals.analyzed} function(s) analyzed, ` + `${taintTotals.noMatch} skipped: no source/sink match` + + `; guards: ${taintTotals.guardFunctions} analyzed, ${taintTotals.guardFallbacks} fallback, ${taintTotals.guardVisits} visits` + (taintTotals.hopsTruncated > 0 ? `, ${taintTotals.hopsTruncated} finding(s) with truncated hop paths` : '') + diff --git a/gitnexus/src/core/ingestion/taint/emit.ts b/gitnexus/src/core/ingestion/taint/emit.ts index 4bae5f66a..89409459e 100644 --- a/gitnexus/src/core/ingestion/taint/emit.ts +++ b/gitnexus/src/core/ingestion/taint/emit.ts @@ -64,6 +64,7 @@ */ import type { ParsedFile, ParsedImport } from 'gitnexus-shared'; +import { createFunctionRefinement, type RefinementSolver } from './guard-refinement.js'; import type { KnowledgeGraph } from '../../graph/types.js'; import { generateId } from '../../../lib/utils.js'; import { @@ -154,6 +155,7 @@ const pushExample = (list: string[], anchor: string): void => { * 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[], @@ -166,6 +168,7 @@ export function emitFileTaint( // functions request it, hitting the cache the call-summary harvest warmed. solve: ReachingDefsSolver = computeReachingDefs, parsed?: ParsedFile, + refine: RefinementSolver = createFunctionRefinement, ): TaintEmitResult { const result: TaintEmitResult = { functionsAnalyzed: 0, @@ -215,7 +218,13 @@ export function emitFileTaint( } const defUse = solve(cfg, { maxFacts }); - const flows = computeTaintFlows(cfg, defUse, matches, { maxFindingsPerFunction, maxHops }); + const flows = computeTaintFlows( + cfg, + defUse, + matches, + { maxFindingsPerFunction, maxHops }, + refine(cfg), + ); 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). diff --git a/gitnexus/src/core/ingestion/taint/guard-refinement.ts b/gitnexus/src/core/ingestion/taint/guard-refinement.ts new file mode 100644 index 000000000..d6294dc2d --- /dev/null +++ b/gitnexus/src/core/ingestion/taint/guard-refinement.ts @@ -0,0 +1,413 @@ +import type { FunctionCfg } from '../cfg/types.js'; +import { + applyValueEffect, + MAX_VALUE_BYTES, + MAX_VALUE_NODES, + UNKNOWN_VALUE, + type StatementValueFacts, + type StringRestriction, + type ValueEffect, + type ValueExpression, +} from './value-refinement.js'; + +const MAX_REFINEMENT_VISITS = 50_000; +const MAX_REFINEMENT_WORDS = 65_536; + +/** Normalized worker facts. No AST, executable predicates or model callbacks. */ +export interface GuardFacts { + readonly eligible: boolean; + readonly primitiveBindings: readonly number[]; + readonly predicates: readonly { + readonly block: number; + readonly subject: number; + readonly accepts: boolean; + readonly restriction: StringRestriction; + readonly establishesPrimitive?: boolean; + }[]; +} + +export interface GuardRefinements { + readonly status: 'computed' | 'no-guard' | 'fallback'; + readonly visits: number; + at(binding: number, block: number): StringRestriction | undefined; +} + +export interface FunctionRefinement { + readonly guards: GuardRefinements; + argument( + block: number, + statement: number, + site: number, + argument: number, + inputBinding?: number, + ): ValueEffect | undefined; + defaultShell(block: number, statement: number, site: number): boolean; + isPredicate(block: number, statement: number, site: number): boolean; +} + +/** Run/file-owned context shared by the two consumers; no AST or global cache. */ +export function createFunctionRefinement(cfg: FunctionCfg): FunctionRefinement { + if (cfg.refinementSafe !== true) + return cfg.refinementSafe === false ? FALLBACK_REFINEMENT : NO_REFINEMENT; + if (cfg.blocks.length + cfg.edges.length > MAX_REFINEMENT_WORDS) return FALLBACK_REFINEMENT; + let scanVisits = 0; + const predicates: GuardFacts['predicates'][number][] = []; + const definitions = new Map(); + const written = new Set(); + const calls = new Map[number]>(); + const predicateSites = new Set(); + let factCount = 0; + const validBinding = (binding: unknown): binding is number => + ++scanVisits <= MAX_REFINEMENT_VISITS && + Number.isInteger(binding) && + typeof binding === 'number' && + binding >= 0 && + binding < (cfg.bindings?.length ?? 0); + const validExpression = (expression: ValueExpression): boolean => { + let nodes = MAX_VALUE_NODES; + let bytes = 0; + const visit = (value: ValueExpression): boolean => { + if ( + ++scanVisits > MAX_REFINEMENT_VISITS || + !value || + typeof value !== 'object' || + --nodes < 0 + ) + return false; + if (value.kind === 'unknown' || value.kind === 'non-string') return true; + if (value.kind === 'binding') return validBinding(value.binding); + if (value.kind === 'literal') { + if (typeof value.value !== 'string') return false; + bytes += value.value.length; + return bytes <= MAX_VALUE_BYTES; + } + return ( + value.kind === 'concat' && + Array.isArray(value.parts) && + value.parts.length <= MAX_VALUE_NODES && + value.parts.every(visit) + ); + }; + return visit(expression); + }; + if ( + cfg.primitiveBindings !== undefined && + (!Array.isArray(cfg.primitiveBindings) || + cfg.primitiveBindings.length > MAX_REFINEMENT_WORDS || + !cfg.primitiveBindings.every(validBinding)) + ) + return FALLBACK_REFINEMENT; + for (const block of cfg.blocks) + for (let index = 0; index < (block.statements?.length ?? 0); index++) { + if (++scanVisits > MAX_REFINEMENT_VISITS) return FALLBACK_REFINEMENT; + const statement = block.statements![index]; + if ( + !Array.isArray(statement.defs) || + (statement.mayDefs !== undefined && !Array.isArray(statement.mayDefs)) + ) + return FALLBACK_REFINEMENT; + const shape = statement.refinement; + if ( + shape !== undefined && + (!shape || + typeof shape !== 'object' || + (shape.definitions !== undefined && + (!Array.isArray(shape.definitions) || shape.definitions.length > 256)) || + (shape.calls !== undefined && (!Array.isArray(shape.calls) || shape.calls.length > 256))) + ) + return FALLBACK_REFINEMENT; + if (shape?.predicate) { + const predicate = shape.predicate; + if ( + !validBinding(predicate.subject) || + typeof predicate.accepts !== 'boolean' || + (predicate.establishesPrimitive !== undefined && + predicate.establishesPrimitive !== true) || + applyValueEffect( + { kind: 'restrict', value: { kind: 'input' }, restriction: predicate.restriction }, + UNKNOWN_VALUE, + ).kind !== 'string' + ) + return FALLBACK_REFINEMENT; + if (predicate.site !== undefined) { + if ( + !Number.isInteger(predicate.site) || + statement.sites?.[predicate.site]?.kind !== 'call' + ) + return FALLBACK_REFINEMENT; + predicateSites.add(`${block.index}:${index}:${predicate.site}`); + } + if (predicates.length >= 64) return FALLBACK_REFINEMENT; + predicates.push({ ...predicate, block: block.index }); + } + for (const definition of shape?.definitions ?? []) { + if ( + ++factCount > 256 || + !definition || + !validBinding(definition.binding) || + !validExpression(definition.value) + ) + return FALLBACK_REFINEMENT; + if (definitions.has(definition.binding)) written.add(definition.binding); + else definitions.set(definition.binding, definition.value); + } + for (const bindings of [statement.defs, statement.mayDefs ?? []]) + for (const binding of bindings) { + if (++scanVisits > MAX_REFINEMENT_VISITS) return FALLBACK_REFINEMENT; + const entry = cfg.bindings?.[binding]; + if ( + entry?.kind === 'param' + ? block.index !== cfg.entryIndex + : entry?.kind !== 'const' || entry.declLine !== statement.line + ) + written.add(binding); + } + for (const call of shape?.calls ?? []) { + if ( + ++factCount > 256 || + !call || + !Number.isInteger(call.site) || + !statement.sites?.[call.site] || + !Array.isArray(call.arguments) || + call.arguments.length > MAX_VALUE_NODES || + !call.arguments.every(validExpression) || + (call.defaultShell !== undefined && typeof call.defaultShell !== 'boolean') + ) + return FALLBACK_REFINEMENT; + calls.set(`${block.index}:${index}:${call.site}`, call); + } + } + const guards = computeGuardRefinements(cfg, { + eligible: cfg.refinementSafe === true, + primitiveBindings: cfg.primitiveBindings ?? [], + predicates, + }); + const active = cfg.refinementSafe === true && guards.status !== 'fallback'; + return { + guards, + defaultShell: (block, statement, site) => + active && calls.get(`${block}:${statement}:${site}`)?.defaultShell === true, + isPredicate: (block, statement, site) => + active && predicateSites.has(`${block}:${statement}:${site}`), + argument(block, statement, site, argument, inputBinding) { + const expression = calls.get(`${block}:${statement}:${site}`)?.arguments[argument]; + if (!active || !expression) return; + let remaining = MAX_VALUE_NODES; + const seen = new Set(); + let exhausted = false; + const visit = (value: ValueExpression): ValueEffect => { + if (--remaining < 0) { + exhausted = true; + return { kind: 'unknown' }; + } + if (value.kind === 'literal' || value.kind === 'non-string' || value.kind === 'unknown') + return value; + if (value.kind === 'concat') return { kind: 'concat', parts: value.parts.map(visit) }; + if (value.kind !== 'binding') return { kind: 'unknown' }; + const binding = value.binding; + if (written.has(binding) || seen.has(binding)) return { kind: 'unknown' }; + seen.add(binding); + const definition = definitions.get(binding); + const base: ValueEffect = + binding === inputBinding + ? { kind: 'input' } + : definition + ? visit(definition) + : { kind: 'unknown' }; + seen.delete(binding); + const restriction = guards.at(binding, block); + return restriction ? { kind: 'restrict', value: base, restriction } : base; + }; + const result = visit(expression); + return exhausted ? undefined : result; + }, + }; +} + +const NO_GUARDS: GuardRefinements = Object.freeze({ + status: 'no-guard', + visits: 0, + at: () => undefined, +}); + +const NO_REFINEMENT: FunctionRefinement = Object.freeze({ + guards: NO_GUARDS, + argument: () => undefined, + defaultShell: () => false, + isPredicate: () => false, +}); +const FALLBACK_REFINEMENT: FunctionRefinement = Object.freeze({ + ...NO_REFINEMENT, + guards: { ...NO_GUARDS, status: 'fallback' as const }, +}); + +export type RefinementSolver = (cfg: FunctionCfg) => FunctionRefinement; + +export function createMemoizedRefinement( + onCompute?: (result: FunctionRefinement) => void, +): RefinementSolver { + const cache = new WeakMap(); + return (cfg) => { + let result = cache.get(cfg); + if (!result) { + result = createFunctionRefinement(cfg); + cache.set(cfg, result); + onCompute?.(result); + } + return result; + }; +} + +/** Removing an accepted edge must make the use unreachable from ENTRY. */ +export function computeGuardRefinements( + cfg: FunctionCfg, + facts?: GuardFacts, + limits?: { maxVisits?: number; maxGuards?: number; maxWords?: number }, +): GuardRefinements { + if (!facts?.predicates?.length) return NO_GUARDS; + let visits = 0; + const fallback = (): GuardRefinements => ({ status: 'fallback', visits, at: () => undefined }); + const maxVisits = limits?.maxVisits ?? MAX_REFINEMENT_VISITS; + const maxGuards = limits?.maxGuards ?? 64; + const maxWords = limits?.maxWords ?? MAX_REFINEMENT_WORDS; + const count = cfg.blocks.length; + if ( + facts.eligible !== true || + !Array.isArray(facts.predicates) || + !Array.isArray(facts.primitiveBindings) || + facts.predicates.length > maxGuards || + facts.primitiveBindings.length > maxWords || + count + cfg.edges.length > maxWords || + !Number.isInteger(cfg.entryIndex) || + cfg.entryIndex < 0 || + cfg.entryIndex >= count + ) + return fallback(); + + const successors: number[][] = Array.from({ length: count }, () => []); + for (let i = 0; i < cfg.edges.length; i++) { + const edge = cfg.edges[i]; + if ( + !Number.isInteger(edge.from) || + !Number.isInteger(edge.to) || + edge.from < 0 || + edge.from >= count || + edge.to < 0 || + edge.to >= count + ) + return fallback(); + successors[edge.from].push(i); + } + const reachable = (removed = -1): Uint8Array | undefined => { + const seen = new Uint8Array(count); + const pending = [cfg.entryIndex]; + seen[cfg.entryIndex] = 1; + while (pending.length) { + if (++visits > maxVisits) return; + const block = pending.pop()!; + for (const index of successors[block]) { + if (++visits > maxVisits) return; + if (index === removed) continue; + const to = cfg.edges[index].to; + if (!seen[to]) { + seen[to] = 1; + pending.push(to); + } + } + } + return seen; + }; + const original = reachable(); + if (!original) return fallback(); + const primitives = new Set(facts.primitiveBindings); + const written = new Set(); + const uses = new Map>(); + const subjects = new Set(facts.predicates.map((predicate) => predicate.subject)); + for (const block of cfg.blocks) { + for (const statement of block.statements ?? []) { + if (++visits > maxVisits) return fallback(); + if ( + !Array.isArray(statement.defs) || + !Array.isArray(statement.uses) || + (statement.mayDefs !== undefined && !Array.isArray(statement.mayDefs)) + ) + return fallback(); + for (const bindings of [statement.defs, statement.mayDefs ?? []]) + for (const binding of bindings) { + if (++visits > maxVisits) return fallback(); + if (!subjects.has(binding)) continue; + const declaration = cfg.bindings?.[binding]; + const isInitial = + declaration?.kind === 'param' + ? block.index === cfg.entryIndex + : declaration?.kind === 'const' && statement.line === declaration.declLine; + if (!isInitial) written.add(binding); + } + for (const binding of statement.uses) { + if (++visits > maxVisits) return fallback(); + if (!subjects.has(binding)) continue; + let blocks = uses.get(binding); + if (!blocks) uses.set(binding, (blocks = new Set())); + blocks.add(block.index); + } + } + } + const certificates = new Map>(); + let words = count + cfg.edges.length; + for (const predicate of facts.predicates) { + if ( + !Number.isInteger(predicate.block) || + !Number.isInteger(predicate.subject) || + !original[predicate.block] || + !cfg.bindings?.[predicate.subject] || + written.has(predicate.subject) || + (!primitives.has(predicate.subject) && !predicate.establishesPrimitive) + ) + continue; + const checked = applyValueEffect( + { kind: 'restrict', value: { kind: 'input' }, restriction: predicate.restriction }, + UNKNOWN_VALUE, + ); + if (checked.kind !== 'string') continue; + const edges = successors[predicate.block]; + // An unlabeled seq leg is the false fallthrough only alongside one true edge. + const explicit = edges.filter( + (index) => cfg.edges[index].kind === (predicate.accepts ? 'cond-true' : 'cond-false'), + ); + const accepted = + explicit.length === 1 + ? explicit[0] + : !predicate.accepts && + explicit.length === 0 && + edges.length === 2 && + edges.some((index) => cfg.edges[index].kind === 'cond-true') + ? edges.find((index) => cfg.edges[index].kind === 'seq') + : undefined; + if (accepted === undefined) continue; + const bypass = reachable(accepted); + if (!bypass) return fallback(); + for (const block of uses.get(predicate.subject) ?? []) { + if (++visits > maxVisits || ++words > maxWords) return fallback(); + if (!original[block] || bypass[block]) continue; + let points = certificates.get(predicate.subject); + if (!points) certificates.set(predicate.subject, (points = new Map())); + const previous = points.get(block); + const restriction = predicate.restriction; + if (!previous) points.set(block, restriction); + else { + const chars = [...new Set(previous.chars)] + .filter((char) => restriction.chars.includes(char)) + .sort() + .join(''); + const min = Math.max(previous.min, restriction.min); + const max = Math.min(previous.max, restriction.max); + if (chars && min <= max) points.set(block, { chars, min, max }); + } + } + } + return { + status: 'computed', + visits, + at: (binding, block) => certificates.get(binding)?.get(block), + }; +} diff --git a/gitnexus/src/core/ingestion/taint/interproc-solver.ts b/gitnexus/src/core/ingestion/taint/interproc-solver.ts index 50d49c4b6..c5659d39d 100644 --- a/gitnexus/src/core/ingestion/taint/interproc-solver.ts +++ b/gitnexus/src/core/ingestion/taint/interproc-solver.ts @@ -51,6 +51,13 @@ import type { SinkKind } from './source-sink-config.js'; import type { FunctionSummary } from './summary-model.js'; +import { + applyValueEffect, + joinRefinedValues, + provesInertCommand, + UNKNOWN_VALUE, + type RefinedValue, +} from './value-refinement.js'; /** * One resolved call edge from the `CALLS` graph. The join to a summary's @@ -86,6 +93,8 @@ export interface InterprocFinding { } export interface InterprocLimits { + /** Internal proof-payload budget; exhaustion restores baseline taint. */ + readonly maxRefinementBytes?: number; /** Max functions in a single flow's hop chain. `undefined`/0 ⇒ default 32. */ readonly maxHops?: number; /** Max findings overall (post-dedup). `undefined`/0 ⇒ unlimited. */ @@ -93,6 +102,7 @@ export interface InterprocLimits { } export interface InterprocResult { + readonly refinementFallback?: boolean; readonly findings: readonly InterprocFinding[]; /** Findings dropped by `maxFindings` (post-dedup). */ readonly droppedFindings: number; @@ -113,6 +123,7 @@ export const DEFAULT_PDG_MAX_INTERPROC_FINDINGS = 2000; /** A tainted parameter, with the flow that first tainted it (for path reconstruction). */ interface TaintedParam { + readonly value?: RefinedValue; readonly fnId: string; readonly paramIndex: number; readonly sourceFnId: string; @@ -205,6 +216,19 @@ export function solveInterprocTaint( const findingsByKey = new Map(); const tainted = new Map(); const queue: TaintedParam[] = []; + let refinementEnabled = true; + // Queue entries retain earlier values after map replacement, so charge cumulatively. + let refinementBytes = 0; + const refinementBudget = limits?.maxRefinementBytes ?? 64 * 1024 * 1024; + const valueBytes = (value: RefinedValue | undefined): number => + value?.kind === 'string' + ? 32 + + value.parts.reduce( + (total, part) => + total + (typeof part === 'string' ? part.length * 2 : part.chars.length * 2 + 32), + 0, + ) + : 0; const recordFinding = ( sourceFnId: string, @@ -225,6 +249,16 @@ export function solveInterprocTaint( for (const ps of summary.paramToSink) { if (ps.param !== tp.paramIndex) continue; if (tp.neutralized.has(ps.sinkKind)) continue; // sanitised across the boundary (P1-2) + if ( + refinementEnabled && + ps.sinkKind === 'command-injection' && + ps.inertCommandPrefixes && + provesInertCommand( + applyValueEffect(ps.valueEffect, tp.value ?? UNKNOWN_VALUE), + ps.inertCommandPrefixes, + ) + ) + continue; // `tp.hops` already terminates at this (tainted) function — it IS the // source→sink chain, no extra hop to append. recordFinding(tp.sourceFnId, tp.fnId, ps.sinkKind, tp.hops, tp.truncated); @@ -241,20 +275,44 @@ export function solveInterprocTaint( */ const taint = (tp: TaintedParam): void => { const key = pkey(tp.fnId, tp.paramIndex, tp.sourceFnId); - const existing = tainted.get(key); + let existing = tainted.get(key); + let value = refinementEnabled + ? existing + ? joinRefinedValues(existing.value ?? UNKNOWN_VALUE, tp.value ?? UNKNOWN_VALUE) + : (tp.value ?? UNKNOWN_VALUE) + : UNKNOWN_VALUE; + if (refinementEnabled && refinementBytes + valueBytes(value) > refinementBudget) { + refinementEnabled = false; + refinementBytes = 0; + for (const [priorKey, prior] of tainted) { + const widened = { ...prior, value: UNKNOWN_VALUE }; + tainted.set(priorKey, widened); + queue.push(widened); + fireSinks(widened); + } + existing = tainted.get(key); + value = UNKNOWN_VALUE; + } if (existing) { const inter = new Set(); for (const k of existing.neutralized) if (tp.neutralized.has(k)) inter.add(k); - if (inter.size >= existing.neutralized.size) return; // no shrink — cycle-safe - const merged: TaintedParam = { ...existing, neutralized: inter }; + if ( + inter.size >= existing.neutralized.size && + JSON.stringify(value) === JSON.stringify(existing.value ?? UNKNOWN_VALUE) + ) + return; + refinementBytes += valueBytes(value); + const merged: TaintedParam = { ...existing, neutralized: inter, value }; tainted.set(key, merged); queue.push(merged); fireSinks(merged); return; } - tainted.set(key, tp); - queue.push(tp); - fireSinks(tp); + const added = { ...tp, value }; + refinementBytes += valueBytes(value); + tainted.set(key, added); + queue.push(added); + fireSinks(added); }; // ── seeds: every source→callee-arg, resolved against CALLS ──────────────── @@ -278,6 +336,9 @@ export function solveInterprocTaint( hops: seed.hops, truncated: seed.truncated, neutralized: new Set(sc.neutralized ?? []), + value: refinementEnabled + ? applyValueEffect(sc.valueEffect, UNKNOWN_VALUE) + : UNKNOWN_VALUE, }); } } @@ -379,6 +440,9 @@ export function solveInterprocTaint( hops: next.hops, truncated: tp.truncated || next.truncated, neutralized, + value: refinementEnabled + ? applyValueEffect(pc.valueEffect, tp.value ?? UNKNOWN_VALUE) + : UNKNOWN_VALUE, }); } } @@ -395,6 +459,7 @@ export function solveInterprocTaint( const findings = all.length > maxFindings ? all.slice(0, maxFindings) : all; return { + ...(!refinementEnabled ? { refinementFallback: true } : {}), findings, droppedFindings: all.length - findings.length, unmatchedCallSites, diff --git a/gitnexus/src/core/ingestion/taint/propagate.ts b/gitnexus/src/core/ingestion/taint/propagate.ts index 36f093480..a4212b74a 100644 --- a/gitnexus/src/core/ingestion/taint/propagate.ts +++ b/gitnexus/src/core/ingestion/taint/propagate.ts @@ -96,6 +96,8 @@ * bypassed sanitizer (`exec(x + escape(x))`) killed nothing. */ +import { createFunctionRefinement, type FunctionRefinement } from './guard-refinement.js'; +import { applyValueEffect, provesInertCommand, UNKNOWN_VALUE } from './value-refinement.js'; 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'; @@ -294,11 +296,13 @@ interface TaintState { * 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, + refinement: FunctionRefinement = createFunctionRefinement(cfg), ): FunctionTaintResult { if (defUse.status !== 'computed') { return { @@ -311,6 +315,22 @@ export function computeTaintFlows( } const bindings = defUse.bindings; + const guardProvesSink = ( + block: number, + statement: number, + site: number, + argument: number, + prefixes: readonly string[] | undefined, + ): boolean => + argument === 0 && + prefixes !== undefined && + refinement.defaultShell(block, statement, site) && + cfg.blocks[block]?.statements?.[statement]?.sites?.[site]?.spread === undefined && + !cfg.blocks[block]?.statements?.[statement]?.sites?.[site]?.template && + provesInertCommand( + applyValueEffect(refinement.argument(block, statement, site, argument), UNKNOWN_VALUE), + prefixes, + ); // ── per-statement context (built lazily; statements revisit often) ──────── const matchByPoint = new Map(); @@ -766,6 +786,17 @@ export function computeTaintFlows( for (const sink of ctx.sinksBySite.get(siteIndex) ?? []) { if (!sink.argPositions.includes(argPos)) continue; const kind = sink.entry.kind; + if ( + kind === 'command-injection' && + guardProvesSink( + ctx.point.blockIndex, + ctx.point.stmtIndex, + siteIndex, + argPos, + sink.entry.inertCommandPrefixes, + ) + ) + continue; if (!sofar.kinds.has(kind)) { recordFinding( kind, @@ -847,6 +878,17 @@ export function computeTaintFlows( for (const sink of sinks) { const kind = sink.entry.kind; for (const argPos of sink.argPositions) { + if ( + kind === 'command-injection' && + guardProvesSink( + ctx.point.blockIndex, + ctx.point.stmtIndex, + siteIndex, + argPos, + sink.entry.inertCommandPrefixes, + ) + ) + continue; const paths = pathsIntoPosition(ctx, b, siteIndex, argPos); if (paths.length === 0) continue; if (E.has(kind)) continue; // suppressed at def time; kill already recorded diff --git a/gitnexus/src/core/ingestion/taint/source-sink-config.ts b/gitnexus/src/core/ingestion/taint/source-sink-config.ts index d7ca28b25..606e66459 100644 --- a/gitnexus/src/core/ingestion/taint/source-sink-config.ts +++ b/gitnexus/src/core/ingestion/taint/source-sink-config.ts @@ -66,6 +66,8 @@ export interface TaintCallableMatcher { * names (`res.send` / `res.write`); exactly `.`, name-based. */ export interface TaintSinkEntry extends TaintCallableMatcher { + /** Reviewed fixed-operation prefixes with one inert data-word argument. */ + readonly inertCommandPrefixes?: readonly string[]; readonly module?: string; readonly global?: boolean; /** Only meaningful with `global`: match `new (…)` sites only. */ diff --git a/gitnexus/src/core/ingestion/taint/summary-harvest-driver.ts b/gitnexus/src/core/ingestion/taint/summary-harvest-driver.ts index 816649489..cb20808be 100644 --- a/gitnexus/src/core/ingestion/taint/summary-harvest-driver.ts +++ b/gitnexus/src/core/ingestion/taint/summary-harvest-driver.ts @@ -26,6 +26,7 @@ * remain unresolved rather than attaching a summary to a different function. */ +import { createFunctionRefinement, type RefinementSolver } from './guard-refinement.js'; import type { ParsedFile, ParsedImport, GraphNode } from 'gitnexus-shared'; import type { KnowledgeGraph } from '../../graph/types.js'; import { computeReachingDefs, type ReachingDefsSolver } from '../cfg/reaching-defs.js'; @@ -124,6 +125,7 @@ export interface FileSummaryResult { * `isEmitSafeCfg`-filtered (the same `wellFormed` array fed to `emitFileTaint`). * Pure aside from the read-only graph lookup; never throws on valid input. */ + export function harvestFileSummaries( fnIndex: FunctionNodeIndex, cfgs: readonly FunctionCfg[], @@ -133,6 +135,7 @@ export function harvestFileSummaries( // U12: shared per-file memoized solver (harvest/taint bucket — no maxBlockVisits). solve: ReachingDefsSolver = computeReachingDefs, parsed?: ParsedFile, + refine: RefinementSolver = createFunctionRefinement, ): FileSummaryResult { const importIndex = buildTaintImportIndex(parsedImports, parsed); const summaries: FunctionSummary[] = []; @@ -147,7 +150,7 @@ export function harvestFileSummaries( } const defUse = solve(cfg, { maxFacts }); const matches = matchFunctionSites(cfg, spec, importIndex); - const harvested = harvestFunctionSummary(cfg, defUse, matches); + const harvested = harvestFunctionSummary(cfg, defUse, matches, refine(cfg)); if (harvested.status !== 'computed') { gaps++; continue; diff --git a/gitnexus/src/core/ingestion/taint/summary-harvest.ts b/gitnexus/src/core/ingestion/taint/summary-harvest.ts index 51e2da5f5..33be8eb25 100644 --- a/gitnexus/src/core/ingestion/taint/summary-harvest.ts +++ b/gitnexus/src/core/ingestion/taint/summary-harvest.ts @@ -63,7 +63,9 @@ import type { FunctionCfg, SiteRecord } from '../cfg/types.js'; import { pointKey, type FunctionDefUse, type ProgramPoint } from '../cfg/reaching-defs.js'; -import type { FunctionSiteMatches } from './match.js'; +import type { FunctionSiteMatches, MatchedSinkCall } from './match.js'; +import { createFunctionRefinement, type FunctionRefinement } from './guard-refinement.js'; +import { valueEffectKey } from './value-refinement.js'; import { sinkKindRank, sortSinkKinds, type SinkKind } from './source-sink-config.js'; import type { CallResult, @@ -129,6 +131,7 @@ export function harvestFunctionSummary( cfg: FunctionCfg, defUse: FunctionDefUse, matches: FunctionSiteMatches, + refinement: FunctionRefinement = createFunctionRefinement(cfg), ): HarvestResult { if (defUse.status !== 'computed') { return { status: 'coverage-gap', gapReason: defUse.status, facts: EMPTY_FACTS }; @@ -144,6 +147,25 @@ export function harvestFunctionSummary( const paramIndexOf = new Map(); paramBindings.forEach((e, paramIdx) => paramIndexOf.set(e.idx, paramIdx)); const paramCount = paramBindings.length; + // Legacy flattened ports cannot carry a new proof across an ambiguous formal. + const precisePorts = paramBindings.every((entry, index) => entry.b.formalIndex === index); + const effectAt = ( + block: number, + statement: number, + site: number, + argument: number, + param?: number, + ) => { + if (!precisePorts) return undefined; + const effect = refinement.argument( + block, + statement, + site, + argument, + param === undefined ? undefined : paramBindings[param]?.idx, + ); + return effect?.kind === 'unknown' ? undefined : effect; + }; // ── return points: source block of every `return` CFG edge ──────────────── // The M2 edge-kind invariant: a `return` edge's SOURCE block terminates in @@ -166,8 +188,13 @@ export function harvestFunctionSummary( // call-result seed (#2084 review P1-1): their result semantics are already // modelled (a sanitizer's result rides U2 exclusions; a sink returns void). const modeledSites = new Set(); + const matchedSinks = new Map>(); for (const sm of matches.statements) { const stmtKey = `${sm.blockIndex}:${sm.statementIndex}`; + const siteSinks = new Map(); + for (const sink of sm.sinks) + siteSinks.set(sink.siteIndex, [...(siteSinks.get(sink.siteIndex) ?? []), sink]); + matchedSinks.set(stmtKey, siteSinks); for (const s of sm.sinks) modeledSites.add(`${stmtKey}:${s.siteIndex}`); for (const s of sm.sanitizers) modeledSites.add(`${stmtKey}:${s.siteIndex}`); if (sm.sinks.length > 0) { @@ -433,6 +460,7 @@ export function harvestFunctionSummary( const sinkBySite = sinkPosBySite.get(useKey); const kindBySite = sinkKindByEntry.get(useKey); useStmt.sites?.forEach((site, siteIndex) => { + if (refinement.isPredicate(fact.use.blockIndex, fact.use.stmtIndex, siteIndex)) return; const argHits = occurrencesInArgs(site, b); for (const argPos of argHits) { const callLine = useStmt.line; @@ -445,13 +473,21 @@ export function harvestFunctionSummary( dest: { to: 'callArg', ...(tail ? { toCallee: tail } : {}), argIndex: argPos }, }); } else if (t.seedId >= 0) { - const caKey = `${t.seedId}:${callLine}:${argPos}:${tail ?? ''}`; + const valueEffect = effectAt( + fact.use.blockIndex, + fact.use.stmtIndex, + siteIndex, + argPos, + t.seedId, + ); + const caKey = `${t.seedId}:${callLine}:${argPos}:${tail ?? ''}:${valueEffectKey(valueEffect)}`; if (!paramCallArg.has(caKey)) { paramCallArg.set(caKey, { param: t.seedId, callLine, argIndex: argPos, ...(tail ? { calleeName: tail } : {}), + ...(valueEffect ? { valueEffect } : {}), }); } // Carry the sanitizer exclusions on the path INTO this call arg, @@ -461,13 +497,20 @@ export function harvestFunctionSummary( // Source-seeded: a generated source flowing into a call argument is // a fixpoint SEED (it taints the callee's param). One source kind // today ('remote-input'); when more exist the seed must carry it. - const scKey = `${callLine}:${argPos}:${tail ?? ''}`; + const valueEffect = effectAt( + fact.use.blockIndex, + fact.use.stmtIndex, + siteIndex, + argPos, + ); + const scKey = `${callLine}:${argPos}:${tail ?? ''}:${valueEffectKey(valueEffect)}`; if (!sourceCallArg.has(scKey)) { sourceCallArg.set(scKey, { sourceKind: 'remote-input', callLine, argIndex: argPos, ...(tail ? { calleeName: tail } : {}), + ...(valueEffect ? { valueEffect } : {}), }); } intersectKinds(sourceCallArgKinds, scKey, t.exclusions); @@ -484,10 +527,37 @@ export function harvestFunctionSummary( dest: { to: 'sink', sinkKind: kind }, }); } else if (t.seedId >= 0) { - const sKey = `${t.seedId}:${kind}`; + const sinkEntries = (matchedSinks.get(useKey)?.get(siteIndex) ?? []).filter( + (sink) => sink.entry.kind === kind && sink.argPositions.includes(argPos), + ); + const canRefine = + kind === 'command-injection' && + argPos === 0 && + site.spread === undefined && + !site.template && + refinement.defaultShell(fact.use.blockIndex, fact.use.stmtIndex, siteIndex) && + sinkEntries.length > 0 && + sinkEntries.every((sink) => sink.entry.inertCommandPrefixes); + const prefixes = canRefine + ? sinkEntries[0].entry.inertCommandPrefixes!.filter((prefix) => + sinkEntries.every((sink) => + sink.entry.inertCommandPrefixes!.includes(prefix), + ), + ) + : undefined; + const valueEffect = prefixes?.length + ? effectAt(fact.use.blockIndex, fact.use.stmtIndex, siteIndex, argPos, t.seedId) + : undefined; + const sKey = `${t.seedId}:${kind}:${valueEffectKey(valueEffect)}:${JSON.stringify(prefixes ?? [])}`; if (!paramSink.has(sKey)) { paramSink.add(sKey); - paramSinkOut.push({ param: t.seedId, sinkKind: kind }); + paramSinkOut.push({ + param: t.seedId, + sinkKind: kind, + ...(valueEffect && prefixes?.length + ? { valueEffect, inertCommandPrefixes: prefixes } + : {}), + }); } } } @@ -539,7 +609,10 @@ export function harvestFunctionSummary( ); const paramToSink = paramSinkOut.sort( - (a, b) => a.param - b.param || sinkKindRank(a.sinkKind) - sinkKindRank(b.sinkKind), + (a, b) => + a.param - b.param || + sinkKindRank(a.sinkKind) - sinkKindRank(b.sinkKind) || + valueEffectKey(a.valueEffect).localeCompare(valueEffectKey(b.valueEffect)), ); const sourceToReturn: SourceToReturn[] = diff --git a/gitnexus/src/core/ingestion/taint/summary-model.ts b/gitnexus/src/core/ingestion/taint/summary-model.ts index 386d3a9b9..cd92a7c29 100644 --- a/gitnexus/src/core/ingestion/taint/summary-model.ts +++ b/gitnexus/src/core/ingestion/taint/summary-model.ts @@ -34,6 +34,7 @@ */ import type { SinkKind, SourceKind } from './source-sink-config.js'; +import { valueEffectKey, type ValueEffect } from './value-refinement.js'; /** * Source-relative parameter index (0-based, in declaration order). A @@ -61,6 +62,7 @@ export type ParamIndex = number; * callees of one caller, never as the primary join key. */ export interface ParamToCallArg { + readonly valueEffect?: ValueEffect; readonly param: ParamIndex; readonly callLine: number; readonly argIndex: number; @@ -96,6 +98,8 @@ export interface ParamToReturn { /** `param i` reaches a modelled sink of kind `sinkKind` inside this function. */ export interface ParamToSink { + readonly valueEffect?: ValueEffect; + readonly inertCommandPrefixes?: readonly string[]; readonly param: ParamIndex; readonly sinkKind: SinkKind; } @@ -140,6 +144,7 @@ export interface CallResult { * procedural `source → sink` partial flow whose sink lives in the callee. */ export interface SourceToCallArg { + readonly valueEffect?: ValueEffect; readonly sourceKind: SourceKind; /** Carried for hop display + same-name tie-break; NOT the join key (see * {@link ParamToCallArg} — the solver joins by `calleeName`). */ @@ -227,17 +232,24 @@ export function ownFactsDigest( ...s.paramToCallArg .map( (c) => - `c:${c.param}:${c.callLine}:${c.argIndex}:${c.calleeName ?? ''}:${[...(c.neutralized ?? [])].sort().join(',')}`, + `c:${c.param}:${c.callLine}:${c.argIndex}:${c.calleeName ?? ''}:${[...(c.neutralized ?? [])].sort().join(',')}${c.valueEffect ? ':' + valueEffectKey(c.valueEffect) : ''}`, + ) + .sort(), + ); + parts.push( + ...s.paramToSink + .map( + (k) => + `k:${k.param}:${k.sinkKind}${k.valueEffect ? ':' + valueEffectKey(k.valueEffect) : ''}${k.inertCommandPrefixes ? ':' + JSON.stringify([...k.inertCommandPrefixes].sort()) : ''}`, ) .sort(), ); - parts.push(...s.paramToSink.map((k) => `k:${k.param}:${k.sinkKind}`).sort()); parts.push(...s.sourceToReturn.map((g) => `g:${g.sourceKind}`).sort()); parts.push( ...s.sourceToCallArg .map( (g) => - `s:${g.sourceKind}:${g.callLine}:${g.argIndex}:${g.calleeName ?? ''}:${[...(g.neutralized ?? [])].sort().join(',')}`, + `s:${g.sourceKind}:${g.callLine}:${g.argIndex}:${g.calleeName ?? ''}:${[...(g.neutralized ?? [])].sort().join(',')}${g.valueEffect ? ':' + valueEffectKey(g.valueEffect) : ''}`, ) .sort(), ); diff --git a/gitnexus/src/core/ingestion/taint/typescript-model.ts b/gitnexus/src/core/ingestion/taint/typescript-model.ts index 6ef39cca9..0a0cbd9d7 100644 --- a/gitnexus/src/core/ingestion/taint/typescript-model.ts +++ b/gitnexus/src/core/ingestion/taint/typescript-model.ts @@ -40,8 +40,20 @@ export const TS_JS_TAINT_MODEL: SourceSinkSanitizerSpec = { sinks: [ // Command execution — shell strings are arg 0; argv-form APIs also treat // the argv array at arg 1 as command/option injection surface. - { name: 'exec', kind: 'command-injection', args: [0], module: 'child_process' }, - { name: 'execSync', kind: 'command-injection', args: [0], module: 'child_process' }, + { + name: 'exec', + kind: 'command-injection', + args: [0], + module: 'child_process', + inertCommandPrefixes: ['git log -1 ', 'git log -1 --format=%s ', 'git show '], + }, + { + name: 'execSync', + kind: 'command-injection', + args: [0], + module: 'child_process', + inertCommandPrefixes: ['git log -1 ', 'git log -1 --format=%s ', 'git show '], + }, { name: 'spawn', kind: 'command-injection', args: [0, 1], module: 'child_process' }, { name: 'spawnSync', kind: 'command-injection', args: [0, 1], module: 'child_process' }, { name: 'execFile', kind: 'command-injection', args: [0, 1], module: 'child_process' }, diff --git a/gitnexus/src/core/ingestion/taint/value-refinement.ts b/gitnexus/src/core/ingestion/taint/value-refinement.ts new file mode 100644 index 000000000..ae1201d1c --- /dev/null +++ b/gitnexus/src/core/ingestion/taint/value-refinement.ts @@ -0,0 +1,245 @@ +/** A deliberately closed string domain. Unknown always preserves ordinary taint. */ +export interface StringRestriction { + readonly chars: string; + readonly min: number; + readonly max: number; +} + +/** Small, provider-normalized expression vocabulary; absent shape stays unknown. */ +export type ValueExpression = + | { readonly kind: 'unknown' | 'non-string' } + | { readonly kind: 'binding'; readonly binding: number } + | { readonly kind: 'literal'; readonly value: string } + | { readonly kind: 'concat'; readonly parts: readonly ValueExpression[] }; + +export interface StatementValueFacts { + readonly predicate?: { + readonly site?: number; + readonly subject: number; + readonly accepts: boolean; + readonly restriction: StringRestriction; + readonly establishesPrimitive?: boolean; + }; + readonly definitions?: readonly { readonly binding: number; readonly value: ValueExpression }[]; + readonly calls?: readonly { + readonly site: number; + readonly arguments: readonly ValueExpression[]; + readonly defaultShell?: boolean; + }[]; +} + +export type ValueEffect = + | { readonly kind: 'unknown' | 'input' | 'non-string' } + | { readonly kind: 'literal'; readonly value: string } + | { readonly kind: 'concat'; readonly parts: readonly ValueEffect[] } + | { + readonly kind: 'restrict'; + readonly value: ValueEffect; + readonly restriction: StringRestriction; + }; + +export type RefinedValue = + | { readonly kind: 'unknown' | 'non-string' } + | { readonly kind: 'string'; readonly parts: readonly (string | StringRestriction)[] }; + +export const UNKNOWN_VALUE: RefinedValue = Object.freeze({ kind: 'unknown' }); +export const MAX_VALUE_NODES = 32; +export const MAX_VALUE_BYTES = 256; + +function validRestriction(value: StringRestriction): boolean { + return ( + typeof value?.chars === 'string' && + value.chars.length > 0 && + value.chars.length <= 128 && + [...value.chars].every((c) => c.charCodeAt(0) < 128) && + Number.isInteger(value.min) && + Number.isInteger(value.max) && + value.min >= 0 && + value.max >= value.min && + value.max <= MAX_VALUE_BYTES + ); +} + +/** Parse a linear, fully anchored ASCII subset; never execute a repository regex. */ +export function parseAnchoredAsciiPattern( + pattern: string, + flags: string, +): StringRestriction | undefined { + if ( + flags !== '' || + pattern.length > MAX_VALUE_BYTES || + !pattern.startsWith('^') || + !pattern.endsWith('$') + ) + return; + const body = pattern.slice(1, -1); + let index = 0; + let min = 0; + let max = 0; + const alphabet = new Set(); + while (index < body.length) { + const chars: string[] = []; + if (body[index] === '[') { + const end = body.indexOf(']', index + 1); + if (end < 0) return; + const raw = body.slice(index + 1, end); + if (!raw || /[^A-Za-z0-9-]/.test(raw)) return; + for (let i = 0; i < raw.length; i++) { + if (raw[i] === '-') return; + if (raw[i + 1] === '-') { + const first = raw.charCodeAt(i); + const last = raw.charCodeAt(i + 2); + if (!Number.isFinite(last) || last < first || last - first > 127) return; + for (let c = first; c <= last; c++) chars.push(String.fromCharCode(c)); + i += 2; + } else chars.push(raw[i]); + } + index = end + 1; + } else { + const char = body[index++]; + if (!/[A-Za-z0-9]/.test(char)) return; + chars.push(char); + } + let lo = 1; + let hi = 1; + if (body[index] === '{') { + const end = body.indexOf('}', index); + if (end < 0) return; + const match = /^(\d{1,3})(?:,(\d{1,3}))?$/.exec(body.slice(index + 1, end)); + if (!match) return; + lo = Number(match[1]); + hi = Number(match[2] ?? match[1]); + if (hi < lo || hi > MAX_VALUE_BYTES) return; + index = end + 1; + } + min += lo; + max += hi; + if (max > MAX_VALUE_BYTES) return; + for (const char of chars) alphabet.add(char); + } + if (alphabet.size === 0 || min === 0) return; + return { chars: [...alphabet].sort().join(''), min, max }; +} + +/** A bounded transfer. Missing, malformed, oversized and incompatible effects are unknown. */ +export function applyValueEffect( + effect: ValueEffect | undefined, + input: RefinedValue, +): RefinedValue { + // A contradicted primitive contract must not revive at a later typed boundary. + if (input.kind === 'non-string') return input; + let remaining = MAX_VALUE_NODES; + let exhausted = false; + const visit = (node: ValueEffect | undefined): RefinedValue => { + if (!node) return UNKNOWN_VALUE; + if (--remaining < 0) { + exhausted = true; + return UNKNOWN_VALUE; + } + switch (node.kind) { + case 'input': + return input; + case 'non-string': + return { kind: 'non-string' }; + case 'unknown': + return UNKNOWN_VALUE; + case 'literal': + return typeof node.value === 'string' && node.value.length <= MAX_VALUE_BYTES + ? { kind: 'string', parts: [node.value] } + : UNKNOWN_VALUE; + case 'restrict': { + if (!validRestriction(node.restriction)) return UNKNOWN_VALUE; + const prior = visit(node.value); + return prior.kind === 'non-string' ? prior : { kind: 'string', parts: [node.restriction] }; + } + case 'concat': { + if (!Array.isArray(node.parts) || node.parts.length > MAX_VALUE_NODES) return UNKNOWN_VALUE; + const parts: (string | StringRestriction)[] = []; + let bytes = 0; + for (const part of node.parts) { + const value = visit(part); + if (value.kind !== 'string') return value; + for (const segment of value.parts) { + bytes += typeof segment === 'string' ? segment.length : segment.max; + if (bytes > MAX_VALUE_BYTES || parts.length >= MAX_VALUE_NODES) return UNKNOWN_VALUE; + if (typeof segment === 'string' && typeof parts.at(-1) === 'string') { + parts[parts.length - 1] = String(parts.at(-1)) + segment; + } else parts.push(segment); + } + } + return { kind: 'string', parts }; + } + default: + return UNKNOWN_VALUE; + } + }; + const result = visit(effect); + return exhausted ? UNKNOWN_VALUE : result; +} + +/** Guarantees weaken across routes. Different shapes widen immediately (including recursion). */ +export function joinRefinedValues(left: RefinedValue, right: RefinedValue): RefinedValue { + if (left.kind === 'non-string') return left; + if (right.kind === 'non-string') return right; + if (left.kind !== 'string' || right.kind !== 'string') { + return UNKNOWN_VALUE; + } + if (left.parts.length !== right.parts.length) return UNKNOWN_VALUE; + const parts: (string | StringRestriction)[] = []; + for (let i = 0; i < left.parts.length; i++) { + const a = left.parts[i]; + const b = right.parts[i]; + if (typeof a === 'string' || typeof b === 'string') { + if (a !== b) return UNKNOWN_VALUE; + parts.push(a); + } else { + parts.push({ + chars: [...new Set(a.chars + b.chars)].sort().join(''), + min: Math.min(a.min, b.min), + max: Math.max(a.max, b.max), + }); + } + } + return { kind: 'string', parts }; +} + +/** Operation/argument roles, not a blanket executable exemption. */ +export function provesInertCommand(value: RefinedValue, prefixes: readonly string[]): boolean { + if (value.kind !== 'string' || value.parts.length !== 2) return false; + const [prefix, argument] = value.parts; + return ( + typeof prefix === 'string' && + prefixes.includes(prefix) && + typeof argument !== 'string' && + validRestriction(argument) && + argument.min > 0 && + // ASCII letters/digits cannot introduce a shell operator, whitespace or option. + /^[A-Za-z0-9]+$/.test(argument.chars) + ); +} + +/** Canonical bounded identity, independent of JSON property insertion order. */ +export function valueEffectKey(effect: ValueEffect | undefined): string { + if (!effect) return ''; + let remaining = MAX_VALUE_NODES; + const encode = (value: ValueEffect): unknown => { + if (--remaining < 0) return '?'; + switch (value.kind) { + case 'literal': + return ['literal', value.value]; + case 'concat': + return ['concat', ...value.parts.map(encode)]; + case 'restrict': + return [ + 'restrict', + encode(value.value), + [...new Set(value.restriction.chars)].sort().join(''), + value.restriction.min, + value.restriction.max, + ]; + default: + return [value.kind]; + } + }; + return JSON.stringify(encode(effect)); +} diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 9b697f79a..02fb39326 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -32,6 +32,7 @@ import { getLbugDbIdentity, } from '../../core/lbug/pool-adapter.js'; import { queryClassBeanMetadata } from './bean-metadata.js'; +import { GUARD_TAINT_LIMITATION } from '../taint-limitations.js'; import { querySpringAopMetadata } from './aop-metadata.js'; import { SYMBOL_IDENTITY_RECOVERY_SUGGESTION, @@ -6270,7 +6271,10 @@ export class LocalBackend { findings: pageFindings, totalFindings: totalFindings + interprocTotal, ...(truncated ? { truncated: true } : {}), - note: 'Intra-procedural (TAINTED, statement hops) AND cross-function (TAINT_PATH, function hops, `interprocedural: true`) flows are modeled, including sources read inside inline callbacks and returned handlers. Captured closure-variable, property/field, and implicit flows are NOT modeled; absence of a finding is not proof of safety. Cross-function findings are context-insensitive and may over-attribute among same-named callees. All line numbers are 1-based; for cross-function findings, `source.line`, `sink.line` and `hops[].line` are the declaration lines of the functions, not statement lines. SANITIZES (kill) edges are queryable via cypher.', + note: + 'Intra-procedural (TAINTED, statement hops) AND cross-function (TAINT_PATH, function hops, `interprocedural: true`) flows are modeled, including sources read inside inline callbacks and returned handlers. Captured closure-variable, property/field, and implicit flows are NOT modeled; absence of a finding is not proof of safety. ' + + GUARD_TAINT_LIMITATION + + ' Cross-function findings are context-insensitive and may over-attribute among same-named callees. All line numbers are 1-based; for cross-function findings, `source.line`, `sink.line` and `hops[].line` are the declaration lines of the functions, not statement lines. SANITIZES (kill) edges are queryable via cypher.', }; } diff --git a/gitnexus/src/mcp/taint-limitations.ts b/gitnexus/src/mcp/taint-limitations.ts new file mode 100644 index 000000000..0b31fcd5f --- /dev/null +++ b/gitnexus/src/mcp/taint-limitations.ts @@ -0,0 +1,2 @@ +export const GUARD_TAINT_LIMITATION = + 'Modeled guards can refine unchanged primitive-string values for reviewed sink uses. Imported and wrapper validators, reassigned values, and unsupported predicates or sink uses remain conservative findings. Declared string types are analysis assumptions, not runtime validation.'; diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index 4efd83295..9d30cf4ab 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -7,6 +7,7 @@ import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js'; import { REL_TYPES } from 'gitnexus-shared'; +import { GUARD_TAINT_LIMITATION } from './taint-limitations.js'; import { CONTEXT_RELATION_TYPES, CONTEXT_RESPONSE_SECTIONS, @@ -751,7 +752,8 @@ CONTRACT CAVEATS (absent flows are NOT proof of safety): - Cross-function matching is by callee NAME (context-insensitive): when one caller invokes two distinct same-named callees, a flow into one over-attributes to both — a cross-function finding does not prove the taint reached every same-named function (sound over-report, never a missed flow). - Sources read inside inline callbacks and returned handlers can flow through helper functions. Captured closure-variable flows are not modeled (e.g. const y = source(); 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. +- ${GUARD_TAINT_LIMITATION} +- Implicit/control-dependence flows are not modeled. - CommonJS aliasing is partially modeled (require('') joins resolve; dynamic requires do not). - Exception-path over-approximation can produce false-positive noise. diff --git a/gitnexus/test/helpers/cfg-harness.ts b/gitnexus/test/helpers/cfg-harness.ts index eb7b5c04c..9c894626c 100644 --- a/gitnexus/test/helpers/cfg-harness.ts +++ b/gitnexus/test/helpers/cfg-harness.ts @@ -53,17 +53,22 @@ export function makeCfgHarness( }; const cfgOf = (code: string, index = 0): FunctionCfg => { - const fn = collectFunctions(parse(code))[index]; + const root = parse(code); + const bound = visitor.forFile?.(root, filePath) ?? visitor; + const fn = collectFunctions(root)[index]; if (!fn) throw new Error(`no function at index ${index}`); - const cfg = visitor.buildFunctionCfg(fn, filePath); + const cfg = bound.buildFunctionCfg(fn, filePath); if (!cfg) throw new Error('buildFunctionCfg returned undefined'); return cfg; }; - const cfgsOf = (code: string): FunctionCfg[] => - collectFunctions(parse(code)) - .map((fn) => visitor.buildFunctionCfg(fn, filePath)) + const cfgsOf = (code: string): FunctionCfg[] => { + const root = parse(code); + const bound = visitor.forFile?.(root, filePath) ?? visitor; + return collectFunctions(root) + .map((fn) => bound.buildFunctionCfg(fn, filePath)) .filter((c): c is FunctionCfg => c !== undefined); + }; return { parse, collectFunctions, cfgOf, cfgsOf }; } diff --git a/gitnexus/test/integration/cfg/guard-taint.test.ts b/gitnexus/test/integration/cfg/guard-taint.test.ts new file mode 100644 index 000000000..385991b3a --- /dev/null +++ b/gitnexus/test/integration/cfg/guard-taint.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { runPipelineFromRepo } from '../../../src/core/ingestion/pipeline.js'; +import { createTempDirPool } from '../../helpers/temp-dir-pool.js'; + +const repos = createTempDirPool('gn-guard-taint-'); + +describe('guard-aware command taint (#3490)', () => { + it('retains findings for bypasses, mutation, uncertain types and unsupported sink uses', async () => { + const cases: Record = { + bypass: + 'const value: string = req.query.ref; if (flag) { if (!/^[0-9a-f]{7,40}$/.test(value)) return; } execSync("git show " + value);', + reassigned: + 'let value: string = req.query.ref; if (!/^[0-9a-f]{7,40}$/.test(value)) return; value = req.query.other; execSync("git show " + value);', + wrongValue: + 'const value: string = req.query.ref; const other: string = "abcdef0"; if (!/^[0-9a-f]{7,40}$/.test(other)) return; execSync("git show " + value);', + lateGuard: + 'const value: string = req.query.ref; execSync("git show " + value); if (!/^[0-9a-f]{7,40}$/.test(value)) return;', + partialPattern: + 'const value: string = req.query.ref; if (!/^[0-9a-f]{7,40}/.test(value)) return; execSync("git show " + value);', + multiline: + 'const value: string = req.query.ref; if (!/^[0-9a-f]{7,40}$/m.test(value)) return; execSync("git show " + value);', + customShell: + 'const value: string = req.query.ref; if (!/^[0-9a-f]{7,40}$/.test(value)) return; execSync("git show " + value, {shell: "custom-shell"});', + interpreter: + 'const value: string = req.query.ref; if (!/^[0-9a-f]{7,40}$/.test(value)) return; execSync("bash -c " + value);', + executable: + 'const value: string = req.query.ref; if (!/^[0-9a-f]{7,40}$/.test(value)) return; spawn(value, []);', + extraInput: + 'const value: string = req.query.ref; if (!/^[0-9a-f]{7,40}$/.test(value)) return; execSync("git show " + value + req.query.other);', + caught: + 'const value: string = req.query.ref; try { if (!/^[0-9a-f]{7,40}$/.test(value)) throw new Error(); } catch {} execSync("git show " + value);', + unknownObject: + 'const value: any = req.query.ref; if (!/^[0-9a-f]{7,40}$/.test(value)) return; execSync("git show " + value);', + suspended: + 'const value: string = req.query.ref; if (!/^[0-9a-f]{7,40}$/.test(value)) return; await pending(); execSync("git show " + value);', + }; + const repo = repos.dir(); + fs.writeFileSync( + path.join(repo, 'unsafe.ts'), + 'import {execSync, spawn} from "node:child_process";\n' + + Object.entries(cases) + .map( + ([name, body]) => + `export ${name === 'suspended' ? 'async ' : ''}function ${name}(req: any, flag: boolean) { ${body} }`, + ) + .join('\n') + + String.raw` +function escapedValue(value: string, req: any) { + if (!/^[0-9a-f]{7,40}$/.test(value)) return; + \u0076alue = req.query.other; + execSync('git show ' + value); +} +export function handleEscaped(req: any) { escapedValue(req.query.ref, req); } +`, + ); + const result = await runPipelineFromRepo(repo, () => {}, { pdg: true, workerPoolSize: 1 }); + const crossSources = [...result.graph.iterRelationships()] + .filter((edge) => edge.type === 'TAINT_PATH') + .map((edge) => result.graph.getNode(edge.sourceId)?.properties.name); + expect(crossSources).toContain('handleEscaped'); + const sourceLines = [...result.graph.iterRelationships()] + .filter((edge) => edge.type === 'TAINTED') + .map((edge) => Number(result.graph.getNode(edge.sourceId)?.properties.startLine)); + for (const [index, name] of Object.keys(cases).entries()) { + expect(sourceLines, `missing conservative finding for ${name}`).toContain(index + 2); + } + }, 120_000); + + it('preserves a validated value across command construction without hiding unsafe siblings', async () => { + const repo = repos.dir(); + fs.writeFileSync( + path.join(repo, 'commands.ts'), + `import { execSync } from 'node:child_process'; +const SHA_RE = /^[0-9a-f]{7,40}$/; +function runGit(args: string) { return execSync('git ' + args); } +function commitSubject(ref: string) { + if (!SHA_RE.test(ref)) throw new Error('invalid sha'); + return runGit('log -1 --format=%s ' + ref).trim(); +} +function showRevision(ref: string) { + if (!/^[0-9a-f]{7,40}$/.test(ref)) return; + return runGit('show ' + ref); +} +export function handleGuarded(req: any) { return commitSubject(req.query.ref as string); } +export function handleShow(req: any) { return showRevision(req.query.ref as string); } +export function handleUnsafe(req: any) { return runGit('blame ' + req.query.ref); } +export function handleInterpreter(req: any) { + const value: string = req.query.ref; + if (!SHA_RE.test(value)) return; + execSync('bash -c ' + value); +} +export function handleLocal(req: any) { + const ref: string = req.query.ref; + if (!SHA_RE.test(ref)) return; + execSync('git log -1 ' + ref); +} +export function handleWrongBranch(req: any) { + const ref: string = req.query.ref; + if (SHA_RE.test(ref)) return; + execSync('git log -1 ' + ref); +} +export function handleLiteral(req: any) { + const ref = req.query.ref; + if (ref !== 'abcdef0') return; + execSync('git show ' + ref); +} +`, + ); + const result = await runPipelineFromRepo(repo, () => {}, { pdg: true, workerPoolSize: 1 }); + const edges = [...result.graph.iterRelationships()]; + const crossFunctionSources = edges + .filter((edge) => edge.type === 'TAINT_PATH') + .map((edge) => result.graph.getNode(edge.sourceId)?.properties.name); + expect(crossFunctionSources).toContain('handleUnsafe'); + expect(crossFunctionSources).not.toContain('handleGuarded'); + expect(crossFunctionSources).not.toContain('handleShow'); + const localFindings = edges.filter((edge) => edge.type === 'TAINTED'); + const localSourceLines = localFindings.map( + (edge) => result.graph.getNode(edge.sourceId)?.properties.startLine, + ); + expect([...new Set(localSourceLines)].sort((a, b) => Number(a) - Number(b))).toEqual([16, 26]); + }, 120_000); +}); diff --git a/gitnexus/test/integration/taint-explain.test.ts b/gitnexus/test/integration/taint-explain.test.ts index 95768990f..09f7078bd 100644 --- a/gitnexus/test/integration/taint-explain.test.ts +++ b/gitnexus/test/integration/taint-explain.test.ts @@ -156,6 +156,8 @@ withTestLbugDB( expect(vuln.pathIncomplete).toBeUndefined(); // The intra-procedural contract caveat reaches the consumer. expect(result.note).toMatch(/intra-procedural/i); + expect(result.note).toContain('Imported and wrapper validators'); + expect(result.note).toContain('not runtime validation'); }); it('anchorless enumerate is deterministic across calls', async () => { diff --git a/gitnexus/test/unit/cfg/typescript-refinement.test.ts b/gitnexus/test/unit/cfg/typescript-refinement.test.ts new file mode 100644 index 000000000..8545cf2d2 --- /dev/null +++ b/gitnexus/test/unit/cfg/typescript-refinement.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'vitest'; +import { parse } from '../../helpers/ts-cfg-harness.js'; +import { + createRefinementFileContext, + readPredicate, + readValueExpression, +} from '../../../src/core/ingestion/cfg/visitors/typescript-refinement.js'; +import type { SyntaxNode } from '../../../src/core/ingestion/utils/ast-helpers.js'; + +import { collectFunctionCfgs } from '../../../src/core/ingestion/cfg/collect.js'; +import { createTypeScriptCfgVisitor } from '../../../src/core/ingestion/cfg/visitors/typescript.js'; +import { createFunctionRefinement } from '../../../src/core/ingestion/taint/guard-refinement.js'; +import { + applyValueEffect, + provesInertCommand, + UNKNOWN_VALUE, +} from '../../../src/core/ingestion/taint/value-refinement.js'; + +function find(root: SyntaxNode, type: string): SyntaxNode { + if (root.type === type) return root; + for (const child of root.namedChildren) { + try { + return find(child, type); + } catch { + /* next subtree */ + } + } + throw new Error(`No ${type}`); +} + +function predicate(source: string) { + const root = parse(source); + return readPredicate( + find(root, 'if_statement').childForFieldName('condition')!, + () => 0, + createRefinementFileContext(root), + ); +} + +describe('normalized predicate and expression facts', () => { + it('preserves guard and value identity through embedded coordinate mappings', () => { + const root = parse(`const SHA_RE = /^[0-9a-f]{7,40}$/; +function f(ref: string) { + if (!SHA_RE.test(ref)) return; + consume('git show ' + ref); +}`); + for (const mapLine of [undefined, (row: number) => row + 12]) { + const { cfgs } = collectFunctionCfgs( + root, + createTypeScriptCfgVisitor(), + 'Component.vue', + 0, + 12, + mapLine, + ); + expect(cfgs).toHaveLength(1); + const cfg = cfgs[0]; + expect(cfg.functionStartLine).toBe(14); + const refinement = createFunctionRefinement(cfg); + const values = cfg.blocks.flatMap((block) => + (block.statements ?? []).flatMap((statement, index) => + (statement.sites ?? []).flatMap((site, siteIndex) => + site.callee === 'consume' + ? [ + applyValueEffect( + refinement.argument(block.index, index, siteIndex, 0), + UNKNOWN_VALUE, + ), + ] + : [], + ), + ), + ); + expect(values).toHaveLength(1); + expect(provesInertCommand(values[0], ['git show '])).toBe(true); + } + }); + + it('resolves the immutable module validator and its accepted polarity', () => { + const fact = predicate( + 'const SHA_RE = /^[0-9a-f]{7,40}$/; function f(ref: string) { if (!SHA_RE.test(ref)) return; }', + ); + expect(fact).toMatchObject({ subject: 0, accepts: false, restriction: { min: 7, max: 40 } }); + }); + + it('recognizes an inline regex validator without treating it as a named callee', () => { + expect( + predicate('function f(ref: string) { if (!/^[0-9a-f]{7,40}$/.test(ref)) return; }'), + ).toMatchObject({ subject: 0, accepts: false, restriction: { min: 7, max: 40 } }); + }); + + it.each([ + 'const SHA_RE = /^[0-9a-f]{7,40}$/m; function f(ref: string) { if (!SHA_RE.test(ref)) return; }', + 'const SHA_RE = /^[0-9a-f]{7,40}$/; SHA_RE.test = other; function f(ref: string) { if (!SHA_RE.test(ref)) return; }', + 'const SHA_RE = /^[0-9a-f]{7,40}$/; function f(SHA_RE: any, ref: string) { if (!SHA_RE.test(ref)) return; }', + 'const SHA_RE = /^[0-9a-f]{7,40}$/; export { SHA_RE }; function f(ref: string) { if (!SHA_RE.test(ref)) return; }', + 'import { SHA_RE } from "./patterns"; function f(ref: string) { if (!SHA_RE.test(ref)) return; }', + '/x/.constructor.prototype.test = () => true; function f(ref: string) { if (!/^[0-9a-f]{7,40}$/.test(ref)) return; }', + 'const OTHER = /x/; OTHER.constructor.prototype.test = () => true; function f(ref: string) { if (!/^[0-9a-f]{7,40}$/.test(ref)) return; }', + 'const SHA_RE = /^[0-9a-f]{7,40}$/; mutate(SHA_RE); function f(ref: string) { if (!/^[0-9a-f]{7,40}$/.test(ref)) return; }', + String.raw`\u0067lobalThis.RegExp.prototype.test = () => true; function f(ref: string) { if (!/^[0-9a-f]{7,40}$/.test(ref)) return; }`, + 'globalThis.RegExp.prototype.test = () => true; function f(ref: string) { if (!/^[0-9a-f]{7,40}$/.test(ref)) return; }', + 'eval("RegExp.prototype.test = () => true"); function f(ref: string) { if (!/^[0-9a-f]{7,40}$/.test(ref)) return; }', + ])('retains unknown validator identity, mutation and unsupported flags', (source) => { + expect(predicate(source)).toBeUndefined(); + }); + + it('strict literal equality establishes a primitive without a cast assumption', () => { + expect(predicate('function f(ref: any) { if (ref !== "abcdef0") return; }')).toMatchObject({ + subject: 0, + accepts: false, + establishesPrimitive: true, + }); + }); + + it('normalizes exact literal concatenation and rejects unknown transformations', () => { + const expression = find(parse('const value = "git " + args;'), 'binary_expression'); + expect(readValueExpression(expression, () => 2)).toEqual({ + kind: 'concat', + parts: [ + { kind: 'literal', value: 'git ' }, + { kind: 'binding', binding: 2 }, + ], + }); + expect( + readValueExpression(find(parse('transform(args);'), 'call_expression'), () => 2), + ).toEqual({ kind: 'unknown' }); + expect( + readValueExpression(find(parse('new Wrapper(args);'), 'new_expression'), () => 2), + ).toEqual({ kind: 'non-string' }); + }); +}); diff --git a/gitnexus/test/unit/parse-cache-identity.test.ts b/gitnexus/test/unit/parse-cache-identity.test.ts index 2138afd5e..13df2bf98 100644 --- a/gitnexus/test/unit/parse-cache-identity.test.ts +++ b/gitnexus/test/unit/parse-cache-identity.test.ts @@ -39,6 +39,8 @@ describe('parse cache artifact identity', () => { 'src/core/ingestion/languages/new-provider.ts', 'src/core/ingestion/preprocess.ts', 'src/core/ingestion/workers/helper.ts', + 'src/core/ingestion/taint/guard-refinement.ts', + 'src/core/ingestion/taint/value-refinement.ts', 'src/core/ingestion/languages/cobol/flow-source.ts', 'node_modules/gitnexus-shared/src/index.ts', 'node_modules/gitnexus-shared/dist/index.js', diff --git a/gitnexus/test/unit/parsedfile-store.test.ts b/gitnexus/test/unit/parsedfile-store.test.ts index d7a1279f9..10bb3838b 100644 --- a/gitnexus/test/unit/parsedfile-store.test.ts +++ b/gitnexus/test/unit/parsedfile-store.test.ts @@ -6,6 +6,7 @@ import { tmpdir } from 'os'; import path from 'path'; import type { ParsedFile } from 'gitnexus-shared'; import { TEST_PARSE_IDENTITY } from '../helpers/cache-identity.js'; +import { cfgsOf } from '../helpers/ts-cfg-harness.js'; import { clearParsedFileStore, persistParsedFileChunk, @@ -76,6 +77,27 @@ function makeStoreEntry(filePath: string, overrides: Record): P } describe('parsedfile-store', () => { + it('preserves normalized guard and value facts through the disk seal', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'pfstore-guard-')); + try { + const cfgs = cfgsOf(`function guarded(ref: string) { + if (!/^[0-9a-f]{7,40}$/.test(ref)) return; + consume('git show ' + ref); + }`); + expect(cfgs[0].refinementSafe).toBe(true); + expect( + cfgs[0].blocks.some((block) => + block.statements?.some((statement) => statement.refinement?.predicate), + ), + ).toBe(true); + const file: ParsedFile = { ...makeParsedFile('guard.ts'), cfgSideChannel: cfgs }; + await persistParsedFileChunk(dir, 'guard-facts', [file]); + const loaded = await loadParsedFilesForPaths(dir, new Set(['guard.ts'])); + expect(loaded.get('guard.ts')?.cfgSideChannel).toEqual(cfgs); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); it('round-trips ParsedFiles (incl. Scope Maps) and filters by requested paths', async () => { const dir = await mkdtemp(path.join(tmpdir(), 'pfstore-')); try { diff --git a/gitnexus/test/unit/taint/disk-scope.test.ts b/gitnexus/test/unit/taint/disk-scope.test.ts index b220cc28e..fdb476791 100644 --- a/gitnexus/test/unit/taint/disk-scope.test.ts +++ b/gitnexus/test/unit/taint/disk-scope.test.ts @@ -96,7 +96,12 @@ describe('taint emission after the disk scope seal', () => { expect(sinks(1)).toEqual([]); expect(sinks(2)).toEqual([ { param: 0, sinkKind: 'command-injection' }, - { param: 1, sinkKind: 'command-injection' }, + { + param: 1, + sinkKind: 'command-injection', + valueEffect: { kind: 'input' }, + inertCommandPrefixes: ['git log -1 ', 'git log -1 --format=%s ', 'git show '], + }, ]); expect(sinks(3)).toEqual([]); }); diff --git a/gitnexus/test/unit/taint/guard-refinement.test.ts b/gitnexus/test/unit/taint/guard-refinement.test.ts new file mode 100644 index 000000000..e21f4e945 --- /dev/null +++ b/gitnexus/test/unit/taint/guard-refinement.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from 'vitest'; +import { cfgOf, parse } from '../../helpers/ts-cfg-harness.js'; +import { + computeGuardRefinements, + createFunctionRefinement, + type GuardFacts, +} from '../../../src/core/ingestion/taint/guard-refinement.js'; + +function fixture(body = 'if (!valid(x)) return; consume(x);') { + const cfg = cfgOf(`function f(x: string) { ${body} }`); + const subject = cfg.bindings!.findIndex((binding) => binding.name === 'x'); + const condition = cfg.blocks.find((block) => block.text.includes('valid(x)'))!.index; + const sink = cfg.blocks.find((block) => block.text.includes('consume(x)'))!.index; + const facts: GuardFacts = { + eligible: true, + primitiveBindings: [subject], + predicates: [ + { + block: condition, + subject, + accepts: false, + restriction: { chars: 'abcdef0123456789', min: 7, max: 40 }, + }, + ], + }; + return { cfg, subject, sink, facts }; +} + +describe('accepted-edge guard proof', () => { + it('discards malformed optional call facts without breaking baseline analysis', () => { + const cfg = cfgOf( + 'function f(x: string) { if (!/^[0-9a-f]{7,40}$/.test(x)) return; consume("git show " + x); }', + ); + const malformed = { + ...cfg, + blocks: cfg.blocks.map((block) => ({ + ...block, + statements: block.statements?.map((statement) => ({ + ...statement, + ...(statement.refinement?.calls + ? { + refinement: { + ...statement.refinement, + calls: statement.refinement.calls.map((call) => ({ ...call, site: 9999 })), + }, + } + : {}), + })), + })), + }; + const refinement = createFunctionRefinement(malformed); + expect(refinement.argument(4, 0, 0, 0)).toBeUndefined(); + expect(refinement.isPredicate(2, 0, 0)).toBe(false); + expect( + createFunctionRefinement({ ...cfg, refinementSafe: undefined }).argument(4, 0, 0, 0), + ).toBeUndefined(); + }); + it('charges large statement operand lists against the traversal budget', () => { + const { cfg, facts } = fixture(); + const oversized = { + ...cfg, + blocks: cfg.blocks.map((block) => ({ + ...block, + statements: block.statements?.map((statement) => ({ + ...statement, + uses: Array(1000).fill(0), + })), + })), + }; + const proof = computeGuardRefinements(oversized, facts, { maxVisits: 128 }); + expect(proof.status).toBe('fallback'); + expect(proof.visits).toBeLessThanOrEqual(129); + }); + + it.each(['if (!/^[0-9a-f]{7,40}$/.test(ref)) return;', 'if (ref !== "abcdef0") return;'])( + 'rejects a proof when an escaped spelling can hide a write: %s', + (guard) => { + const source = + 'function f(ref:string) {' + + guard + + String.raw` \u0072ef = input(); consume('git show ' + ref); }`; + expect(parse(source).hasError).toBe(false); + const cfg = cfgOf(source); + expect(cfg.refinementSafe).toBe(false); + expect(createFunctionRefinement(cfg).guards.status).toBe('fallback'); + }, + ); + + it('certifies only uses dominated by the accepted edge', () => { + const { cfg, facts, subject, sink } = fixture(); + const proof = computeGuardRefinements(cfg, facts); + expect(proof.status).toBe('computed'); + expect(proof.at(subject, sink)?.min).toBe(7); + expect(proof.at(subject, cfg.entryIndex)).toBeUndefined(); + }); + + it('does not borrow validation from the rejected edge', () => { + const { cfg, facts, subject, sink } = fixture('if (valid(x)) return; consume(x);'); + const predicates = facts.predicates.map((predicate) => ({ ...predicate, accepts: true })); + expect( + computeGuardRefinements(cfg, { ...facts, predicates }).at(subject, sink), + ).toBeUndefined(); + }); + + it('rejects a guard with a bypass path', () => { + const { cfg, facts, subject, sink } = fixture( + 'if (flag) { if (!valid(x)) return; } consume(x);', + ); + expect(computeGuardRefinements(cfg, facts).at(subject, sink)).toBeUndefined(); + }); + + it('does not certify reassigned parameters or unknown primitive identity', () => { + const { cfg, facts, subject, sink } = fixture( + 'if (!valid(x)) return; x = input(); consume(x);', + ); + expect(computeGuardRefinements(cfg, facts).at(subject, sink)).toBeUndefined(); + expect( + computeGuardRefinements(cfg, { ...facts, primitiveBindings: [] }).at(subject, sink), + ).toBeUndefined(); + }); + + it('retains no proof after a traversal budget failure or unsupported region', () => { + const { cfg, facts, subject, sink } = fixture(); + const capped = computeGuardRefinements(cfg, facts, { maxVisits: 1 }); + expect(capped.status).toBe('fallback'); + expect(capped.at(subject, sink)).toBeUndefined(); + expect( + computeGuardRefinements(cfg, { ...facts, eligible: false }).at(subject, sink), + ).toBeUndefined(); + }); +}); diff --git a/gitnexus/test/unit/taint/interproc-solver.test.ts b/gitnexus/test/unit/taint/interproc-solver.test.ts index 47e15ab2a..34591fccd 100644 --- a/gitnexus/test/unit/taint/interproc-solver.test.ts +++ b/gitnexus/test/unit/taint/interproc-solver.test.ts @@ -42,6 +42,144 @@ function summary( const map = (...ss: FunctionSummary[]) => new Map(ss.map((s) => [s.fnId, s])); +describe('guard value composition', () => { + it('caps retained worklist payload across weakening alternatives', () => { + const source = summary('source', { + paramCount: 0, + sourceToCallArg: ['a', 'b', 'c'].map((chars) => ({ + sourceKind: 'remote-input', + callLine: 1, + argIndex: 0, + calleeName: 'sink', + valueEffect: { + kind: 'concat', + parts: [ + { kind: 'literal', value: 'git show ' }, + { kind: 'restrict', value: { kind: 'input' }, restriction: { chars, min: 1, max: 1 } }, + ], + }, + })), + }); + const sink = summary('sink', { + paramToSink: [ + { + param: 0, + sinkKind: 'command-injection', + inertCommandPrefixes: ['git show '], + valueEffect: { kind: 'input' }, + }, + ], + }); + const result = solveInterprocTaint( + map(source, sink), + [{ callerId: source.fnId, calleeId: sink.fnId, calleeName: 'sink' }], + { maxRefinementBytes: 150 }, + ); + expect(result.refinementFallback).toBe(true); + expect(result.findings).toHaveLength(1); + }); + + const safeValue = { + kind: 'concat', + parts: [ + { kind: 'literal', value: 'git show ' }, + { + kind: 'restrict', + value: { kind: 'input' }, + restriction: { chars: '0123456789abcdef', min: 7, max: 40 }, + }, + ], + } as const; + const source = (id: string, unsafe = false) => + summary(id, { + paramCount: 0, + sourceToCallArg: [ + { + sourceKind: 'remote-input', + callLine: 1, + argIndex: 0, + calleeName: 'sink', + valueEffect: safeValue, + }, + ...(unsafe + ? [{ sourceKind: 'remote-input' as const, callLine: 2, argIndex: 0, calleeName: 'sink' }] + : []), + ], + }); + const sink = () => + summary('sink', { + paramToSink: [ + { + param: 0, + sinkKind: 'command-injection', + valueEffect: { kind: 'input' }, + inertCommandPrefixes: ['git show '], + }, + ], + }); + + it('suppresses only the source whose complete command meets the sink contract', () => { + const safe = source('safe'); + const unsafe = summary('unsafe', { + sourceToCallArg: [ + { sourceKind: 'remote-input', callLine: 1, argIndex: 0, calleeName: 'sink' }, + ], + }); + const result = solveInterprocTaint(map(safe, unsafe, sink()), [ + { callerId: 'safe', calleeId: 'sink', calleeName: 'sink' }, + { callerId: 'unsafe', calleeId: 'sink', calleeName: 'sink' }, + ]); + expect(result.findings.map((finding) => finding.sourceFnId)).toEqual(['unsafe']); + }); + + it('keeps a mixed safe/unsafe route from the same source reported', () => { + const mixed = source('mixed', true); + const result = solveInterprocTaint(map(mixed, sink()), [ + { callerId: 'mixed', calleeId: 'sink', calleeName: 'sink' }, + ]); + expect(result.findings).toHaveLength(1); + }); + + it('reconsiders earlier clean states when the global proof budget is exhausted', () => { + const result = solveInterprocTaint( + map(source('first'), source('second'), sink()), + [ + { callerId: 'first', calleeId: 'sink', calleeName: 'sink' }, + { callerId: 'second', calleeId: 'sink', calleeName: 'sink' }, + ], + { maxRefinementBytes: 150 }, + ); + expect(result.refinementFallback).toBe(true); + expect(result.findings.map((finding) => finding.sourceFnId)).toEqual(['first', 'second']); + }); + + it('widens recursive command growth instead of enumerating paths', () => { + const recursive = sink(); + const result = solveInterprocTaint( + map(source('source'), { + ...recursive, + paramToCallArg: [ + { + param: 0, + callLine: 3, + argIndex: 0, + calleeName: 'sink', + valueEffect: { + kind: 'concat', + parts: [{ kind: 'literal', value: 'again ' }, { kind: 'input' }], + }, + }, + ], + }), + [ + { callerId: 'source', calleeId: 'sink', calleeName: 'sink' }, + { callerId: 'sink', calleeId: 'sink', calleeName: 'sink' }, + ], + ); + expect(result.findings).toHaveLength(1); + }); +}); + describe('solveInterprocTaint — seed path respects maxHops (#2084 review P2-7)', () => { it('caps the seed path at maxHops:1 (truncated prefix, not a 2-entry path)', () => { const A = summary('Function:a.ts:A', { diff --git a/gitnexus/test/unit/taint/lexical-imports.test.ts b/gitnexus/test/unit/taint/lexical-imports.test.ts index fdd6c61a8..606478863 100644 --- a/gitnexus/test/unit/taint/lexical-imports.test.ts +++ b/gitnexus/test/unit/taint/lexical-imports.test.ts @@ -276,7 +276,12 @@ function unrelated(req, value) { expect(harvested.gaps).toBe(0); expect( harvested.summaries.find((summary) => summary.fnId === 'function:0')?.paramToSink, - ).toContainEqual({ param: 1, sinkKind: 'command-injection' }); + ).toContainEqual({ + param: 1, + sinkKind: 'command-injection', + valueEffect: { kind: 'input' }, + inertCommandPrefixes: ['git log -1 ', 'git log -1 --format=%s ', 'git show '], + }); expect( harvested.summaries.find((summary) => summary.fnId === 'function:1')?.paramToSink, ).toEqual([]); diff --git a/gitnexus/test/unit/taint/summary-model.test.ts b/gitnexus/test/unit/taint/summary-model.test.ts index 7ceda74d9..e6a6ba3c2 100644 --- a/gitnexus/test/unit/taint/summary-model.test.ts +++ b/gitnexus/test/unit/taint/summary-model.test.ts @@ -28,6 +28,31 @@ const baseFacts: Facts = { }; describe('ownFactsDigest', () => { + it('includes value effects and sink-use contracts in summary identity', () => { + const restricted: Facts = { + ...baseFacts, + paramToSink: [ + { + param: 0, + sinkKind: 'command-injection', + valueEffect: { kind: 'input' }, + inertCommandPrefixes: ['git show ', 'git log -1 '], + }, + ], + }; + const other: Facts = { + ...restricted, + paramToSink: [{ ...restricted.paramToSink[0], valueEffect: { kind: 'unknown' } }], + }; + const reordered: Facts = { + ...restricted, + paramToSink: [ + { ...restricted.paramToSink[0], inertCommandPrefixes: ['git log -1 ', 'git show '] }, + ], + }; + expect(ownFactsDigest(restricted)).not.toBe(ownFactsDigest(other)); + expect(ownFactsDigest(restricted)).toBe(ownFactsDigest(reordered)); + }); it('is stable for identical facts', () => { expect(ownFactsDigest(baseFacts)).toBe(ownFactsDigest({ ...baseFacts })); }); diff --git a/gitnexus/test/unit/taint/value-refinement.test.ts b/gitnexus/test/unit/taint/value-refinement.test.ts new file mode 100644 index 000000000..3f99854b8 --- /dev/null +++ b/gitnexus/test/unit/taint/value-refinement.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest'; +import { + applyValueEffect, + joinRefinedValues, + parseAnchoredAsciiPattern, + provesInertCommand as evaluateCommand, + UNKNOWN_VALUE, + type ValueEffect, + type RefinedValue, +} from '../../../src/core/ingestion/taint/value-refinement.js'; + +const provesInertCommand = (value: RefinedValue) => + evaluateCommand(value, ['git log -1 ', 'git log -1 --format=%s ', 'git show ']); + +describe('bounded value refinements', () => { + const restriction = { chars: '0123456789abcdef', min: 7, max: 40 }; + const validated: ValueEffect = { kind: 'restrict', value: { kind: 'input' }, restriction }; + const prefix = (text: string, value: ValueEffect): ValueEffect => ({ + kind: 'concat', + parts: [{ kind: 'literal', value: text }, value], + }); + + it('composes validator and literal construction across function boundaries', () => { + const args = applyValueEffect(prefix('log -1 ', validated), UNKNOWN_VALUE); + const command = applyValueEffect(prefix('git ', { kind: 'input' }), args); + expect(provesInertCommand(command)).toBe(true); + expect( + provesInertCommand( + applyValueEffect(prefix('git log -1 --format=%s ', validated), UNKNOWN_VALUE), + ), + ).toBe(true); + expect( + provesInertCommand(applyValueEffect(prefix('git show ', validated), UNKNOWN_VALUE)), + ).toBe(true); + }); + + it.each(['bash -c ', 'git -c alias.foo=!sh foo ', 'git ', 'env '])( + 'does not certify executable or interpreter selection: %s', + (text) => + expect(provesInertCommand(applyValueEffect(prefix(text, validated), UNKNOWN_VALUE))).toBe( + false, + ), + ); + + it('keeps an independent unknown contribution unsafe', () => { + const effect: ValueEffect = { + kind: 'concat', + parts: [prefix('git log -1 ', validated), { kind: 'unknown' }], + }; + expect(provesInertCommand(applyValueEffect(effect, UNKNOWN_VALUE))).toBe(false); + }); + + it('weakens safe/unsafe joins and recursive template growth to unknown', () => { + const safe = applyValueEffect(prefix('git show ', validated), UNKNOWN_VALUE); + expect(joinRefinedValues(safe, UNKNOWN_VALUE)).toEqual(UNKNOWN_VALUE); + const growing = applyValueEffect(prefix('again ', { kind: 'input' }), safe); + expect(joinRefinedValues(safe, growing)).toEqual(UNKNOWN_VALUE); + }); + + it('does not turn a known incompatible object into a primitive proof', () => { + const incompatible: RefinedValue = { kind: 'non-string' }; + const first = applyValueEffect(validated, incompatible); + expect(first).toEqual(incompatible); + for (const value of [first, joinRefinedValues(incompatible, UNKNOWN_VALUE)]) { + const second = applyValueEffect(prefix('git show ', validated), value); + expect(provesInertCommand(second)).toBe(false); + } + expect(joinRefinedValues(UNKNOWN_VALUE, incompatible)).toEqual(incompatible); + expect(applyValueEffect(prefix('git show ', { kind: 'input' }), incompatible)).toEqual( + incompatible, + ); + }); + + it('discards all proof when an expression exceeds its budget', () => { + let effect: ValueEffect = validated; + for (let i = 0; i < 40; i++) effect = { kind: 'restrict', value: effect, restriction }; + expect(applyValueEffect(effect, UNKNOWN_VALUE)).toEqual(UNKNOWN_VALUE); + }); + + it('parses only the bounded, fully anchored ASCII predicate subset', () => { + expect(parseAnchoredAsciiPattern('^[0-9a-f]{7,40}$', '')).toEqual(restriction); + expect(parseAnchoredAsciiPattern('^[a-z][0-9]{2}$', '')).toEqual({ + chars: '0123456789abcdefghijklmnopqrstuvwxyz', + min: 3, + max: 3, + }); + for (const [pattern, flags] of [ + ['[0-9a-f]{7,40}', ''], + ['^[0-9a-f]+$', ''], + ['^[^;]+$', ''], + ['^[0-9a-f]{7,40}$', 'g'], + ['^[0-9a-f]{7,40}$', 'm'], + ['^(a+)+$', ''], + ['^[a-z]{1,999999999}$', ''], + ]) + expect(parseAnchoredAsciiPattern(pattern, flags)).toBeUndefined(); + }); +}); diff --git a/gitnexus/test/unit/tools.test.ts b/gitnexus/test/unit/tools.test.ts index 1439ebcda..6467a6004 100644 --- a/gitnexus/test/unit/tools.test.ts +++ b/gitnexus/test/unit/tools.test.ts @@ -489,7 +489,14 @@ describe('GITNEXUS_TOOLS', () => { 'sources read inside inline callbacks and returned handlers can flow through helper functions', ); expect(d.toLowerCase()).toContain('property/field'); - expect(d.toLowerCase()).toContain('guard-style'); + expect(d.toLowerCase()).toContain( + 'modeled guards can refine unchanged primitive-string values', + ); + expect(d.toLowerCase()).toContain('imported and wrapper validators'); + expect(d.toLowerCase()).toContain( + 'unsupported predicates or sink uses remain conservative findings', + ); + expect(d.toLowerCase()).toContain('declared string types are analysis assumptions'); expect(d.toLowerCase()).toContain('cross-function'); expect(d.toLowerCase()).toContain('commonjs'); expect(d.toLowerCase()).toContain('exception');