2026-09-08 12:23:40 +02:00
|
|
|
"""Taint reachability over the CPG: the arbiter for the flow families (Req 6.1).
|
|
|
|
|
|
|
|
|
|
The verdict is the static analogue of a reproduced crash. An unsanitized source→sink path is `ENTAILED` — the
|
|
|
|
|
graph establishes it and the LLM's claim is no longer load-bearing. A path a sanitizer may break is
|
|
|
|
|
`CORROBORATED`: the flow is real, but whether the sanitizer is *sufficient* is a semantic judgement the model
|
|
|
|
|
cannot make, and that residual is the LLM's — checked against this verdict rather than averaged over runs.
|
|
|
|
|
|
|
|
|
|
No path at all yields `None`, not a negative verdict: the caller reports `suspicion`. And a query that failed
|
|
|
|
|
yields `None` too, because "the engine could not decide" is not "there is no flow" — conflating them is how a
|
|
|
|
|
missing engine would silently become a clean bill of health.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from collections.abc import Mapping, Sequence
|
|
|
|
|
|
|
|
|
|
from ..cpg.backend import CpgResult
|
|
|
|
|
from .ladder import Rung, Verdict
|
|
|
|
|
from .specs import TaintSpec
|
|
|
|
|
|
|
|
|
|
|
2026-09-09 09:27:19 +02:00
|
|
|
def request_params(
|
2026-09-09 20:42:35 +02:00
|
|
|
spec: TaintSpec,
|
|
|
|
|
*,
|
|
|
|
|
function: str = "",
|
|
|
|
|
file: str = "",
|
|
|
|
|
parameter_sources: bool = False,
|
|
|
|
|
call_depth: int = 0,
|
|
|
|
|
hook_callbacks: str = "",
|
2026-09-09 09:27:19 +02:00
|
|
|
) -> dict[str, object]:
|
2026-09-09 07:57:35 +02:00
|
|
|
"""The query parameters this arbiter sends. Shared with the batcher so there is one definition, not two.
|
|
|
|
|
|
|
|
|
|
Two copies of a parameter dict is precisely the duplication that produced five bugs of one shape in the
|
|
|
|
|
predecessor: a change made in one place and not its sibling.
|
2026-09-09 08:46:21 +02:00
|
|
|
|
|
|
|
|
``file`` scopes the answer to one source file. A region with no enclosing function sends ``function=""``,
|
|
|
|
|
which without this matches the whole repository and gets that answer attributed to it -- one permissive
|
|
|
|
|
literal in `app.py` became eight identical entailed findings in eight files that do not contain it. Empty
|
|
|
|
|
means unscoped, which is what the single-region pair path still uses.
|
2026-09-09 07:57:35 +02:00
|
|
|
"""
|
|
|
|
|
return {
|
|
|
|
|
"sources": spec.sources,
|
|
|
|
|
"sinks": spec.sinks,
|
|
|
|
|
"sanitizers": spec.sanitizers,
|
|
|
|
|
"function": function,
|
2026-09-09 08:46:21 +02:00
|
|
|
"file": file,
|
2026-09-09 07:57:35 +02:00
|
|
|
"parameterSources": "true" if parameter_sources else "false",
|
2026-09-09 09:27:19 +02:00
|
|
|
"callDepth": str(call_depth),
|
2026-09-09 13:01:30 +02:00
|
|
|
"boundedSinks": spec.bounded_sinks,
|
2026-09-09 20:42:35 +02:00
|
|
|
# The hook link table, `hook:callback;hook:callback`. Read out of the source text rather than the
|
|
|
|
|
# graph, because php2cpg drops a registration's callback argument entirely -- see
|
|
|
|
|
# `mapping.php_hook_callbacks`. Empty for every language that does not register handlers by string.
|
|
|
|
|
"hookCallbacks": hook_callbacks,
|
2026-09-10 07:32:20 +02:00
|
|
|
# The registry's own vocabulary, from the fact tables. Without it the query would have to know what
|
|
|
|
|
# `apply_filters` is, which makes a general mechanism into a WordPress feature.
|
|
|
|
|
"dispatchApply": spec.dispatch_apply,
|
2026-09-09 07:57:35 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-09-09 09:27:19 +02:00
|
|
|
def verdict(
|
|
|
|
|
cpg: CpgResult, spec: TaintSpec, *, function: str = "", file: str = "", parameter_sources: bool = False, call_depth: int = 0
|
|
|
|
|
) -> Verdict | None:
|
2026-09-08 12:56:13 +02:00
|
|
|
"""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.
|
2026-09-09 09:27:19 +02:00
|
|
|
An ENTRY POINT is the case where both are true at once: its parameters are attacker input by definition,
|
|
|
|
|
which is what the driver uses to decide.
|
|
|
|
|
|
|
|
|
|
``call_depth`` admits sinks that many call-graph levels below the labeled method. Zero keeps the old
|
|
|
|
|
function-local question; above zero is what lets a handler's parameter reach a sink in another module,
|
|
|
|
|
which is the shape VAmPI's SQL injection has and the shape a function-scoped question cannot see.
|
2026-09-08 12:56:13 +02:00
|
|
|
"""
|
2026-09-09 20:42:35 +02:00
|
|
|
# The strongest of the per-site verdicts, which is what this always meant. It used to run its own
|
|
|
|
|
# selection over the union of flows, and two copies of "which flow decides" is the duplication this
|
|
|
|
|
# module has been bitten by before: the per-site preference added to `verdicts` did not reach here, so
|
|
|
|
|
# the two entry points disagreed about the same rows.
|
|
|
|
|
#
|
2026-09-09 13:01:30 +02:00
|
|
|
# A flow the graph cannot see a discharge for is the finding; a sanitized or BOUNDED one is a question.
|
|
|
|
|
# libpng bounds `strcpy(outname+len, ".png")` with `(len = strlen(inname)) > 250` against a char[256],
|
|
|
|
|
# and entailing it is a claim the code contradicts. But the bound reaches the sink through an `error`
|
|
|
|
|
# flag rather than by dominating it, so the graph has not shown the copy is guarded either -- and a
|
|
|
|
|
# bound is no proof of safety in any case, off-by-one being the classic way they fail. Corroborated is
|
|
|
|
|
# the honest rung: the model cannot assert this, so the judge is asked instead of the site being
|
|
|
|
|
# silenced.
|
2026-09-09 20:42:35 +02:00
|
|
|
answers = verdicts(cpg, spec, function=function, file=file, parameter_sources=parameter_sources, call_depth=call_depth)
|
|
|
|
|
return answers[0] if answers else None
|
2026-09-09 14:30:48 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _usable_flows(
|
|
|
|
|
cpg: CpgResult,
|
|
|
|
|
spec: TaintSpec,
|
|
|
|
|
*,
|
|
|
|
|
function: str,
|
|
|
|
|
file: str,
|
|
|
|
|
parameter_sources: bool,
|
|
|
|
|
call_depth: int,
|
|
|
|
|
) -> list[dict[str, object]]:
|
|
|
|
|
"""The flows both entry points reason over, so neither can drift from the other.
|
|
|
|
|
|
|
|
|
|
A sink whose *shape* is safe is the fix, not the bug: `execute(sql, params)` binds rather than
|
|
|
|
|
interpolates and `printf("literal", x)` has a constant format string, so a flow reaching them is not a
|
|
|
|
|
vulnerability even though its taint path is identical to the vulnerable twin's. Dropping those is what
|
|
|
|
|
lets the model distinguish a pair at all -- measured on the injection slice, taint reachability alone
|
|
|
|
|
entailed both sides of the canonical SQL pair and arbitrated nothing.
|
|
|
|
|
|
|
|
|
|
Parameter sources are then a FALLBACK, never an override. When a modelled source reaches the sink those
|
|
|
|
|
flows are the evidence, so a captured `res` parameter also reaching it unsanitized cannot report a fix as
|
|
|
|
|
still vulnerable on the strength of a flow that names no attacker input.
|
|
|
|
|
"""
|
|
|
|
|
rows = cpg.run(
|
|
|
|
|
"taint",
|
|
|
|
|
request_params(spec, function=function, file=file, parameter_sources=parameter_sources, call_depth=call_depth),
|
|
|
|
|
)
|
2026-09-09 20:42:35 +02:00
|
|
|
return [flow for flow in _flows(rows, function=function) if not _has_safe_shape(flow, spec)]
|
2026-09-09 14:30:48 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def verdicts(
|
|
|
|
|
cpg: CpgResult,
|
|
|
|
|
spec: TaintSpec,
|
|
|
|
|
*,
|
|
|
|
|
function: str = "",
|
|
|
|
|
file: str = "",
|
|
|
|
|
parameter_sources: bool = False,
|
|
|
|
|
call_depth: int = 0,
|
|
|
|
|
) -> list[Verdict]:
|
|
|
|
|
"""One verdict per distinct SINK SITE, strongest first.
|
|
|
|
|
|
|
|
|
|
``verdict`` answers "what is the strongest thing to say about this region", which is the right question
|
|
|
|
|
for a pair: one labelled function, one bug, one answer. It is the wrong question for a region that spans
|
|
|
|
|
a file. A PHP file with an SQL injection on line 6 and a command injection on line 13 got ONE injection
|
|
|
|
|
verdict, and `system` won it on flow length -- so the SQL injection went unreported, by construction
|
|
|
|
|
rather than by any failure of the analysis.
|
|
|
|
|
|
|
|
|
|
The first element is exactly what ``verdict`` returns, so the pair path and the committed measurements
|
|
|
|
|
that rest on it are unchanged.
|
|
|
|
|
"""
|
|
|
|
|
flows = _usable_flows(cpg, spec, function=function, file=file, parameter_sources=parameter_sources, call_depth=call_depth)
|
|
|
|
|
if not flows:
|
|
|
|
|
return []
|
|
|
|
|
by_site: dict[str, list[Mapping[str, object]]] = {}
|
|
|
|
|
for flow in flows:
|
|
|
|
|
by_site.setdefault(_location(flow) or str(flow.get("sink", "")), []).append(flow)
|
|
|
|
|
|
|
|
|
|
chosen: list[Mapping[str, object]] = []
|
|
|
|
|
for site_flows in by_site.values():
|
2026-09-09 20:42:35 +02:00
|
|
|
# Parameter sources are a FALLBACK, never an override, and that preference is PER SINK SITE. Applied
|
|
|
|
|
# across the whole region it decides one site's question with another site's evidence: on Paid
|
|
|
|
|
# Memberships Pro, field-sourced flows at lines 671 and 705 displaced the parameter-sourced flow at
|
|
|
|
|
# 936, and CVE-2023-23488 vanished from a region that still reported fifteen other findings.
|
|
|
|
|
preferred = [flow for flow in site_flows if _from_modelled_source(flow, spec)] or list(site_flows)
|
|
|
|
|
open_flows = [flow for flow in preferred if not flow["sanitized"] and not flow["bounded"]]
|
|
|
|
|
chosen.append(min(open_flows or preferred, key=lambda flow: _rank(flow, spec)))
|
2026-09-09 14:30:48 +02:00
|
|
|
# Same total order the single-verdict path uses, so `verdicts(...)[0] == verdict(...)`.
|
|
|
|
|
chosen.sort(key=lambda flow: (bool(flow["sanitized"]) or bool(flow["bounded"]), _rank(flow, spec)))
|
|
|
|
|
return [_verdict_for(flow, spec) for flow in chosen]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _verdict_for(flow: Mapping[str, object], spec: TaintSpec) -> Verdict:
|
|
|
|
|
settled = bool(flow["sanitized"]) or bool(flow["bounded"])
|
2026-09-09 13:01:30 +02:00
|
|
|
rung = Rung.CORROBORATED if settled else Rung.ENTAILED
|
2026-09-09 14:30:48 +02:00
|
|
|
return Verdict(rung=rung, family=spec.family, witness=_witness(flow), location=_location(flow))
|
2026-09-08 12:23:40 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _flows(rows: object, *, function: str) -> list[dict[str, object]]:
|
|
|
|
|
"""The query's rows, filtered to the labeled function. A non-list (a failure) yields nothing."""
|
|
|
|
|
if not isinstance(rows, Sequence) or isinstance(rows, (str, bytes)):
|
|
|
|
|
return []
|
|
|
|
|
kept: list[dict[str, object]] = []
|
|
|
|
|
for row in rows:
|
|
|
|
|
if not isinstance(row, Mapping):
|
|
|
|
|
continue
|
2026-09-08 16:24:23 +02:00
|
|
|
if function and not _in_scope(row, function):
|
2026-09-08 12:23:40 +02:00
|
|
|
continue
|
2026-09-08 19:46:58 +02:00
|
|
|
kept.append(
|
|
|
|
|
{
|
|
|
|
|
"sink": str(row.get("sink", "")),
|
|
|
|
|
"sinkArity": row.get("sinkArity"),
|
|
|
|
|
"sinkArg0Literal": row.get("sinkArg0Literal"),
|
|
|
|
|
"inLabeledScope": row.get("inLabeledScope"),
|
|
|
|
|
"sinkLine": str(row.get("sinkLine", "")),
|
|
|
|
|
"sinkMethod": str(row.get("sinkMethod", "")),
|
2026-09-09 09:27:19 +02:00
|
|
|
"sinkFile": str(row.get("sinkFile", "")),
|
2026-09-09 13:01:30 +02:00
|
|
|
"bounded": bool(row.get("bounded", False)),
|
|
|
|
|
"bound": str(row.get("bound", "")),
|
2026-09-08 19:46:58 +02:00
|
|
|
"source": str(row.get("source", "")),
|
|
|
|
|
"sanitized": bool(row.get("sanitized", False)),
|
2026-09-09 20:42:35 +02:00
|
|
|
"sourceKind": str(row.get("sourceKind", "")),
|
2026-09-08 19:46:58 +02:00
|
|
|
"length": _as_int(row.get("length")),
|
|
|
|
|
}
|
|
|
|
|
)
|
2026-09-08 12:23:40 +02:00
|
|
|
return kept
|
|
|
|
|
|
|
|
|
|
|
2026-09-08 12:51:42 +02:00
|
|
|
def _has_safe_shape(flow: Mapping[str, object], spec: TaintSpec) -> bool:
|
|
|
|
|
"""Does this flow end at a sink call written in the form the spec declares safe?
|
|
|
|
|
|
|
|
|
|
Only sinks named in ``safe_shape_sinks`` are subject to the test -- ``os.system(x)`` is not safe merely
|
|
|
|
|
because it takes one argument. A row that carries no shape fields (an engine that could not report arity)
|
|
|
|
|
is judged on the flow alone: unknown shape must never be read as a safe one.
|
|
|
|
|
"""
|
|
|
|
|
if not spec.safe_shape_sinks:
|
|
|
|
|
return False
|
|
|
|
|
sink = str(flow.get("sink", ""))
|
|
|
|
|
if not any(name in sink for name in spec.safe_shape_sinks):
|
|
|
|
|
return False
|
|
|
|
|
arity = flow.get("sinkArity")
|
|
|
|
|
literal = flow.get("sinkArg0Literal")
|
|
|
|
|
if arity is None and literal is None:
|
|
|
|
|
return False # shape unknown -> judge on the flow, never assume safety
|
|
|
|
|
bound = isinstance(arity, int | float) and int(arity) >= 2
|
|
|
|
|
constant_format = literal is True
|
|
|
|
|
return bound or constant_format
|
|
|
|
|
|
|
|
|
|
|
2026-09-08 12:23:40 +02:00
|
|
|
def _as_int(value: object) -> int:
|
|
|
|
|
return int(value) if isinstance(value, int | float) else 0
|
|
|
|
|
|
|
|
|
|
|
2026-09-08 16:24:23 +02:00
|
|
|
def _in_scope(row: Mapping[str, object], function: str) -> bool:
|
|
|
|
|
"""Is this sink inside the labeled function, counting closures nested within it?
|
|
|
|
|
|
|
|
|
|
JavaScript is callback-heavy, so the method containing a sink routinely is not the labeled function --
|
|
|
|
|
`fs.readFileSync` inside the callback passed to `fs.stat` has enclosing method `<lambda>0`. The query
|
|
|
|
|
decides containment by line range and reports it here; a row that carries no flag falls back to the name
|
|
|
|
|
comparison, because an engine that cannot report scope must not have its sinks silently treated as scoped.
|
|
|
|
|
"""
|
|
|
|
|
flag = row.get("inLabeledScope")
|
|
|
|
|
if isinstance(flag, bool):
|
|
|
|
|
return flag
|
|
|
|
|
return str(row.get("sinkMethod", "")) == function
|
|
|
|
|
|
|
|
|
|
|
2026-09-09 20:42:35 +02:00
|
|
|
# A source the model VOUCHES for, as opposed to a bare captured parameter. The two-stage joins produce the
|
|
|
|
|
# first kind without producing a superglobal: a hook source reads as `$current_page["id"]` and a field source
|
|
|
|
|
# as `$this->attachments`, neither of which matches a source pattern, yet both were established by a flow the
|
|
|
|
|
# query resolved from a real one.
|
|
|
|
|
_MODELLED_KINDS = frozenset({"framework", "field", "hook"})
|
|
|
|
|
|
|
|
|
|
|
2026-09-08 16:24:23 +02:00
|
|
|
def _from_modelled_source(flow: Mapping[str, object], spec: TaintSpec) -> bool:
|
2026-09-09 20:42:35 +02:00
|
|
|
"""Does this flow start at something the source model names, rather than a bare captured parameter?
|
|
|
|
|
|
|
|
|
|
`sourceKind` is the engine's own answer and wins when present. Before it existed this was decided by
|
|
|
|
|
matching the source's TEXT against the source patterns, which cannot see the difference between a hook
|
|
|
|
|
source and a parameter -- both are just identifiers by then. On WP Statistics that cost the CVE: seven
|
|
|
|
|
sanitized `$_SERVER["REQUEST_URI"]` flows counted as modelled, the unsanitized hook flow did not, and
|
|
|
|
|
`modelled or flows` in `_usable_flows` discarded the finding between the query and the verdict.
|
|
|
|
|
"""
|
|
|
|
|
kind = str(flow.get("sourceKind", ""))
|
|
|
|
|
if kind:
|
|
|
|
|
return kind in _MODELLED_KINDS
|
2026-09-08 16:24:23 +02:00
|
|
|
source = str(flow["source"])
|
|
|
|
|
return any(pattern in source for pattern in spec.sources)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _rank(flow: Mapping[str, object], spec: TaintSpec) -> tuple[int, int, str, str]:
|
|
|
|
|
"""A flow from a modelled source first, then shortest, then lexicographic — a total order.
|
|
|
|
|
|
|
|
|
|
Shortest alone picks the wrong witness once closures are in scope: a captured `res` reaches the sink in
|
|
|
|
|
three steps while the actual `req.url -> ... -> possibleFilename` path takes eleven, and reporting the
|
|
|
|
|
former names no attacker input at all. Naming a real source is what makes a witness evidence rather than
|
|
|
|
|
an assertion, so it outranks brevity.
|
|
|
|
|
"""
|
|
|
|
|
source = str(flow["source"])
|
|
|
|
|
rank_modelled = 0 if _from_modelled_source(flow, spec) else 1
|
|
|
|
|
return (rank_modelled, _as_int(flow["length"]), source, str(flow["sink"]))
|
2026-09-08 12:23:40 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _witness(flow: Mapping[str, object]) -> str:
|
|
|
|
|
line = flow.get("sinkLine") or "?"
|
2026-09-09 09:27:19 +02:00
|
|
|
where = str(flow.get("sinkFile") or "")
|
|
|
|
|
at = f" in {where}" if where else ""
|
2026-09-09 13:01:30 +02:00
|
|
|
bound = str(flow.get("bound") or "")
|
|
|
|
|
governed = f", governed by `{bound}`" if flow.get("bounded") and bound else ""
|
|
|
|
|
return f"{flow['source']} -> {flow['sink']}{at} (line {line}, {flow['length']} steps{governed})"
|
2026-09-09 09:27:19 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _location(flow: Mapping[str, object]) -> str:
|
|
|
|
|
"""``path:line:function`` of the SINK, when the engine reported a file. Empty leaves the region's own.
|
|
|
|
|
|
|
|
|
|
All three parts come from the sink, not from the region that asked. A flow that crosses modules ends in
|
|
|
|
|
a different file AND a different function, and naming the entry point beside the sink's line describes a
|
|
|
|
|
place that does not exist -- `models/user_model.py:73:get_by_username`, where line 73 is inside
|
|
|
|
|
`get_user`.
|
|
|
|
|
"""
|
|
|
|
|
where = str(flow.get("sinkFile") or "")
|
|
|
|
|
line = str(flow.get("sinkLine") or "")
|
|
|
|
|
if not where or not line.lstrip("-").isdigit() or int(line) < 0:
|
|
|
|
|
return ""
|
|
|
|
|
return f"{where}:{line}:{flow.get('sinkMethod') or '?'}"
|
2026-09-08 12:23:40 +02:00
|
|
|
|
|
|
|
|
|
2026-09-09 07:57:35 +02:00
|
|
|
__all__ = ["request_params", "verdict"]
|