mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-02 02:11:29 +00:00
* 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>
381 lines
15 KiB
TypeScript
381 lines
15 KiB
TypeScript
/**
|
||
* 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));
|
||
});
|
||
});
|