GitNexus/gitnexus/test/unit/taint/summary-harvest.test.ts
azizur100389 1c8ad84796
feat(taint): add conservative Java source/sink model (#2267)
* feat(taint): add conservative Java source model

* fix(taint): preserve Java import provenance

* chore: retry CI after network timeout

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-06-23 06:59:46 +01:00

249 lines
10 KiB
TypeScript

/**
* U1 (#2084 M4) — per-function taint summary harvest.
*
* Fixtures parse REAL TypeScript through the shared CFG/import harness, so the
* harvester consumes the exact `FunctionCfg` / `FunctionDefUse` /
* `FunctionSiteMatches` structures the pipeline produces. The four summary
* edge categories are asserted directly: param→return, param→callee-arg,
* param→sink, source→return.
*/
import { describe, it, expect } from 'vitest';
import { cfgOf, importsFor } from '../../helpers/ts-cfg-harness.js';
import type { FunctionCfg } from '../../../src/core/ingestion/cfg/types.js';
import { computeReachingDefs } from '../../../src/core/ingestion/cfg/reaching-defs.js';
import {
buildTaintImportIndex,
matchFunctionSites,
} from '../../../src/core/ingestion/taint/match.js';
import type { SourceSinkSanitizerSpec } from '../../../src/core/ingestion/taint/source-sink-config.js';
import { harvestFunctionSummary } from '../../../src/core/ingestion/taint/summary-harvest.js';
const SPEC: SourceSinkSanitizerSpec = {
sources: [{ kind: 'remote-input', objects: ['req'], properties: ['body', 'query', 'params'] }],
sinks: [
{ name: 'exec', kind: 'command-injection', args: [0], global: true },
{ name: 'query', kind: 'sql-injection', args: [0], anyReceiver: true },
],
sanitizers: [{ name: 'escape', neutralizes: ['command-injection'], global: true }],
};
const CALL_RESULT_SOURCE_SPEC: SourceSinkSanitizerSpec = {
sources: [
{
type: 'call-result',
kind: 'remote-input',
receivers: ['request'],
methods: ['getParameter'],
},
],
sinks: [],
sanitizers: [],
};
function harvest(code: string, spec: SourceSinkSanitizerSpec = SPEC, fnIndex = 0) {
const cfg: FunctionCfg = cfgOf(code, fnIndex);
const defUse = computeReachingDefs(cfg);
const matches = matchFunctionSites(cfg, spec, buildTaintImportIndex(importsFor(code)));
return harvestFunctionSummary(cfg, defUse, matches).facts;
}
describe('harvestFunctionSummary — param→return', () => {
it('records a param flowing straight to return', () => {
const f = harvest(`function f(x: string) { return x; }`);
expect(f.paramCount).toBe(1);
expect(f.paramToReturn).toEqual([{ param: 0 }]);
});
it('records a param returned through a local assignment', () => {
const f = harvest(`function f(x: string) { const y = x; return y; }`);
expect(f.paramToReturn).toEqual([{ param: 0 }]);
});
it('records receiver-TITO return (x.trim())', () => {
const f = harvest(`function f(x: string) { return x.trim(); }`);
expect(f.paramToReturn.map((r) => r.param)).toContain(0);
});
it('does not record an unrelated param', () => {
const f = harvest(`function f(x: string, y: string) { return x; }`);
expect(f.paramToReturn.map((r) => r.param)).toEqual([0]);
});
});
describe('harvestFunctionSummary — param→callee-arg', () => {
it('records a param flowing into a callee argument', () => {
const f = harvest(`function f(x: string) { helper(x); }`);
const ca = f.paramToCallArg;
expect(ca.length).toBeGreaterThanOrEqual(1);
expect(ca.some((c) => c.param === 0 && c.argIndex === 0 && c.calleeName === 'helper')).toBe(
true,
);
});
it('records the correct argument index', () => {
const f = harvest(`function f(x: string) { helper(a, x); }`);
expect(f.paramToCallArg.some((c) => c.param === 0 && c.argIndex === 1)).toBe(true);
});
});
describe('harvestFunctionSummary — param→sink', () => {
it('records a param reaching a modelled sink', () => {
const f = harvest(`function f(x: string) { exec(x); }`);
expect(f.paramToSink).toEqual([{ param: 0, sinkKind: 'command-injection' }]);
});
it('a sanitizer neutralises the matching sink kind', () => {
const f = harvest(`function f(x: string) { const y = escape(x); exec(y); }`);
// escape neutralises command-injection on the path to exec → no param→sink.
expect(f.paramToSink).toEqual([]);
});
});
describe('harvestFunctionSummary — call-arg sanitizer exclusions (#2084 review P1-2)', () => {
it('carries the neutralized kind onto a param→callee-arg edge', () => {
// x → escape(x) → y → helper(y): the call-arg edge to the user fn `helper`
// records that command-injection was neutralised on the path.
const f = harvest(`function f(x: string) { const y = escape(x); helper(y); }`);
const edge = f.paramToCallArg.find((c) => c.calleeName === 'helper');
if (edge === undefined) throw new Error('expected helper call-arg edge');
expect(edge.neutralized).toEqual(['command-injection']);
});
it('records no neutralized when the param reaches the call directly', () => {
const f = harvest(`function f(x: string) { helper(x); }`);
const edge = f.paramToCallArg.find((c) => c.calleeName === 'helper');
if (edge === undefined) throw new Error('expected helper call-arg edge');
expect(edge.neutralized).toBeUndefined();
});
});
describe('harvestFunctionSummary — source→callee-arg (fixpoint seed)', () => {
it('records a source passed directly into a callee argument', () => {
const f = harvest(`function f() { runIt(req.body); }`);
expect(f.sourceToCallArg.some((s) => s.argIndex === 0 && s.calleeName === 'runIt')).toBe(true);
});
it('records a source passed via a local into a callee argument', () => {
const f = harvest(`function f() { const u = req.body; runIt(u); }`);
expect(f.sourceToCallArg.some((s) => s.calleeName === 'runIt')).toBe(true);
});
it('records an assigned call-result source passed via a local into a callee argument', () => {
const f = harvest(
`function f(request: { getParameter(name: string): string }) {
const u = request.getParameter('path');
runIt(u);
}`,
CALL_RESULT_SOURCE_SPEC,
);
expect(f.sourceToCallArg).toEqual([
{ sourceKind: 'remote-input', callLine: 3, argIndex: 0, calleeName: 'runIt' },
]);
});
});
describe('harvestFunctionSummary — call-result seeds (#2084 review P1-1)', () => {
it('records a generative call result reaching a sink via a local', () => {
const f = harvest(`function f() { const t = getInput(); exec(t); }`);
expect(f.callResults.some((cr) => cr.calleeName === 'getInput' && cr.dest.to === 'sink')).toBe(
true,
);
});
it('records a call result flowing into another callee arg', () => {
const f = harvest(`function f() { const t = getInput(); forward(t); }`);
expect(
f.callResults.some(
(cr) =>
cr.calleeName === 'getInput' &&
cr.dest.to === 'callArg' &&
cr.dest.toCallee === 'forward',
),
).toBe(true);
});
it('records a bare `return getInput()` as a call result → return', () => {
const f = harvest(`function f() { return getInput(); }`);
expect(
f.callResults.some((cr) => cr.calleeName === 'getInput' && cr.dest.to === 'return'),
).toBe(true);
});
it('does not record call results for sink/sanitizer calls', () => {
const f = harvest(`function f(x: string) { exec(escape(x)); }`);
// exec is a sink, escape is a sanitizer — neither is a user-fn call result.
expect(f.callResults.some((cr) => cr.calleeName === 'exec' || cr.calleeName === 'escape')).toBe(
false,
);
});
});
describe('harvestFunctionSummary — source→return', () => {
it('records a generated source returned directly', () => {
const f = harvest(`function f() { return req.body; }`);
expect(f.sourceToReturn).toEqual([{ sourceKind: 'remote-input' }]);
});
it('records a generated source returned via a local', () => {
const f = harvest(`function f() { const u = req.body; return u; }`);
expect(f.sourceToReturn).toEqual([{ sourceKind: 'remote-input' }]);
});
it('records an assigned call-result source returned via a local', () => {
const f = harvest(
`function f(request: { getParameter(name: string): string }) {
const u = request.getParameter('path');
return u;
}`,
CALL_RESULT_SOURCE_SPEC,
);
expect(f.sourceToReturn).toEqual([{ sourceKind: 'remote-input' }]);
});
it('is empty when no source is present', () => {
const f = harvest(`function f(x: string) { return x; }`);
expect(f.sourceToReturn).toEqual([]);
});
});
describe('harvestFunctionSummary — documented limitations', () => {
it('all-simple params map to their formal argument position', () => {
const f = harvest(`function f(a: string, b: string) { exec(b); }`);
// `b` is formal param 1 — the index the interproc solver joins against.
expect(f.paramToSink).toEqual([{ param: 1, sinkKind: 'command-injection' }]);
});
it('destructured param before a simple param shifts the index (known FN, pinned)', () => {
// `function f([a, b], x)` — formal positions are [a,b]=0, x=1. The harvest
// assigns by binding ordinal (a=0, b=1, x=2), so x's port is 2, not the
// formal 1 the solver joins against → documented cross-function FN. Pinned
// so the behaviour is a known boundary, not a silent surprise; the proper
// fix (formal-param index from the worker) is deferred.
const f = harvest(`function f([a, b]: string[], x: string) { exec(x); }`);
const xSink = f.paramToSink.find((s) => s.sinkKind === 'command-injection');
if (xSink === undefined) throw new Error('expected command-injection param sink');
// Current (limited) behaviour: ordinal index 2, NOT the formal index 1.
expect(xSink.param).toBe(2);
});
});
describe('harvestFunctionSummary — edges & gaps', () => {
it('empty summary for a param-less, site-less function', () => {
const f = harvest(`function f() { const a = 1; return a; }`);
expect(f.paramToReturn).toEqual([]);
expect(f.paramToCallArg).toEqual([]);
expect(f.paramToSink).toEqual([]);
expect(f.sourceToReturn).toEqual([]);
});
it('reports a coverage gap when reaching-defs is not computed', () => {
// A hand-built CFG with no bindings → reaching-defs returns no-facts.
const cfg = cfgOf(`function f(x: string) { return x; }`);
const bare = { ...cfg, bindings: undefined } as FunctionCfg;
const defUse = computeReachingDefs(bare);
const matches = matchFunctionSites(bare, SPEC, buildTaintImportIndex([]));
const r = harvestFunctionSummary(bare, defUse, matches);
expect(r.status).toBe('coverage-gap');
});
});