GitNexus/gitnexus/test/unit/taint/java-model-match.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

341 lines
11 KiB
TypeScript

/**
* Java taint model (#2261) over real Java CFG and import capture output.
*/
import { createRequire } from 'node:module';
import type { ParsedImport } from 'gitnexus-shared';
import { assert, describe, expect, it } from 'vitest';
import { createJavaCfgVisitor } from '../../../src/core/ingestion/cfg/visitors/java.js';
import { computeReachingDefs } from '../../../src/core/ingestion/cfg/reaching-defs.js';
import type { FunctionCfg } from '../../../src/core/ingestion/cfg/types.js';
import { emitJavaScopeCaptures } from '../../../src/core/ingestion/languages/java/captures.js';
import { interpretJavaImport } from '../../../src/core/ingestion/languages/java/interpret.js';
import { JAVA_TAINT_MODEL } from '../../../src/core/ingestion/taint/java-model.js';
import { hasTaintSafeSites } from '../../../src/core/ingestion/taint/site-safety.js';
import {
buildTaintImportIndex,
matchFunctionSites,
type FunctionSiteMatches,
type MatchedSinkCall,
type MatchedSource,
} from '../../../src/core/ingestion/taint/match.js';
import { computeTaintFlows } from '../../../src/core/ingestion/taint/propagate.js';
import { makeCfgHarness, bindingIdx, type CfgHarness } from '../../helpers/cfg-harness.js';
const javaGrammar = createRequire(import.meta.url)('tree-sitter-java') as Parameters<
typeof makeCfgHarness
>[0];
const java: CfgHarness = makeCfgHarness(javaGrammar, createJavaCfgVisitor(), 'fixture.java');
function importsFor(src: string): ParsedImport[] {
return emitJavaScopeCaptures(src, 'fixture.java')
.filter((m) => m['@import.statement'] !== undefined)
.map((m) => interpretJavaImport(m))
.filter((p): p is ParsedImport => p !== null);
}
function cfgOf(code: string, fnIndex = 0): FunctionCfg {
const cfg = java.cfgOf(code, fnIndex);
expect(hasTaintSafeSites(cfg)).toBe(true);
return cfg;
}
function matchesOf(code: string, fnIndex = 0): { cfg: FunctionCfg; matches: FunctionSiteMatches } {
const cfg = cfgOf(code, fnIndex);
return {
cfg,
matches: matchFunctionSites(cfg, JAVA_TAINT_MODEL, buildTaintImportIndex(importsFor(code))),
};
}
function analyze(code: string, fnIndex = 0) {
const { cfg, matches } = matchesOf(code, fnIndex);
const defUse = computeReachingDefs(cfg);
return { cfg, matches, flows: computeTaintFlows(cfg, defUse, matches) };
}
const allSources = (m: FunctionSiteMatches): MatchedSource[] =>
m.statements.flatMap((s) => [...s.sources]);
const allSinks = (m: FunctionSiteMatches): MatchedSinkCall[] =>
m.statements.flatMap((s) => [...s.sinks]);
function matchedSinkSite(cfg: FunctionCfg, matches: FunctionSiteMatches, sink: MatchedSinkCall) {
const sinkSite = matches.statements
.flatMap((stmt) => stmt.sinks.map((matched) => ({ stmt, matched })))
.find(({ matched }) => matched === sink);
assert(sinkSite !== undefined, 'expected matched sink site');
const site =
cfg.blocks[sinkSite.stmt.blockIndex].statements?.[sinkSite.stmt.statementIndex]?.sites?.[
sink.siteIndex
];
assert(site !== undefined, 'expected concrete sink site');
return site;
}
const wrap = (body: string, imports = ''): string => `${imports}
class C {
void f(javax.servlet.http.HttpServletRequest request, javax.servlet.http.HttpServletRequest req, Helper helper, String safe) {
${body}
}
}`;
describe('Java taint model (#2261)', () => {
it('matches assigned request call results as remote-input sources', () => {
const { cfg, matches } = matchesOf(wrap(`String p = request.getParameter("id");`));
const sources = allSources(matches);
expect(sources).toHaveLength(1);
expect(sources[0].type).toBe('call-result');
expect(sources[0].entry.kind).toBe('remote-input');
expect(sources[0].type === 'call-result' ? [...sources[0].resultDefs] : []).toEqual([
bindingIdx(cfg, 'p'),
]);
expect(matches.hasSource).toBe(true);
});
it('propagates an assigned request source into a static-import-proven file read sink', () => {
const { cfg, matches, flows } = analyze(
wrap(
`
String p = request.getParameter("path");
readString(of(p));
`,
`
import static java.nio.file.Files.readString;
import static java.nio.file.Path.of;
`,
),
);
const p = bindingIdx(cfg, 'p');
const sink = allSinks(matches)[0];
expect(sink.entry.name).toBe('readString');
const site = matchedSinkSite(cfg, matches, sink);
expect(site?.callee).toBe('readString');
expect(site?.args?.[0]).toContainEqual([p, expect.any(Number)]);
expect(flows.status).toBe('computed');
expect(flows.findings).toHaveLength(1);
expect(flows.findings[0].sinkKind).toBe('path-traversal');
expect(flows.findings[0].source.type).toBe('call-result');
});
it('propagates regular-import-proven Files.readString into a path-traversal finding', () => {
const { cfg, matches, flows } = analyze(
wrap(
`
String p = request.getParameter("path");
Files.readString(Path.of(p));
`,
`
import java.nio.file.Files;
import java.nio.file.Path;
`,
),
);
const p = bindingIdx(cfg, 'p');
const sinks = allSinks(matches);
expect(sinks).toHaveLength(1);
expect(sinks[0].entry.kind).toBe('path-traversal');
const site = matchedSinkSite(cfg, matches, sinks[0]);
expect(site.callee).toBe('Files.readString');
expect(site.args?.[0]).toContainEqual([p, expect.any(Number)]);
expect(flows.findings).toHaveLength(1);
expect(flows.findings[0].sinkKind).toBe('path-traversal');
});
it('supports getHeader call-result sources', () => {
const { cfg, matches, flows } = analyze(
wrap(
`
String p = request.getHeader("X-Path");
readString(of(p));
`,
`
import static java.nio.file.Files.readString;
import static java.nio.file.Path.of;
`,
),
);
const p = bindingIdx(cfg, 'p');
const sources = allSources(matches);
expect(sources).toHaveLength(1);
assert(sources[0].type === 'call-result', 'expected getHeader to be a call-result source');
expect(sources[0].entry.kind).toBe('remote-input');
expect([...sources[0].resultDefs]).toEqual([p]);
const sinks = allSinks(matches);
expect(sinks).toHaveLength(1);
expect(sinks[0].entry.kind).toBe('path-traversal');
expect(flows.findings).toHaveLength(1);
expect(flows.findings[0].source.type).toBe('call-result');
});
it('supports the short req receiver for call-result sources', () => {
const { cfg, matches, flows } = analyze(
wrap(
`
String p = req.getParameter("path");
readString(of(p));
`,
`
import static java.nio.file.Files.readString;
import static java.nio.file.Path.of;
`,
),
);
const p = bindingIdx(cfg, 'p');
const [source] = allSources(matches);
assert(source?.type === 'call-result', 'expected req.getParameter source');
expect([...source.resultDefs]).toEqual([p]);
expect(flows.findings).toHaveLength(1);
});
it('does not seed an unassigned request call result', () => {
const { matches, flows } = analyze(
wrap(
`readString(request.getParameter("path"));`,
'import static java.nio.file.Files.readString;',
),
);
expect(allSources(matches)).toHaveLength(0);
expect(flows.findings).toHaveLength(0);
});
it('does not treat unrelated same-named receivers as servlet sources', () => {
const { matches, flows } = analyze(
wrap(
`
String p = helper.getParameter("path");
readString(p);
`,
'import static java.nio.file.Files.readString;',
),
);
expect(allSources(matches)).toHaveLength(0);
expect(flows.findings).toHaveLength(0);
});
it('does not report a sink when the tainted value is not in the dangerous argument', () => {
const { cfg, matches, flows } = analyze(
wrap(
`
String p = request.getParameter("path");
String unused = p;
readString(of(safe));
`,
`
import static java.nio.file.Files.readString;
import static java.nio.file.Path.of;
`,
),
);
const p = bindingIdx(cfg, 'p');
const sink = allSinks(matches)[0];
expect(sink.entry.name).toBe('readString');
const site = matchedSinkSite(cfg, matches, sink);
expect(site?.callee).toBe('readString');
expect(site?.args?.[0]).not.toContainEqual([p, expect.any(Number)]);
expect(site?.args?.[0]).not.toContain(p);
expect(flows.status).toBe('computed');
expect(flows.findings).toHaveLength(0);
});
it('does not match same-named readString calls without static import provenance', () => {
const { matches, flows } = analyze(
wrap(`
String p = request.getParameter("path");
readString(p);
helper.readString(p);
`),
);
expect(allSinks(matches)).toHaveLength(0);
expect(flows.findings).toHaveLength(0);
});
it('does not report Paths.get or Path.of constructors as sinks', () => {
const { matches, flows } = analyze(
wrap(
`
String p = request.getParameter("path");
get(p);
of(p);
`,
`
import static java.nio.file.Paths.get;
import static java.nio.file.Path.of;
`,
),
);
expect(allSinks(matches)).toHaveLength(0);
expect(flows.findings).toHaveLength(0);
});
it('does not match unrelated static imports named readString', () => {
const { matches, flows } = analyze(
wrap(
`
String p = request.getParameter("path");
readString(p);
`,
'import static com.example.Files.readString;',
),
);
expect(allSinks(matches)).toHaveLength(0);
expect(flows.findings).toHaveLength(0);
});
it('does not match unrelated regular imports named Files', () => {
const { matches, flows } = analyze(
wrap(
`
String p = request.getParameter("path");
Files.readString(p);
`,
'import com.example.Files;',
),
);
expect(allSinks(matches)).toHaveLength(0);
expect(flows.findings).toHaveLength(0);
});
it('does not let a local Files receiver inherit import provenance', () => {
const { matches, flows } = analyze(
wrap(
`
String p = request.getParameter("path");
Helper Files = helper;
Files.readString(p);
`,
'import java.nio.file.Files;',
),
);
expect(allSinks(matches)).toHaveLength(0);
expect(flows.findings).toHaveLength(0);
});
it('does not report untainted input reaching the file read sink', () => {
const { matches, flows } = analyze(
wrap(
`readString(of(safe));`,
`
import static java.nio.file.Files.readString;
import static java.nio.file.Path.of;
`,
),
);
expect(allSinks(matches)).toHaveLength(1);
expect(flows.findings).toHaveLength(0);
});
it('does not report regular-import path constructors without a file sink', () => {
const { matches, flows } = analyze(
wrap(
`
String p = request.getParameter("path");
Path.of(p);
`,
'import java.nio.file.Path;',
),
);
expect(allSinks(matches)).toHaveLength(0);
expect(flows.findings).toHaveLength(0);
});
});