mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-11-11 08:37:35 +00:00
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
This commit is contained in:
parent
f3fa67b144
commit
7b4c7b33bd
36 changed files with 2328 additions and 59 deletions
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": []
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<TNode = unknown> {
|
||||
/** File-bound worker context, released after collection. Existing providers need no hook. */
|
||||
forFile?(root: TNode, filePath: string): CfgVisitor<TNode>;
|
||||
buildFunctionCfg(fnNode: TNode, filePath: string): FunctionCfg | undefined;
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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<string, number>;
|
||||
}
|
||||
|
||||
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<number>;
|
||||
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<number, number>;
|
||||
definitions?: NonNullable<StatementValueFacts['definitions']>[number][];
|
||||
calls?: NonNullable<StatementValueFacts['calls']>[number][];
|
||||
};
|
||||
private readonly bindings: BindingEntry[] = [];
|
||||
/** Scope-opening node id → its scope. */
|
||||
private readonly scopeByNode = new Map<number, Scope>();
|
||||
|
|
@ -148,7 +183,20 @@ export class TsHarvester {
|
|||
*/
|
||||
private readonly resultDefTargets = new Map<number, number[]>();
|
||||
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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<string, StringRestriction>;
|
||||
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<string, { declaration: number; restriction?: StringRestriction }>();
|
||||
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<string, StringRestriction>();
|
||||
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',
|
||||
);
|
||||
}
|
||||
|
|
@ -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<SyntaxNode> {
|
||||
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 };
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
: '') +
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
413
gitnexus/src/core/ingestion/taint/guard-refinement.ts
Normal file
413
gitnexus/src/core/ingestion/taint/guard-refinement.ts
Normal file
|
|
@ -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<number, ValueExpression>();
|
||||
const written = new Set<number>();
|
||||
const calls = new Map<string, NonNullable<StatementValueFacts['calls']>[number]>();
|
||||
const predicateSites = new Set<string>();
|
||||
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<number>();
|
||||
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<FunctionCfg, FunctionRefinement>();
|
||||
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<number>();
|
||||
const uses = new Map<number, Set<number>>();
|
||||
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<number, Map<number, StringRestriction>>();
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
|
@ -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<string, InterprocFinding>();
|
||||
const tainted = new Map<string, TaintedParam>();
|
||||
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<SinkKind>();
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<string, StatementMatches>();
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -66,6 +66,8 @@ export interface TaintCallableMatcher<K extends string = string> {
|
|||
* names (`res.send` / `res.write`); exactly `<receiver>.<name>`, name-based.
|
||||
*/
|
||||
export interface TaintSinkEntry extends TaintCallableMatcher<SinkKind> {
|
||||
/** 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 <name>(…)` sites only. */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<number, number>();
|
||||
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<string>();
|
||||
const matchedSinks = new Map<string, Map<number, MatchedSinkCall[]>>();
|
||||
for (const sm of matches.statements) {
|
||||
const stmtKey = `${sm.blockIndex}:${sm.statementIndex}`;
|
||||
const siteSinks = new Map<number, MatchedSinkCall[]>();
|
||||
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[] =
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
245
gitnexus/src/core/ingestion/taint/value-refinement.ts
Normal file
245
gitnexus/src/core/ingestion/taint/value-refinement.ts
Normal file
|
|
@ -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<string>();
|
||||
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));
|
||||
}
|
||||
|
|
@ -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.',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
2
gitnexus/src/mcp/taint-limitations.ts
Normal file
2
gitnexus/src/mcp/taint-limitations.ts
Normal file
|
|
@ -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.';
|
||||
|
|
@ -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('<literal>') joins resolve; dynamic requires do not).
|
||||
- Exception-path over-approximation can produce false-positive noise.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
125
gitnexus/test/integration/cfg/guard-taint.test.ts
Normal file
125
gitnexus/test/integration/cfg/guard-taint.test.ts
Normal file
|
|
@ -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<string, string> = {
|
||||
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);
|
||||
});
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
132
gitnexus/test/unit/cfg/typescript-refinement.test.ts
Normal file
132
gitnexus/test/unit/cfg/typescript-refinement.test.ts
Normal file
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>): 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 {
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
|
|
|
|||
131
gitnexus/test/unit/taint/guard-refinement.test.ts
Normal file
131
gitnexus/test/unit/taint/guard-refinement.test.ts
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
});
|
||||
|
|
|
|||
98
gitnexus/test/unit/taint/value-refinement.test.ts
Normal file
98
gitnexus/test/unit/taint/value-refinement.test.ts
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue