diff --git a/src/openultrasast/cpg/queries/taint.sc b/src/openultrasast/cpg/queries/taint.sc index 67451d3..88e5f56 100644 --- a/src/openultrasast/cpg/queries/taint.sc +++ b/src/openultrasast/cpg/queries/taint.sc @@ -15,12 +15,20 @@ // * `reachableByFlows` is called ON the sink WITH the source, not the other way about. // // Parameters (comma-separated where plural): -// cpgFile, sources, sinks, sanitizers (optional), function (optional: restrict to this enclosing method) +// cpgFile, sources, sinks, sanitizers (optional), function (optional: restrict to this enclosing method), +// parameterSources ("true" to treat the labeled function's parameters as untrusted -- see below) // // Output: a fenced JSON array of {sink, sinkLine, sinkMethod, source, sanitized, length}. The fence exists // because Joern prints a banner, pass logs and a prompt around whatever a script emits. -@main def exec(cpgFile: String, sources: String, sinks: String, sanitizers: String = "", function: String = "") = { +@main def exec( + cpgFile: String, + sources: String, + sinks: String, + sanitizers: String = "", + function: String = "", + parameterSources: String = "false" +) = { importCpg(cpgFile) def split(raw: String): List[String] = raw.split(",").map(_.trim).filter(_.nonEmpty).toList @@ -32,7 +40,22 @@ // A source is any call whose code contains one of the patterns. Matching on `code` rather than a method // name keeps `request.args["x"]` (an indexAccess over a fieldAccess) and `req.body.name` both reachable // without a per-framework rule for each. - def sourceNodes = cpg.call.filter(c => sourcePatterns.exists(p => c.code.contains(p))) + // Framework sources: a call whose code contains one of the patterns. Matching on `code` rather than a + // method name keeps `request.args["x"]` (an indexAccess over a fieldAccess) and `req.body.name` both + // reachable without a per-framework rule for each. + def frameworkSources = cpg.call.filter(c => sourcePatterns.exists(p => c.code.contains(p))) + + // Parameter sources: the labeled function's own parameters. In a function-level pair the function boundary + // IS the trust boundary -- the corpus is built so the labeled function's inputs are attacker-controlled -- + // and 39 of this slice's 50 pairs carry no framework token at all. Our flat-IR baseline counted these as + // sources (`source_kinds: ["parameter"]`), so a comparison against it is only like-for-like with them on. + // Off by default: outside a labeled function, treating every parameter as untrusted is not sound. + def parameterNodes = + if (parameterSources != "true") Iterator.empty + else if (function.isEmpty) cpg.method.parameter.iterator + else cpg.method.nameExact(function).parameter.iterator + + def sourceNodes = frameworkSources.l.iterator ++ parameterNodes // A sink call: matched by short name (`system`), by leading code (`os.system(...)`), or by resolved // full name (`os.py:.system`), so both bare and dotted forms in the spec hit. diff --git a/src/openultrasast/model/taint.py b/src/openultrasast/model/taint.py index 15d9367..6001866 100644 --- a/src/openultrasast/model/taint.py +++ b/src/openultrasast/model/taint.py @@ -19,11 +19,22 @@ from .ladder import Rung, Verdict from .specs import TaintSpec -def verdict(cpg: CpgResult, spec: TaintSpec, *, function: str = "") -> Verdict | None: - """The strongest verdict the taint query supports for ``spec`` in ``function``, or ``None`` to stay at suspicion.""" +def verdict(cpg: CpgResult, spec: TaintSpec, *, function: str = "", parameter_sources: bool = False) -> Verdict | None: + """The strongest verdict the taint query supports for ``spec`` in ``function``, or ``None`` to stay at suspicion. + + ``parameter_sources`` additionally treats the labeled function's own parameters as untrusted. That is right + for a function-level pair, where the function boundary *is* the trust boundary, and wrong for a whole + repository, where most parameters carry internal values — so it is off by default and the caller opts in. + """ rows = cpg.run( "taint", - {"sources": spec.sources, "sinks": spec.sinks, "sanitizers": spec.sanitizers, "function": function}, + { + "sources": spec.sources, + "sinks": spec.sinks, + "sanitizers": spec.sanitizers, + "function": function, + "parameterSources": "true" if parameter_sources else "false", + }, ) flows = _flows(rows, function=function) # A sink whose *shape* is safe is the fix, not the bug. `execute(sql, params)` binds rather than diff --git a/tests/test_model_taint.py b/tests/test_model_taint.py index be68e55..71ca1b3 100644 --- a/tests/test_model_taint.py +++ b/tests/test_model_taint.py @@ -182,3 +182,25 @@ def test_a_row_without_shape_fields_is_judged_on_the_flow_alone() -> None: "sanitized": False, "length": 3}] answer = verdict(_cpg(rows), _shape_spec(), function="run") assert answer is not None and answer.rung is Rung.ENTAILED + + +def test_parameter_sources_are_opt_in_and_reach_the_query() -> None: + """A function-level pair's parameters are its trust boundary; a whole repository's are not. + + 39 of the 50 injection pairs carry no framework source token at all — their untrusted input arrives as a + function parameter — and the flat-IR baseline this feature is measured against counted those as sources. + So the flag must exist and must default to off. + """ + from openultrasast.cpg.backend import CpgResult + from openultrasast.model.taint import verdict + + seen: dict[str, object] = {} + + def run(query: str, params: dict[str, object]) -> object: + seen.update(params) + return [] + + verdict(CpgResult(cpg_path=Path("c.bin"), run=run), _spec(), function="run") + assert seen["parameterSources"] == "false", "treating every parameter as untrusted is not the default" + verdict(CpgResult(cpg_path=Path("c.bin"), run=run), _spec(), function="run", parameter_sources=True) + assert seen["parameterSources"] == "true"