GitNexus/gitnexus/test/unit/taint/model-match.test.ts
azizur100389 3dd553b345
feat(taint): expand TS/JS sink model (#2490)
* feat(taint): expand TS/JS sink model

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(taint): cover TS sink disambiguation end-to-end

Add a real-pipeline integration test proving the expanded TS/JS taint sinks only emit findings for intended imported and receiver-conventional symbols.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-07-16 13:11:57 +01:00

381 lines
15 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.

/**
* U2 (#2083 M3) — built-in TS/JS taint model + import-aware site matcher.
*
* Fixtures parse REAL source: CFGs (and therefore SiteRecords) come from the
* worker-side TS CFG visitor (the harvest.test.ts harness pattern), and
* ParsedImports come from the real scope-capture + interpretTsImport path
* (the typescript-imports.test.ts harness pattern) — matches run against the
* exact structures U4 will feed the matcher, never hand-built mocks.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { cfgOf, importsFor } from '../../helpers/ts-cfg-harness.js';
import { hasTaintSafeSites } from '../../../src/core/ingestion/taint/site-safety.js';
import type { SourceSinkSanitizerSpec } from '../../../src/core/ingestion/taint/source-sink-config.js';
import {
BUILTIN_TAINT_MODELS,
TS_JS_TAINT_MODEL,
computeTaintModelVersion,
registerBuiltinTaintModels,
taintModelVersion,
} from '../../../src/core/ingestion/taint/typescript-model.js';
import {
buildTaintImportIndex,
matchFunctionSites,
type FunctionSiteMatches,
type MatchedSanitizerCall,
type MatchedSinkCall,
type MatchedSource,
} from '../../../src/core/ingestion/taint/match.js';
import {
getSourceSinkConfig,
clearSourceSinkRegistry,
registeredTaintLanguages,
} from '../../../src/core/ingestion/taint/source-sink-registry.js';
/** Match function #`fnIndex` of `code` against the built-in model (or `spec`). */
function matchesOf(
code: string,
fnIndex = 0,
spec: SourceSinkSanitizerSpec = TS_JS_TAINT_MODEL,
): FunctionSiteMatches {
const cfg = cfgOf(code, fnIndex);
// The matcher's documented precondition — real harvests must always pass.
expect(hasTaintSafeSites(cfg)).toBe(true);
return matchFunctionSites(cfg, spec, buildTaintImportIndex(importsFor(code)));
}
const allSinks = (m: FunctionSiteMatches): MatchedSinkCall[] =>
m.statements.flatMap((s) => [...s.sinks]);
const allSources = (m: FunctionSiteMatches): MatchedSource[] =>
m.statements.flatMap((s) => [...s.sources]);
const allSanitizers = (m: FunctionSiteMatches): MatchedSanitizerCall[] =>
m.statements.flatMap((s) => [...s.sanitizers]);
describe('sink resolution — ESM import joins', () => {
it('named import: `import { exec } from "child_process"; exec(c)` matches command-injection', () => {
const m = matchesOf(`import { exec } from 'child_process';
function f(c) { exec(c); }`);
const sinks = allSinks(m);
expect(sinks).toHaveLength(1);
expect(sinks[0].entry.kind).toBe('command-injection');
expect(sinks[0].entry.name).toBe('exec');
expect([...sinks[0].argPositions]).toEqual([0]);
expect(m.hasSink).toBe(true);
});
it('alias import: `import { exec as run }` — run(c) resolves to child_process.exec', () => {
const m = matchesOf(`import { exec as run } from 'child_process';
function f(c) { run(c); }`);
const sinks = allSinks(m);
expect(sinks).toHaveLength(1);
expect(sinks[0].entry.name).toBe('exec');
});
it('namespace import: `import * as cp` — cp.exec(c) resolves via the receiver binding', () => {
const m = matchesOf(`import * as cp from 'child_process';
function f(c) { cp.exec(c); }`);
expect(allSinks(m).map((s) => s.entry.name)).toEqual(['exec']);
});
it('named import from a same-tail module is not canonicalized as a namespace handle', () => {
const spec: SourceSinkSanitizerSpec = {
sources: [],
sinks: [{ name: 'readString', kind: 'path-traversal', args: [0], module: 'pkg.Files' }],
sanitizers: [],
};
const m = matchesOf(
`import { Files } from 'pkg.Files';
function f(p) { Files.readString(p); }`,
0,
spec,
);
expect(allSinks(m)).toHaveLength(0);
});
it('node: scheme prefix is normalized — `from "node:child_process"` matches too', () => {
const m = matchesOf(`import { execSync } from 'node:child_process';
function f(c) { execSync(c); }`);
expect(allSinks(m).map((s) => s.entry.name)).toEqual(['execSync']);
});
it('argv-form child_process sinks match command injection on arg 0', () => {
const m = matchesOf(`import { execFile, execFileSync, spawnSync } from 'node:child_process';
function f(cmd, arg) {
execFile(cmd, [arg]);
execFileSync(cmd, [arg]);
spawnSync(cmd, [arg]);
}`);
expect(allSinks(m).map((s) => s.entry.name)).toEqual(['execFile', 'execFileSync', 'spawnSync']);
expect(allSinks(m).map((s) => [...s.argPositions])).toEqual([
[0, 1],
[0, 1],
[0, 1],
]);
expect(allSinks(m).every((s) => s.entry.kind === 'command-injection')).toBe(true);
});
it('an in-FUNCTION local `exec` shadows the import — no match', () => {
const m = matchesOf(`import { exec } from 'child_process';
function f(c) { function exec(x) { return x; } exec(c); }`);
expect(allSinks(m)).toHaveLength(0);
expect(m.hasSink).toBe(false);
});
it('a module-level local `exec` (no import) does NOT match — synthetic binding, no import entry', () => {
const m = matchesOf(
`function exec(x) { return x; }
function g(c) { exec(c); }`,
1, // g — index 0 is the local exec itself
);
expect(allSinks(m)).toHaveLength(0);
});
});
describe('sink resolution — globals and require joins', () => {
it('eval(x) matches code-injection via the synthetic-global fallback', () => {
const m = matchesOf(`function f(x) { eval(x); }`);
const sinks = allSinks(m);
expect(sinks).toHaveLength(1);
expect(sinks[0].entry.kind).toBe('code-injection');
});
it('`new Function(x)` matches; a bare `Function(x)` CALL does not (newOnly)', () => {
const withNew = matchesOf(`function f(x) { const fn = new Function(x); }`);
expect(allSinks(withNew).map((s) => s.entry.name)).toEqual(['Function']);
const bareCall = matchesOf(`function f(x) { const fn = Function(x); }`);
expect(allSinks(bareCall)).toHaveLength(0);
});
it('require literal join: `const cp = require("child_process"); cp.exec(c)` matches', () => {
const m = matchesOf(`function f(c) { const cp = require('child_process'); cp.exec(c); }`);
const sinks = allSinks(m);
expect(sinks).toHaveLength(1);
expect(sinks[0].entry.name).toBe('exec');
expect(sinks[0].entry.kind).toBe('command-injection');
});
it('a require’d local utility named exec does NOT match (no bare-name fallback for non-globals)', () => {
const m = matchesOf(`function f(c) { const exec = require('./my-utils'); exec(c); }`);
expect(allSinks(m)).toHaveLength(0);
});
it('non-renamed destructured require resolves via the dual interpretation', () => {
const m = matchesOf(`function f(c) { const { exec } = require('child_process'); exec(c); }`);
expect(allSinks(m).map((s) => s.entry.name)).toEqual(['exec']);
});
});
describe('sources — conventional member reads', () => {
it('req.body matches remote-input; request.body matches; myObj.body does not', () => {
const m = matchesOf(`function f(req, request, myObj) {
const a = req.body;
const b = request.body;
const c = myObj.body;
}`);
const sources = allSources(m);
expect(sources).toHaveLength(2);
expect(sources.every((s) => s.entry.kind === 'remote-input')).toBe(true);
expect(m.hasSource).toBe(true);
});
it('all five conventional properties match; an unlisted property does not', () => {
const m = matchesOf(`function f(req) {
const a = req.body, b = req.query, c = req.params, d = req.headers, e = req.cookies;
const z = req.socket;
}`);
expect(allSources(m)).toHaveLength(5);
});
});
describe('sink argument-position discipline', () => {
it('exec(safe, tainted): only the registered position 0 is a sink position', () => {
const m = matchesOf(`import { exec } from 'child_process';
function f(safe, tainted) { exec(safe, tainted); }`);
const [sink] = allSinks(m);
expect([...sink.argPositions]).toEqual([0]); // position 1 carries an occurrence but is not registered
});
it('spread: exec(...args) matches — recorded position ≥ spread index degrades soundly', () => {
const m = matchesOf(`import { exec } from 'child_process';
function f(args) { exec(...args); }`);
const [sink] = allSinks(m);
expect([...sink.argPositions]).toEqual([0]);
});
it('spread precision: a pre-spread position stays exact; post-spread matches any q ≥ spread', () => {
// Custom spec: sink position 1 only. `s2(a, ...rest)` — recorded 0 is
// BEFORE the spread (exact: no match); recorded 1 is the spread (match).
const spec: SourceSinkSanitizerSpec = {
sources: [],
sinks: [{ name: 's2', kind: 'command-injection', args: [1], global: true }],
sanitizers: [],
};
const m = matchesOf(`function f(a, rest) { s2(a, ...rest); }`, 0, spec);
const [sink] = allSinks(m);
expect([...sink.argPositions]).toEqual([1]);
});
it('tagged template: substitutions aggregate at position 0 and match any registered position', () => {
const spec: SourceSinkSanitizerSpec = {
sources: [],
sinks: [{ name: 'sql', kind: 'sql-injection', args: [1], global: true }],
sanitizers: [],
};
const m = matchesOf(`function f(id) { sql\`select \${id}\`; }`, 0, spec);
const [sink] = allSinks(m);
expect([...sink.argPositions]).toEqual([0]);
});
it('a sink whose dangerous positions carry no occurrences is not reported', () => {
const m = matchesOf(`import { exec } from 'child_process';
function f(t) { exec('ls -la', t); }`);
expect(allSinks(m)).toHaveLength(0);
});
});
describe('receiver-conventional sinks', () => {
it('res.send / res.write match xss; out.send does not', () => {
const m = matchesOf(`function f(res, out, x) { res.send(x); res.write(x); out.send(x); }`);
expect(allSinks(m).map((s) => s.entry.name)).toEqual(['send', 'write']);
expect(allSinks(m).every((s) => s.entry.kind === 'xss')).toBe(true);
});
it('res.render matches template and data args; out.render does not', () => {
const m = matchesOf(`function f(res, out, template, data) {
res.render(template, data);
out.render(template, data);
}`);
const sinks = allSinks(m);
expect(sinks.map((s) => s.entry.name)).toEqual(['render']);
expect(sinks.map((s) => [...s.argPositions])).toEqual([[0, 1]]);
expect(sinks[0].entry.kind).toBe('xss');
});
it('.query/.execute match sql-injection on ANY receiver', () => {
const m = matchesOf(`function f(db, pool, x) { db.query(x); pool.execute(x); }`);
expect(allSinks(m).map((s) => s.entry.kind)).toEqual(['sql-injection', 'sql-injection']);
});
it('modern DB method sinks match only conventional DB receivers', () => {
const m = matchesOf(`function f(db, stmt, knex, map, task, x) {
db.run(x);
db.all(x);
stmt.get(x);
knex.raw(x);
db.values(x);
map.get(x);
task.run(x);
}`);
expect(allSinks(m).map((s) => s.entry.name)).toEqual(['run', 'all', 'get', 'raw', 'values']);
expect(allSinks(m).every((s) => s.entry.kind === 'sql-injection')).toBe(true);
});
});
describe('sanitizers — import-aware only, kind-scoped', () => {
it('path.basename matches and neutralizes path-traversal, NOT command-injection', () => {
const m = matchesOf(`import path from 'path';
function f(p) { const safe = path.basename(p); }`);
const sans = allSanitizers(m);
expect(sans).toHaveLength(1);
expect(sans[0].entry.neutralizes).toContain('path-traversal');
expect(sans[0].entry.neutralizes).not.toContain('command-injection');
// resultDefs carries the kill target (KTD4b)
expect(sans[0].resultDefs).toHaveLength(1);
});
it('validator.escape via named import matches xss', () => {
const m = matchesOf(`import { escape } from 'validator';
function f(x) { const safe = escape(x); }`);
const sans = allSanitizers(m);
expect(sans).toHaveLength(1);
expect([...sans[0].entry.neutralizes]).toEqual(['xss']);
});
it('default-imported escape-html matches via the default pseudo-name', () => {
const m = matchesOf(`import escapeHtml from 'escape-html';
function f(x) { const safe = escapeHtml(x); }`);
expect(allSanitizers(m)).toHaveLength(1);
});
it('encodeURIComponent matches as a true global (xss + path-traversal)', () => {
const m = matchesOf(`function f(x) { const safe = encodeURIComponent(x); }`);
const sans = allSanitizers(m);
expect(sans).toHaveLength(1);
expect([...sans[0].entry.neutralizes]).toEqual(['xss', 'path-traversal']);
});
it('a user-defined in-file `escape` is NEVER a sanitizer (no bare-name resolution)', () => {
const m = matchesOf(
`function escape(s) { return s; }
function f(x) { const safe = escape(x); }`,
1, // f
);
expect(allSanitizers(m)).toHaveLength(0);
});
it('value-position sanitizer (exec(escape(x))) matches with EMPTY resultDefs — interposition substrate', () => {
const m = matchesOf(`import { exec } from 'child_process';
import { escape } from 'validator';
function f(x) { exec(escape(x)); }`);
const sans = allSanitizers(m);
expect(sans).toHaveLength(1);
expect(sans[0].resultDefs).toHaveLength(0);
// The sink still matches — interposition is U3's call, not the matcher's.
expect(allSinks(m)).toHaveLength(1);
});
});
describe('registry + model identity', () => {
beforeEach(() => clearSourceSinkRegistry());
it('registerBuiltinTaintModels registers TS, JS, and Python (idempotent); others stay undefined', () => {
registerBuiltinTaintModels();
registerBuiltinTaintModels(); // idempotent — last-write-wins on the same ids
expect(registeredTaintLanguages().sort()).toEqual([
'java',
'javascript',
'python',
'typescript',
]);
expect(getSourceSinkConfig('java')).toBe(BUILTIN_TAINT_MODELS.java);
expect(getSourceSinkConfig('typescript')).toBe(TS_JS_TAINT_MODEL);
expect(getSourceSinkConfig('javascript')).toBe(TS_JS_TAINT_MODEL);
expect(getSourceSinkConfig('python')).toBe(BUILTIN_TAINT_MODELS.python);
expect(getSourceSinkConfig('ruby')).toBeUndefined();
});
it('taintModelVersion covers the full built-in model registry', () => {
expect(taintModelVersion).toMatch(/^[0-9a-f]{12}$/);
expect(taintModelVersion).not.toBe(computeTaintModelVersion(TS_JS_TAINT_MODEL));
});
it('adding an entry changes the version', () => {
const added: SourceSinkSanitizerSpec = {
...TS_JS_TAINT_MODEL,
sinks: [...TS_JS_TAINT_MODEL.sinks, { name: 'load', kind: 'code-injection', module: 'vm' }],
};
expect(computeTaintModelVersion(added)).not.toBe(computeTaintModelVersion(TS_JS_TAINT_MODEL));
});
it('changing only a kind label changes the version', () => {
const relabeled: SourceSinkSanitizerSpec = {
...TS_JS_TAINT_MODEL,
sinks: TS_JS_TAINT_MODEL.sinks.map((s) =>
s.name === 'exec' ? { ...s, kind: 'xss' as const } : s,
),
};
expect(computeTaintModelVersion(relabeled)).not.toBe(
computeTaintModelVersion(TS_JS_TAINT_MODEL),
);
});
it('the version is content-derived: key order does not matter, entry order does', () => {
const reordered: SourceSinkSanitizerSpec = {
sanitizers: TS_JS_TAINT_MODEL.sanitizers,
sinks: TS_JS_TAINT_MODEL.sinks,
sources: TS_JS_TAINT_MODEL.sources,
};
expect(computeTaintModelVersion(reordered)).toBe(computeTaintModelVersion(TS_JS_TAINT_MODEL));
});
});