Files
Open_Ultra_SAST/src/openultrasast/model/ladder.py
T
Marius CiepluchandClaude Opus 5 ca7b5ba370 feat(model): a region that spans the flow, not just a function
contributor-scan 2.4, closing the no-go task 2.2 recorded.

VAmPI's SQL injection is a request parameter reaching `get_by_username`
in api_views/users.py, passed to `User.get_user` in models/user_model.py,
interpolated into a query string and executed there. Source and sink are
in different functions and different modules. Regions were per-function,
so neither region saw both ends and the engine was silent on the most
ordinary shape a real injection bug has.

`reachableByFlows` was never the limitation -- it follows calls already.
The limitation was the QUESTION. Sinks were filtered to the labeled
function's own body, so a crossing flow had no sink to end at.

Three changes, each with a reason it is sound rather than convenient:

**An entry point is the trust boundary.** parameter_sources was off for
repository scans because a repository's parameters are not all attacker
input. An entry point's are, by definition -- that is what makes it an
entry point. The driver turns it on only for regions the mapper found and
named, and leaves it off for walker fallbacks and for named functions that
are not entry points.

**Sinks may sit below the handler.** taint.sc admits sinks in methods
reachable from the labeled method through the call graph, bounded at
ENTRY_POINT_CALL_DEPTH = 3: handler -> service -> data access, which is
where these bugs live. Unbounded would make the question about most of the
repository rather than about this entry point, and make it unaffordable.

**The finding names the sink, not the asker.** A crossing flow ends in
another file and another function, so Verdict carries the evidence's own
`path:line:function` and the site uses it. Reporting
`models/user_model.py:73:get_by_username` would name a place that does not
exist: line 73 is inside get_user.

Also: labeled methods are now resolved by name AND file. `get_user` exists
in more than one module of a real repository, which a corpus of one-file
excerpts could never surface.

Measured on the same checkout and budget as 2.2:

  VAmPI-SQLI    missed -> model_entailed at models/user_model.py:73:get_user
  known found   0 of 2 before 2.2's fixes, 1 of 2 in 2.2, 2 of 2 now
  entailed      7 -> 8
  query time    40.9s -> 37.4s
  peak RSS      869MB -> 827MB
  cost          $0.0067, unchanged

Asking the bigger question cost nothing measurable here: file scoping and
call-graph following pull in opposite directions and roughly cancel at this
size. That is one 520-line repository, and VAmPI is now the repository this
change was developed against -- the exact overfitting shape Req 8 exists to
catch. The depth bound and the entry-point rule mean nothing general until
a checkout nobody tuned against says so.

Gates byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
2026-09-09 09:27:19 +02:00

75 lines
3.0 KiB
Python

"""The evidence ladder: what established a finding (model-grounded-detection Req 5).
Every finding carries the rung that established it, so a deterministic model result is never confused with an
LLM suspicion. The two middle rungs are the point of this feature:
suspicion a site was enumerated and something flagged it
model_corroborated the LLM's claim is consistent with the CPG -- the source it names reaches the sink it
names, or a guard its siblings carry is absent
model_entailed the CPG establishes it alone, independent of trusting the LLM
execution_confirmed a sandbox reproduced it (deferred tier, memory/C first)
`model_corroborated` and above are established with no model call, and deterministically: run the checker
twice over the same CPG and the verdict sequence is identical. That is what removes the run-to-run noise the
prior architecture tried to average away.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
from typing import TYPE_CHECKING, TypeVar
if TYPE_CHECKING:
from ..findings import StaticFinding
_Finding = TypeVar("_Finding", bound="StaticFinding")
class Rung(StrEnum):
SUSPICION = "suspicion"
CORROBORATED = "model_corroborated"
ENTAILED = "model_entailed"
EXECUTION_CONFIRMED = "execution_confirmed"
# Ascending strength; `_ORDER.index` is the comparison, so a new rung cannot be silently mis-ranked.
_ORDER = (Rung.SUSPICION, Rung.CORROBORATED, Rung.ENTAILED, Rung.EXECUTION_CONFIRMED)
def at_or_above(rung: Rung, threshold: Rung) -> bool:
return _ORDER.index(rung) >= _ORDER.index(threshold)
@dataclass(frozen=True)
class Verdict:
"""What the model established, and the evidence it established it from."""
rung: Rung
family: str
witness: str = "" # the taint path or the dominance fact; empty only at `suspicion`
contradiction: str = "" # why an LLM claim was dropped, when it was
# ``path:line`` of the evidence, when the arbiter knows it. Once a flow may cross into another module,
# the region that asked the question is no longer where the answer lives, and a finding reported against
# the handler's file when the bug is two modules away is unactionable.
location: str = ""
def at_rung(finding: _Finding, verdict: Verdict | None) -> _Finding:
"""Return ``finding`` carrying ``verdict``'s rung and witness, or unchanged when there is no verdict.
This is the only way a finding rises above ``suspicion`` (Req 5.4). The witness travels with the rung
because a rung without the evidence that justifies it is just a louder assertion.
"""
from dataclasses import replace
if verdict is None or verdict.rung is Rung.SUSPICION:
return finding
rationale = finding.rationale
if verdict.witness and verdict.witness not in rationale:
rationale = f"{rationale} [{verdict.rung.value}: {verdict.witness}]".strip()
return replace(finding, rung=verdict.rung.value, rationale=rationale)
__all__ = ["Rung", "Verdict", "at_or_above", "at_rung"]