mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-11-13 08:51:28 +00:00
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>
This commit is contained in:
parent
4eda955c96
commit
4968604270
4 changed files with 200 additions and 2 deletions
|
|
@ -38,10 +38,14 @@ export const TS_JS_TAINT_MODEL: SourceSinkSanitizerSpec = {
|
|||
},
|
||||
],
|
||||
sinks: [
|
||||
// Command execution — the command string is argument 0.
|
||||
// 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: 'spawn', kind: 'command-injection', args: [0], module: 'child_process' },
|
||||
{ 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' },
|
||||
{ name: 'execFileSync', kind: 'command-injection', args: [0, 1], module: 'child_process' },
|
||||
// Code evaluation. `eval` takes code at 0; `new Function(...)` treats
|
||||
// EVERY argument as source text (params + body), so `args` is omitted
|
||||
// (= all positions) rather than pinned to 0.
|
||||
|
|
@ -56,9 +60,37 @@ export const TS_JS_TAINT_MODEL: SourceSinkSanitizerSpec = {
|
|||
// (mysql2/pg/knex handles go by many names; receiver-conventional).
|
||||
{ name: 'query', kind: 'sql-injection', args: [0], anyReceiver: true },
|
||||
{ name: 'execute', kind: 'sql-injection', args: [0], anyReceiver: true },
|
||||
// Modern DB libraries expose shorter method names with high collision
|
||||
// rates (`map.get`, `task.run`, ...), so keep these receiver-conventional.
|
||||
{
|
||||
name: 'run',
|
||||
kind: 'sql-injection',
|
||||
args: [0],
|
||||
receivers: ['db', 'database', 'conn', 'client', 'pool', 'stmt', 'statement', 'prepared'],
|
||||
},
|
||||
{
|
||||
name: 'all',
|
||||
kind: 'sql-injection',
|
||||
args: [0],
|
||||
receivers: ['db', 'database', 'conn', 'client', 'pool', 'stmt', 'statement', 'prepared'],
|
||||
},
|
||||
{
|
||||
name: 'get',
|
||||
kind: 'sql-injection',
|
||||
args: [0],
|
||||
receivers: ['db', 'database', 'conn', 'client', 'pool', 'stmt', 'statement', 'prepared'],
|
||||
},
|
||||
{
|
||||
name: 'values',
|
||||
kind: 'sql-injection',
|
||||
args: [0],
|
||||
receivers: ['db', 'database', 'conn', 'client', 'pool'],
|
||||
},
|
||||
{ name: 'raw', kind: 'sql-injection', args: [0], receivers: ['db', 'knex', 'sequelize'] },
|
||||
// Reflected XSS — Express response writes, conventional receiver `res`.
|
||||
{ name: 'send', kind: 'xss', args: [0], receivers: ['res'] },
|
||||
{ name: 'write', kind: 'xss', args: [0], receivers: ['res'] },
|
||||
{ name: 'render', kind: 'xss', args: [0, 1], receivers: ['res'] },
|
||||
],
|
||||
sanitizers: [
|
||||
// URL-encoding: neutralizes markup injection AND path separators
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import { LocalBackend } from '../../src/mcp/local/local-backend.js';
|
|||
import { listRegisteredRepos, loadMeta } from '../../src/storage/repo-manager.js';
|
||||
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
|
||||
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
|
||||
import { decodeTaintPath } from '../../src/core/ingestion/taint/path-codec.js';
|
||||
|
||||
vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/storage/repo-manager.js')>();
|
||||
|
|
@ -42,6 +43,82 @@ vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => {
|
|||
|
||||
const FIXTURE = path.join(__dirname, 'cfg', 'fixtures', 'pdg-repo');
|
||||
|
||||
describe('TS/JS taint model sink disambiguation — real pipeline', () => {
|
||||
it('emits findings only for the intended imported/receiver-conventional sinks', async () => {
|
||||
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-taint-disambig-'));
|
||||
try {
|
||||
fs.mkdirSync(path.join(repoDir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(repoDir, 'src', 'app.ts'),
|
||||
`import { execFile as childExecFile, spawnSync } from 'node:child_process';
|
||||
|
||||
function execFile(cmd: string, args: string[]) {
|
||||
return { cmd, args };
|
||||
}
|
||||
|
||||
export function handle(req: any, db: any, map: Map<string, string>, task: any, res: any, out: any) {
|
||||
const value = req.body;
|
||||
childExecFile('git', [value]);
|
||||
spawnSync(value, []);
|
||||
execFile(value, [value]);
|
||||
db.run(value);
|
||||
map.get(value);
|
||||
task.run(value);
|
||||
res.render(value, {});
|
||||
out.render(value, {});
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const result = await runPipelineFromRepo(repoDir, () => {}, { pdg: true });
|
||||
const findings = [...result.graph.iterRelationships()]
|
||||
.filter((rel) => rel.type === 'TAINTED')
|
||||
.map((rel) => {
|
||||
const sink = result.graph.getNode(rel.targetId);
|
||||
const decoded = decodeTaintPath(rel.reason);
|
||||
if (!decoded.ok) {
|
||||
throw new Error(`invalid TAINTED reason for ${rel.id}: ${decoded.error}`);
|
||||
}
|
||||
return {
|
||||
kind: decoded.kind,
|
||||
sinkLine: decoded.hops.at(-1)?.line,
|
||||
sinkText: String(sink?.properties.text ?? ''),
|
||||
};
|
||||
});
|
||||
|
||||
expect(findings.map((f) => f.kind).sort()).toEqual([
|
||||
'command-injection',
|
||||
'command-injection',
|
||||
'sql-injection',
|
||||
'xss',
|
||||
]);
|
||||
const findingSites = findings
|
||||
.map((f) => `${f.kind}@${f.sinkLine}`)
|
||||
.sort((a, b) => Number(a.split('@')[1]) - Number(b.split('@')[1]));
|
||||
expect(findingSites).toEqual([
|
||||
'command-injection@9',
|
||||
'command-injection@10',
|
||||
'sql-injection@12',
|
||||
'xss@15',
|
||||
]);
|
||||
expect(findings.map((f) => f.sinkLine).sort((a, b) => Number(a) - Number(b))).not.toContain(
|
||||
11,
|
||||
);
|
||||
expect(findings.map((f) => f.sinkLine).sort((a, b) => Number(a) - Number(b))).not.toContain(
|
||||
13,
|
||||
);
|
||||
expect(findings.map((f) => f.sinkLine).sort((a, b) => Number(a) - Number(b))).not.toContain(
|
||||
14,
|
||||
);
|
||||
expect(findings.map((f) => f.sinkLine).sort((a, b) => Number(a) - Number(b))).not.toContain(
|
||||
16,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(repoDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Block 1: a --pdg index with real taint findings ─────────────────
|
||||
|
||||
withTestLbugDB(
|
||||
|
|
|
|||
|
|
@ -99,6 +99,22 @@ 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); }`);
|
||||
|
|
@ -225,10 +241,35 @@ describe('receiver-conventional sinks', () => {
|
|||
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', () => {
|
||||
|
|
|
|||
|
|
@ -539,6 +539,54 @@ describe('multi-source identity — distinct sources do not merge at one def', (
|
|||
// ── kind-set exclusion model (real built-in model) ──────────────────────────
|
||||
|
||||
describe('kind-set exclusions — sanitizers neutralize their kinds only', () => {
|
||||
it('built-in model catches argv-form child_process command sinks', () => {
|
||||
const r = analyze(
|
||||
`import { execFileSync } from 'node:child_process';
|
||||
function f(req) {
|
||||
const tool = req.body;
|
||||
const arg = req.query;
|
||||
execFileSync(tool, ['--version']);
|
||||
execFileSync('git', [arg]);
|
||||
}`,
|
||||
{ spec: TS_JS_TAINT_MODEL },
|
||||
);
|
||||
expect(r.findings.map((finding) => finding.sinkKind)).toEqual([
|
||||
'command-injection',
|
||||
'command-injection',
|
||||
]);
|
||||
});
|
||||
|
||||
it('built-in model catches conventional modern DB receiver methods', () => {
|
||||
const r = analyze(
|
||||
`function f(req, db, stmt, knex) {
|
||||
const name = req.body;
|
||||
db.run(name);
|
||||
db.all(name);
|
||||
stmt.get(name);
|
||||
knex.raw(name);
|
||||
}`,
|
||||
{ spec: TS_JS_TAINT_MODEL },
|
||||
);
|
||||
expect(r.findings.map((finding) => finding.sinkKind)).toEqual([
|
||||
'sql-injection',
|
||||
'sql-injection',
|
||||
'sql-injection',
|
||||
'sql-injection',
|
||||
]);
|
||||
});
|
||||
|
||||
it('built-in model catches Express render template and data sinks', () => {
|
||||
const r = analyze(
|
||||
`function f(req, res) {
|
||||
const template = req.body;
|
||||
const viewData = req.query;
|
||||
res.render(template, viewData);
|
||||
}`,
|
||||
{ spec: TS_JS_TAINT_MODEL },
|
||||
);
|
||||
expect(r.findings.map((finding) => finding.sinkKind)).toEqual(['xss', 'xss']);
|
||||
});
|
||||
|
||||
it('escape(req.body) → res.send(b) suppressed (xss neutralized) BUT db.query(b) fires (sql not)', () => {
|
||||
const r = analyze(
|
||||
`import { escape } from 'validator';
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue