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:
Gergo Magyar 2026-10-23 22:07:16 +00:00
parent f3fa67b144
commit 7b4c7b33bd
36 changed files with 2328 additions and 59 deletions

View file

@ -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."
}
}

View file

@ -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)),

View file

@ -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

View file

@ -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": []
}

View file

@ -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;

View file

@ -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;
/**

View file

@ -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) {

View file

@ -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',
);
}

View file

@ -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 };

View file

@ -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`
: '') +

View file

@ -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).

View 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),
};
}

View file

@ -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,

View file

@ -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

View file

@ -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. */

View file

@ -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;

View file

@ -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[] =

View file

@ -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(),
);

View file

@ -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' },

View 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));
}

View file

@ -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.',
};
}

View 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.';

View file

@ -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.

View file

@ -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 };
}

View 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);
});

View file

@ -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 () => {

View 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' });
});
});

View file

@ -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',

View file

@ -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 {

View file

@ -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([]);
});

View 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();
});
});

View file

@ -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', {

View file

@ -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([]);

View file

@ -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 }));
});

View 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();
});
});

View file

@ -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');