mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 19:59:26 +00:00
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
311 lines
16 KiB
Python
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"]
|