mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-18 04:29:27 +00:00
v5 (parameter-fed carried confirmed by the exact flow): pmpro 69, wpstatistics 172, mwwpform 68, vampi 16. Reading the recorded head of each order: every region ahead of a CVE is in its tier on a tied or higher score, and 10 of the 67 ahead of MW WP Form's are test-mail.php. The build's own word on what is product was on the region and not in the vector; it is now, at -2.0 for an unshipped file. Orders only, never promotes. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
210 lines
9.3 KiB
Python
210 lines
9.3 KiB
Python
"""The evidence vector: what a (region, family) pair carries BEFORE any dataflow is asked (flow-aware-ranking).
|
|
|
|
Tiering is the CPG query without dataflow; arbitration is the query with it. Same graph, same facts, two costs.
|
|
This module turns the evidence rows that `taint.sc` emits in `evidenceOnly` mode into one vector per pair,
|
|
and assigns the tier. Every field is stated in fact-table terms -- source, sink, sanitizer -- so a heuristic
|
|
that mentions a language's own syntax has, by construction, left this module.
|
|
|
|
Only tier 0 excludes. Its predicate has a proof -- a family with no sink in reach cannot produce a flow, in any
|
|
language, under any dataflow model -- and it is the only place exactness is required. Everything above orders,
|
|
where being wrong costs position, and `regions_truncated` reports what position cost.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Mapping, Sequence
|
|
from dataclasses import dataclass, field
|
|
|
|
TIER_EXCLUDE = 0 # no sink of this family in reach: never ask
|
|
TIER_NO_SOURCE = 1 # sinks, but nothing untrusted can arrive: ask last
|
|
TIER_CLEANSED = 2 # a source exists, but every sink call is bound or cleansed on the call: ask late
|
|
TIER_OPEN = 3 # a source exists and some sink call is computed and uncleansed: ask first
|
|
TIER_OPEN_PUBLIC = 4 # tier 3 and declared public, or a source on the same statement: ask very first
|
|
|
|
# The within-tier weights, published here because they are the whole of what "heuristic ranking" means in
|
|
# phase 2 and a reader should be able to see every one of them at once. They order, they never exclude.
|
|
WEIGHTS: dict[str, float] = {
|
|
"open_sink": 1.0, # per sink call that is computed, uncleansed and unbound, up to OPEN_SINK_CAP
|
|
"cleansed_sink": -0.5, # per sink call that is cleansed on the call or bound, up to CLEANSED_SINK_CAP
|
|
"source_local": 2.0, # a modelled source in the region's own body
|
|
"carried": 1.5, # stage one of the two-stage join says a field or dispatch value arrives tainted
|
|
"carried_weak": 0.75, # a field assigned from a parameter of its method: every constructor does it
|
|
"source_near": 1.0, # a modelled source within the arbiter's callDepth
|
|
"entry": 0.5, # parameters untrusted by contract
|
|
"access_declared_public": 1.0,
|
|
# A file the build does not ship: tests, fixtures, examples. Measured: 10 of the 67 regions ahead of
|
|
# MW WP Form's CVE were test-mail.php.
|
|
"unshipped": -2.0,
|
|
}
|
|
OPEN_SINK_CAP = 5
|
|
CLEANSED_SINK_CAP = 3
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SinkEvidence:
|
|
"""One sink call of the family, as the graph saw it, with no flow asked."""
|
|
|
|
code: str
|
|
line: int
|
|
method: str
|
|
arity: int
|
|
arg0_literal: bool
|
|
cleansed_on_call: bool # a sanitizer of this family wraps an ARGUMENT -- not merely present in the function
|
|
|
|
@property
|
|
def shape(self) -> str:
|
|
"""``literal`` cannot carry taint; ``computed`` is the residue that can."""
|
|
return "literal" if self.arg0_literal else "computed"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Evidence:
|
|
"""The vector for one (region, family) pair."""
|
|
|
|
sinks: tuple[SinkEvidence, ...] = ()
|
|
family_in_repo: bool = True # False is the repository-wide tier 0, exact at every callDepth
|
|
source_local: bool = False # a modelled source in the region's own body
|
|
source_near: bool = False # a modelled source within the arbiter's callDepth
|
|
entry: bool = False # parameters untrusted by contract
|
|
access_declared_public: bool = False # declared, not inferred -- "no decorator found" is not "public"
|
|
carried: bool | None = None # stage one of the two-stage join, the STRONG form; None where it was not computed
|
|
carried_weak: bool = False # the weak form: a field assigned from a parameter of its method. Orders, never promotes.
|
|
shipped: bool = True # the project's own build declaration names this file (regions.shipped); tests and fixtures are not
|
|
bound_names: tuple[str, ...] = field(default=()) # sink names the shape test treats as bound at arity >= 2
|
|
|
|
@property
|
|
def any_source(self) -> bool:
|
|
return self.source_local or self.source_near or self.entry or bool(self.carried)
|
|
|
|
def is_bound(self, sink: SinkEvidence) -> bool:
|
|
"""The safe-shape rule the arbiter already applies: a named sink with two or more arguments binds."""
|
|
return sink.arity >= 2 and any(name in sink.code for name in self.bound_names)
|
|
|
|
@property
|
|
def open_sinks(self) -> tuple[SinkEvidence, ...]:
|
|
"""Sink calls that could carry an uncleansed, unbound, computed value."""
|
|
return tuple(s for s in self.sinks if s.shape == "computed" and not s.cleansed_on_call and not self.is_bound(s))
|
|
|
|
@property
|
|
def tier(self) -> int:
|
|
if not self.family_in_repo or not self.sinks:
|
|
return TIER_EXCLUDE
|
|
if not self.any_source:
|
|
return TIER_NO_SOURCE
|
|
if not self.open_sinks:
|
|
return TIER_CLEANSED
|
|
if self.access_declared_public or self.source_local or self.carried:
|
|
return TIER_OPEN_PUBLIC
|
|
return TIER_OPEN
|
|
|
|
@property
|
|
def score(self) -> float:
|
|
"""Order WITHIN a tier, by the published weights below. Never across tiers: the tier is the predicate.
|
|
|
|
A weighted sum of the same exact facts the tier is made of, so it can rank but cannot manufacture --
|
|
a pair with no sink scores nothing and stays tier 0 whatever its weights. Counts are capped because
|
|
a file-scope region with 346 sinks is not 346 times more likely to hold the bug than one with 5;
|
|
the cap is where the evidence stops saying more. Phase 2 of flow-aware-ranking.
|
|
"""
|
|
if self.tier == TIER_EXCLUDE:
|
|
return 0.0 # no sink in reach: nothing to weigh, whatever the sources say
|
|
w = WEIGHTS
|
|
return (
|
|
w["open_sink"] * min(len(self.open_sinks), OPEN_SINK_CAP)
|
|
+ w["cleansed_sink"] * min(sum(1 for s in self.sinks if s.cleansed_on_call or self.is_bound(s)), CLEANSED_SINK_CAP)
|
|
+ (w["source_local"] if self.source_local else 0.0)
|
|
+ (w["carried"] if self.carried else 0.0)
|
|
+ (w["carried_weak"] if self.carried_weak and not self.carried else 0.0)
|
|
+ (w["source_near"] if self.source_near else 0.0)
|
|
+ (w["entry"] if self.entry else 0.0)
|
|
+ (w["access_declared_public"] if self.access_declared_public else 0.0)
|
|
+ (w["unshipped"] if not self.shipped else 0.0)
|
|
)
|
|
|
|
@property
|
|
def order_key(self) -> tuple[int, float]:
|
|
"""Higher sorts first: ``sorted(..., key=lambda e: e.order_key, reverse=True)``."""
|
|
return (self.tier, self.score)
|
|
|
|
|
|
def evidence_from_rows(
|
|
rows: Sequence[Mapping[str, object]] | None,
|
|
*,
|
|
entry: bool = False,
|
|
access_declared_public: bool = False,
|
|
carried: bool | None = None,
|
|
bound_names: Sequence[str] = (),
|
|
shipped: bool = True,
|
|
) -> Evidence | None:
|
|
"""Read the query's evidence rows for one request. ``None`` when the query did not answer.
|
|
|
|
A request that answered with no sinks still carries a ``summary`` row -- that is tier 0, and it must be
|
|
distinguishable from a request that failed, which carries nothing.
|
|
"""
|
|
if rows is None:
|
|
return None
|
|
summary = next((r for r in rows if isinstance(r, Mapping) and r.get("kind") == "summary"), None)
|
|
if summary is None:
|
|
return None
|
|
sinks = tuple(
|
|
SinkEvidence(
|
|
code=str(r.get("sink", "")),
|
|
line=_as_int(r.get("sinkLine")),
|
|
method=str(r.get("sinkMethod", "")),
|
|
arity=_as_int(r.get("sinkArity")),
|
|
arg0_literal=_as_bool(r.get("sinkArg0Literal")),
|
|
cleansed_on_call=_as_bool(r.get("cleansedOnCall")),
|
|
)
|
|
for r in rows
|
|
if isinstance(r, Mapping) and r.get("kind") == "sink"
|
|
)
|
|
return Evidence(
|
|
sinks=sinks,
|
|
family_in_repo=_as_bool(summary.get("familyInRepo"), default=True),
|
|
source_local=_as_bool(summary.get("sourceLocal")),
|
|
source_near=_as_bool(summary.get("sourceNear")),
|
|
entry=entry,
|
|
access_declared_public=access_declared_public,
|
|
# The query's own answer when it gave one (phase 2: stage one of the two-stage join, reused);
|
|
# the caller's otherwise; None where neither knows, which is not False.
|
|
carried=(str(summary.get("carriedKind", "")) == "source" or (_as_bool(summary["carried"]) and "carriedKind" not in summary))
|
|
if "carried" in summary
|
|
else carried,
|
|
carried_weak=str(summary.get("carriedKind", "")) == "parameter",
|
|
shipped=shipped,
|
|
bound_names=tuple(bound_names),
|
|
)
|
|
|
|
|
|
def _as_bool(value: object, *, default: bool = False) -> bool:
|
|
"""A JSON boolean, or its string spelling. ``bool("False")`` is True, which would make every unsanitized
|
|
sink read as cleansed the moment a serialiser chose strings -- the kind of silent inversion this project
|
|
has paid for before."""
|
|
if value is None:
|
|
return default
|
|
if isinstance(value, bool):
|
|
return value
|
|
return str(value).strip().lower() in {"true", "1", "yes"}
|
|
|
|
|
|
def _as_int(value: object) -> int:
|
|
try:
|
|
return int(str(value))
|
|
except (TypeError, ValueError):
|
|
return -1
|
|
|
|
|
|
__all__ = [
|
|
"CLEANSED_SINK_CAP",
|
|
"OPEN_SINK_CAP",
|
|
"TIER_CLEANSED",
|
|
"TIER_EXCLUDE",
|
|
"TIER_NO_SOURCE",
|
|
"TIER_OPEN",
|
|
"TIER_OPEN_PUBLIC",
|
|
"WEIGHTS",
|
|
"Evidence",
|
|
"SinkEvidence",
|
|
"evidence_from_rows",
|
|
]
|