Files
Open_Ultra_SAST/src/openultrasast/model/taint.py
T
Marius CiepluchandClaude Opus 5.5 03f2ce7162 feat(taint): guards -- a validator or allowlist check makes a value safe where it held
Three of the four fixed-side alerts left in the independent population had one
cause: the fix was a check, and checks were not modelled.
- wger's fix is `url_has_allowed_host_and_scheme`: `if not ...(next_url):
  next_url = reverse(..)` and `if next_url and ...(next_url): return redirect`.
- Ultimate Member's fix is an allowlist: `in_array(strtoupper($order), [...],
  true) ? $order : 'ASC'` and `elseif (in_array($sortby, $allowed, true))`.
(The fourth, Signal K, is `assertAllowedHost`, a project-local function. It is not
covered.)

Sanitizer facts can now say `guard = true`. A guard is a check, never a cleansing
call on a path. A flow is discharged only when a guard tests a variable the path
carries, and the flow is in one of these places:
- inside the branch whose condition requires the check. The guard may be combined
  with `&&` only;
- past a failure test, a single `!` combined with `||` only, whose branch exits
  (return, exit, die, wp_die, throw) or overwrites the value, and whose condition
  dominates the flow's end;
- through a ternary whose condition requires the check, where the other arm cannot
  carry the value. The engine can enter through the condition's own copy of the
  value, which never becomes the result.

Facts: `in_array` (PHP) and Django's `url_has_allowed_host_and_scheme` /
`is_safe_url` (Python). A row now consults guards even when a request lists no
sanitizers.

Two Joern-gated tests cover the discharged shapes and, as controls, an unguarded
redirect, a guard on a different variable, a failure that is only logged, the
unguarded else branch, and the ternary arm the check does not select. Both tests
fail without the guard.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
2026-09-25 16:56:15 +02:00

311 lines
16 KiB
Python

"""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
def request_params(
spec: TaintSpec,
*,
function: str = "",
file: str = "",
parameter_sources: bool = False,
call_depth: int = 0,
hook_callbacks: str = "",
field_parameter_sources: bool | None = None,
) -> dict[str, object]:
"""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.
``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.
"""
return {
"sources": spec.sources,
"sinks": spec.sinks,
"sanitizers": spec.sanitizers,
"function": function,
"file": file,
"parameterSources": "true" if parameter_sources else "false",
# The FIELD join's parameter seeds, apart from the region's own parameters: a constructor handed the
# uploads stores them in a field another method reads (MW WP Form, CVE-2023-6559), whatever kind of
# entry the reading method is. Defaults to the region's own rule, so a caller that sets one sets both.
"fieldParameterSources": "true" if (parameter_sources if field_parameter_sources is None else field_parameter_sources) else "false",
"callDepth": str(call_depth),
"boundedSinks": spec.bounded_sinks,
# 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,
# 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,
"dispatchValue": spec.dispatch_value,
"fixedOrigin": "true" if spec.fixed_origin else "false",
"originAnchors": spec.origin_anchors,
"quotedSanitizers": spec.quoted_sanitizers,
"guards": spec.guards,
}
def verdict(
cpg: CpgResult, spec: TaintSpec, *, function: str = "", file: str = "", parameter_sources: bool = False, call_depth: int = 0
) -> 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.
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.
"""
# 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.
#
# 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.
answers = verdicts(cpg, spec, function=function, file=file, parameter_sources=parameter_sources, call_depth=call_depth)
return answers[0] if answers else None
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),
)
return [flow for flow in _flows(rows, function=function) if not _has_safe_shape(flow, spec)]
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():
# 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)))
# 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"])
rung = Rung.CORROBORATED if settled else Rung.ENTAILED
return Verdict(rung=rung, family=spec.family, witness=_witness(flow), location=_location(flow))
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
# Answer rows carry no `kind`. Anything that names one is a separate export -- the
# operation inventory, an evidence vector, a context location -- and normalizing it
# here would manufacture a flow with an empty source that names no attacker input.
if "kind" in row:
continue
if function and not _in_scope(row, function):
continue
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", "")),
"sinkFile": str(row.get("sinkFile", "")),
"bounded": bool(row.get("bounded", False)),
"bound": str(row.get("bound", "")),
"source": str(row.get("source", "")),
"sanitized": bool(row.get("sanitized", False)),
"sourceKind": str(row.get("sourceKind", "")),
"length": _as_int(row.get("length")),
}
)
return kept
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
def _as_int(value: object) -> int:
return int(value) if isinstance(value, int | float) else 0
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
# 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"})
def _from_modelled_source(flow: Mapping[str, object], spec: TaintSpec) -> bool:
"""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
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"]))
def _witness(flow: Mapping[str, object]) -> str:
line = flow.get("sinkLine") or "?"
where = str(flow.get("sinkFile") or "")
at = f" in {where}" if where else ""
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})"
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 '?'}"
__all__ = ["request_params", "verdict"]