mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
* 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>
341 lines
11 KiB
TypeScript
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);
|
|
});
|
|
});
|