GitNexus/gitnexus/test/unit/taint/taint-emit.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

382 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* U4 (#2083 M3) — `emitFileTaint` over REAL harvested CFGs.
*
* Fixtures parse real source through the worker-side TS CFG visitor (the
* propagate.test.ts harness): CFGs and sites come from the harvest, imports
* from the real TS capture+interpret path — the emit driver consumes exactly
* the structures the run.ts pdg window feeds it, never hand-built mocks
* (except the deliberate corrupted-store mutation below).
*
* Pinned here: KTD6 statement-level finding identity (occurrence-distinct
* rows for `exec(req.body, req.query)`; variable-distinct rows on a shared
* block pair), dedup-before-budget with truncate-and-warn, the zero-match
* fast path (no solver call — asserted via the result counters), the
* unsafe-sites skip-taint-keep-RD degradation, kills-without-findings, and
* the decodability of every persisted `reason` via the shared path codec.
*/
import { describe, it, expect } from 'vitest';
import { cfgsOf, importsFor } from '../../helpers/ts-cfg-harness.js';
import { emitFileCfgs } from '../../../src/core/ingestion/cfg/emit.js';
import type { FunctionCfg } from '../../../src/core/ingestion/cfg/types.js';
import type { SourceSinkSanitizerSpec } from '../../../src/core/ingestion/taint/source-sink-config.js';
import {
emitFileTaint,
type TaintEmitLimits,
type TaintEmitResult,
} from '../../../src/core/ingestion/taint/emit.js';
import { decodeTaintPath } from '../../../src/core/ingestion/taint/path-codec.js';
import { createKnowledgeGraph } from '../../../src/core/graph/graph.js';
import type { KnowledgeGraph } from '../../../src/core/graph/types.js';
import type { GraphRelationship } from 'gitnexus-shared';
/** Mechanics spec (propagate.test.ts MECH): global exec sink, global escape sanitizer. */
const MECH: SourceSinkSanitizerSpec = {
sources: [
{ kind: 'remote-input', objects: ['req'], properties: ['body', 'query', 'params', 'headers'] },
],
sinks: [{ name: 'exec', kind: 'command-injection', args: [0], global: true }],
sanitizers: [{ name: 'escape', neutralizes: ['command-injection'], global: true }],
};
/** MECH with the sink dangerous at EVERY position (`args` omitted). */
const MECH_ALL_ARGS: SourceSinkSanitizerSpec = {
...MECH,
sinks: [{ name: 'exec', kind: 'command-injection', global: true }],
};
const CALL_RESULT_SOURCE_SPEC: SourceSinkSanitizerSpec = {
sources: [
{
type: 'call-result',
kind: 'remote-input',
receivers: ['request'],
methods: ['getParameter'],
},
],
sinks: [{ name: 'exec', kind: 'command-injection', args: [0], global: true }],
sanitizers: [],
};
interface RunResult {
graph: KnowledgeGraph;
result: TaintEmitResult;
tainted: GraphRelationship[];
sanitizes: GraphRelationship[];
warns: string[];
}
function run(
code: string,
opts: { spec?: SourceSinkSanitizerSpec; limits?: TaintEmitLimits; cfgs?: FunctionCfg[] } = {},
): RunResult {
const graph = createKnowledgeGraph();
const cfgs = opts.cfgs ?? cfgsOf(code);
// Emit the M1 layer first so taint endpoints can be checked against REAL
// persisted BasicBlock nodes (run.ts ordering).
emitFileCfgs(graph, cfgs);
const warns: string[] = [];
const result = emitFileTaint(graph, cfgs, importsFor(code), opts.spec ?? MECH, opts.limits, (m) =>
warns.push(m),
);
const tainted: GraphRelationship[] = [];
const sanitizes: GraphRelationship[] = [];
for (const rel of graph.iterRelationships()) {
if (rel.type === 'TAINTED') tainted.push(rel);
if (rel.type === 'SANITIZES') sanitizes.push(rel);
}
return { graph, result, tainted, sanitizes, warns };
}
function blockIds(graph: KnowledgeGraph): Set<string> {
const ids = new Set<string>();
graph.forEachNode((n) => {
if (n.label === 'BasicBlock') ids.add(n.id);
});
return ids;
}
describe('emitFileTaint — happy path', () => {
const CODE = `
function handler(req: { body: string }) {
const cmd = req.body;
exec(cmd);
}`;
it('persists one TAINTED edge whose endpoints are real BasicBlock nodes', () => {
const { graph, result, tainted } = run(CODE);
expect(result.functionsAnalyzed).toBe(1);
expect(result.findingsEmitted).toBe(1);
expect(tainted).toHaveLength(1);
const ids = blockIds(graph);
expect(ids.has(tainted[0].sourceId)).toBe(true);
expect(ids.has(tainted[0].targetId)).toBe(true);
expect(tainted[0].id.startsWith('TAINTED:fixture.ts:')).toBe(true);
});
it('preserves the legacy member-read TAINTED edge identity', () => {
const { tainted } = run(CODE);
expect(tainted[0].id).toBe(
'TAINTED:fixture.ts:2:0:command-injection:2:0.0:req:2:17:2:1.0.0:cmd:3:8:exec:body',
);
});
it('persists a call-result source TAINTED edge with deterministic identity', () => {
const { result, tainted } = run(
`
function handler(request: { getParameter(name: string): string }) {
const cmd = request.getParameter('cmd');
exec(cmd);
}`,
{ spec: CALL_RESULT_SOURCE_SPEC },
);
expect(result.functionsAnalyzed).toBe(1);
expect(result.findingsEmitted).toBe(1);
expect(tainted).toHaveLength(1);
expect(tainted[0].id).toBe(
'TAINTED:fixture.ts:2:0:command-injection:2:0.0:call-result:cmd:3:8:request.getParameter:2:1.0.0:cmd:3:8:exec',
);
const decoded = decodeTaintPath(tainted[0].reason);
expect(decoded.ok).toBe(true);
if (decoded.ok) {
expect(decoded.kind).toBe('command-injection');
expect(decoded.hops.map((h) => `${h.variable}@${h.line}`)).toEqual(['cmd@3', 'cmd@4']);
}
});
it('the persisted reason decodes via the shared codec with ordered hops + variables', () => {
const { tainted } = run(CODE);
const decoded = decodeTaintPath(tainted[0].reason);
expect(decoded.ok).toBe(true);
if (decoded.ok) {
expect(decoded.truncated).toBe(false);
// The finding's sinkKind rides the `;<kind>` header (the only persisted
// channel — the edge id embedding it is not a stored column; U6 reads it).
expect(decoded.kind).toBe('command-injection');
// seed def (cmd @ const line) → sink use (cmd @ exec line)
expect(decoded.hops.map((h) => `${h.variable}@${h.line}`)).toEqual(['cmd@3', 'cmd@4']);
}
});
});
describe('KTD6 statement-level finding identity', () => {
it('exec(req.body, req.query) → TWO rows distinguished by occurrence', () => {
const { result, tainted } = run(
`
function handler(req: { body: string; query: string }) {
exec(req.body, req.query);
}`,
{ spec: MECH_ALL_ARGS },
);
expect(result.findingsEmitted).toBe(2);
expect(tainted).toHaveLength(2);
// Same block pair — the identity (and the id) differ ONLY by occurrence.
expect(tainted[0].sourceId).toBe(tainted[1].sourceId);
expect(tainted[0].targetId).toBe(tainted[1].targetId);
expect(tainted[0].id).not.toBe(tainted[1].id);
});
it('two findings on the same block pair with different variables → two rows', () => {
const { result, tainted } = run(`
function handler(req: { body: string; query: string }) {
const a = req.body;
const b = req.query;
exec(a);
exec(b);
}`);
expect(result.findingsEmitted).toBe(2);
expect(tainted).toHaveLength(2);
expect(new Set(tainted.map((t) => t.id)).size).toBe(2);
const variables = tainted.map((t) => {
const d = decodeTaintPath(t.reason);
if (!d.ok) throw new Error(d.error);
return d.hops[d.hops.length - 1].variable;
});
expect(variables.sort()).toEqual(['a', 'b']);
});
});
describe('dedup-before-budget and the findings cap', () => {
const FOUR_FINDINGS = `
function handler(req: { body: string; query: string }) {
const a = req.body;
const b = req.query;
exec(a);
exec(b);
exec(a);
exec(b);
}`;
it('uncapped: identical (deduped) flows collapse; distinct ones all emit', () => {
const { result, tainted } = run(FOUR_FINDINGS);
// 4 sink statements × 1 variable each — all distinct sink points → 4 rows.
expect(result.findingsEmitted).toBe(4);
expect(result.findingsDropped).toBe(0);
expect(new Set(tainted.map((t) => t.id)).size).toBe(4);
});
it('capped: truncates deterministically and warns naming the drop count', () => {
const { result, tainted, warns } = run(FOUR_FINDINGS, {
limits: { maxFindingsPerFunction: 1 },
});
expect(result.findingsEmitted).toBe(1);
expect(result.findingsDropped).toBe(3);
expect(tainted).toHaveLength(1);
const capWarn = warns.find((w) => w.includes('findings cap'));
expect(capWarn).toBeDefined();
expect(capWarn).toContain('dropped 3 of 4');
expect(result.droppedExamples).toEqual(['fixture.ts:2']);
});
});
describe('zero-match fast path', () => {
it('no source AND no sink: function skipped without a solver call', () => {
const { result, tainted, sanitizes } = run(`
function pure(x: number) {
const y = x + 1;
return y;
}`);
expect(result.functionsSkippedNoMatch).toBe(1);
expect(result.functionsAnalyzed).toBe(0);
expect(tainted).toHaveLength(0);
expect(sanitizes).toHaveLength(0);
});
it('sink without source (and vice versa) also short-circuits', () => {
const { result } = run(`
function sinkOnly(cmd: string) {
exec(cmd);
}
function sourceOnly(req: { body: string }) {
return req.body;
}`);
expect(result.functionsSkippedNoMatch).toBe(2);
expect(result.functionsAnalyzed).toBe(0);
expect(result.findingsEmitted).toBe(0);
});
});
describe('unsafe-sites degradation (skip-taint-keep-RD)', () => {
const TWO_FNS = `
function corrupted(req: { body: string }) {
exec(req.body);
}
function stillVulnerable(req: { body: string }) {
exec(req.body);
}`;
it('a corrupted-store site skips ONLY that function; siblings still emit', () => {
const cfgs = JSON.parse(JSON.stringify(cfgsOf(TWO_FNS))) as FunctionCfg[];
// Corrupt the first function's first site: out-of-range binding index.
let mutated = false;
outer: for (const block of cfgs[0].blocks) {
for (const stmt of block.statements ?? []) {
if (stmt.sites !== undefined && stmt.sites.length > 0) {
(stmt.sites[0] as { object?: number }).object = 9999;
mutated = true;
break outer;
}
}
}
expect(mutated).toBe(true);
const { result, tainted, warns } = run(TWO_FNS, { cfgs });
expect(result.functionsSkippedUnsafeSites).toBe(1);
expect(result.functionsAnalyzed).toBe(1);
expect(result.findingsEmitted).toBe(1); // the sibling's finding survives
expect(tainted).toHaveLength(1);
expect(tainted[0].id).toContain('fixture.ts:5'); // the SECOND function
expect(warns.some((w) => w.includes('malformed site annotations'))).toBe(true);
expect(result.coverageGapExamples).toEqual(['fixture.ts:2']);
});
});
describe('kills without findings', () => {
it('a fully-sanitized flow emits SANITIZES and zero TAINTED', () => {
const { graph, result, tainted, sanitizes } = run(`
function safe(req: { body: string }) {
const b = escape(req.body);
exec(b);
}`);
expect(result.functionsAnalyzed).toBe(1);
expect(result.findingsEmitted).toBe(0);
expect(tainted).toHaveLength(0);
expect(result.killsEmitted).toBe(1);
expect(sanitizes).toHaveLength(1);
// reason = the killed binding's plain name; endpoints are real blocks.
expect(sanitizes[0].reason).toBe('b');
expect(sanitizes[0].id.startsWith('SANITIZES:fixture.ts:')).toBe(true);
const ids = blockIds(graph);
expect(ids.has(sanitizes[0].sourceId)).toBe(true);
expect(ids.has(sanitizes[0].targetId)).toBe(true);
});
it('a kill alongside a finding emits both edge kinds', () => {
const { result } = run(`
function mixed(req: { body: string; query: string }) {
const safe = escape(req.body);
exec(safe);
exec(req.query);
}`);
expect(result.killsEmitted).toBe(1);
expect(result.findingsEmitted).toBe(1);
});
});
describe('hop truncation accounting', () => {
it('maxHops=1 truncates the persisted path and counts the finding', () => {
const { result, tainted } = run(
`
function handler(req: { body: string }) {
const a = req.body;
const b = a;
exec(b);
}`,
{ limits: { maxHops: 1 } },
);
expect(result.findingsEmitted).toBe(1);
expect(result.hopsTruncatedFindings).toBe(1);
const decoded = decodeTaintPath(tainted[0].reason);
expect(decoded.ok).toBe(true);
if (decoded.ok) {
expect(decoded.truncated).toBe(true); // path-incomplete, not an error
expect(decoded.hops).toHaveLength(1); // source-side prefix
expect(decoded.hops[0].variable).toBe('a');
}
});
});
describe('telemetry completeness', () => {
it('every counter field is present (nothing dropped on the floor — the M2 lesson)', () => {
const { result } = run(`function noop() { return 1; }`);
expect(result).toEqual({
functionsAnalyzed: 0,
functionsSkippedNoMatch: 1,
functionsSkippedUnsafeSites: 0,
functionsCoverageGap: { truncated: 0, overflow: 0, 'no-facts': 0 },
findingsEmitted: 0,
killsEmitted: 0,
findingsDropped: 0,
hopsTruncatedFindings: 0,
coverageGapExamples: [],
droppedExamples: [],
});
});
it('a solver coverage gap (fact limit) is counted by reason, with an example anchor', () => {
const { result, tainted } = run(
`
function gap(req: { body: string }) {
const a = req.body;
const b = a;
const c = b;
exec(c);
}`,
{ limits: { maxFacts: 1 } },
);
expect(result.functionsCoverageGap.truncated).toBe(1);
expect(result.functionsAnalyzed).toBe(0);
expect(tainted).toHaveLength(0); // R4: never partially analyzed
expect(result.coverageGapExamples).toEqual(['fixture.ts:2']);
});
});