Files
Open_Ultra_SAST/src/openultrasast/model/dominance.py
T
Marius CiepluchandClaude Opus 5 c0427e6ceb feat(access-control): model the collection read, by argument shape rather than by name
NodeGoat's documented insecure direct object reference reads with
allocationsCol.find(searchCriteria()), and `find` was absent from an operation table holding
findOne, findById, findAll and findByPk. The obligation was missing at the commonest read there
is, so that defect raised nothing at any point in the pipeline.

It was absent for a good reason, and the reason is the argument, not the name. Measured on
wp-statistics, a WordPress plugin that ships jQuery: its assets hold 88 calls named find, of
which 70 pass a jQuery selector, 13 pass a predicate to Array.prototype.find and 5 pass a
selector built at run time by a template literal or a concatenation. None is a collection read.

So the obligation is claimed only where the first argument has the shape of a query document.
The operation fact states which argument with query_document_arg, and the arbiter that can see
argument shapes decides it: no arguments at all, an object literal, or a builder call are
claimed; a method reference, a literal, an operator call and a bare identifier are not. Each
exclusion is one of the measured classes above rather than a guess.

Zero of those 88 calls became an obligated operation, and the zero is measured rather than
empty: the same run's census reports 60 files and 4,552 methods, and the files holding those
calls are in it.

On NodeGoat all three find calls are collection reads and all three are claimed, which raises
the obligation at four more handlers -- the allocations handler that takes userId from req.params
on an authenticated route, the benefits pages whose isAdmin guard is commented out in the route
table, and the memo listing. Seven of fifteen handlers now reach a verdict, all corroborated,
none reported without a judge, and the census is unchanged on both subjects.

A bare identifier is deliberately not claimed: col.find(query) and arr.find(pred) are the same
shape and neither repository measured holds one, so this is a stated false negative rather than
an unmeasured false-positive risk.

1,415 tests pass, sixteen skipped; twelve Joern-gated query tests pass in the image.

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

169 lines
8.9 KiB
Python

"""Guard dominance: the arbiter for the bugs that never crash (model-grounded-detection, Req 6.2).
An access-control bug is the *absence* of a guard. There is no flow to follow and no crash to reproduce, so
neither taint reachability nor an execution oracle reaches this class at all — which is precisely the gap
Clearwing's crash ladder leaves open and this project exists to close.
What the model *can* establish is a consistency violation: an obligated operation that no discharging guard
governs, in a file where its siblings are governed by one. That asymmetry is the evidence, and it is
deterministic — no model call, and the same verdict on every run over one CPG.
The rungs are deliberately asymmetric:
ENTAILED unguarded here, guarded on a sibling — the file contradicts itself, and the model can say so
CORROBORATED unguarded here, and no sibling is guarded either — the obligation is real but there is
nothing to be inconsistent *with*. A whole module may be unauthenticated by design, and
calling that entailed would assert a policy the model cannot see.
(none) guarded, or no obligated operation at all
"""
from __future__ import annotations
import json
from collections.abc import Mapping, Sequence
from ..cpg.backend import CpgResult
from .ladder import Rung, Verdict
from .specs import DominanceSpec
def request_params(spec: DominanceSpec, *, function: str = "", file: str = "") -> dict[str, object]:
"""The query parameters this arbiter sends. Shared with the batcher; see the note in `taint.request_params`.
``operationRequires`` and ``dischargersByKind`` carry the facts' own relation as JSON, so the engine can
ask whether a guard discharges THIS obligation rather than whether it discharges anything. ``file`` scopes
the rows: the sibling comparison is a claim about one file contradicting itself, and two functions of the
same name in different modules were otherwise pooled into one region's answer.
"""
return {
"operations": spec.operations,
"dischargers": spec.dischargers,
"function": function,
"file": file,
# The calls whose obligation depends on the argument's shape rather than on the name alone.
"documentShape": ",".join(spec.document_shape),
"operationRequires": json.dumps({k: list(v) for k, v in spec.requirements.items()}, sort_keys=True) if spec.requirements else "",
"dischargersByKind": json.dumps({k: list(v) for k, v in spec.dischargers_by_kind.items()}, sort_keys=True)
if spec.dischargers_by_kind
else "",
}
def verdict(cpg: CpgResult, spec: DominanceSpec, *, function: str = "", file: str = "") -> Verdict | None:
"""The verdict for the obligated operation in ``function``, or ``None`` when there is nothing to say."""
rows = cpg.run("dominance", request_params(spec, function=function, file=file))
operations = _operations(rows)
if not operations:
return None
here = [row for row in operations if not function or row["opMethod"] == function]
if not here:
return None
unguarded = [row for row in here if not row["guards"]]
if not unguarded:
return None # every operation in this function is governed; nothing to report
# Siblings are the obligated operations *elsewhere* in the graph. Sorted so the witness is stable.
siblings = sorted(
(row for row in operations if row["opMethod"] not in {r["opMethod"] for r in here}),
key=lambda row: (row["opMethod"], row["opLine"]),
)
# Count guarded sibling *methods*, not rows: one method can hold several obligated operations, and
# naming it twice makes the witness read as more corroboration than the file actually contains.
guarded_methods = sorted({str(row["opMethod"]) for row in siblings if row["guards"]})
target = min(unguarded, key=lambda row: (row["opMethod"], row["opLine"]))
where = _where(target)
# A carried obligation does not ENTAIL, however clean the asymmetry looks.
#
# The rung's justification is that one FILE contradicts itself: same file, same trust context, so an
# unguarded operation beside a guarded one is evidence. Once the obligation is carried across a module
# boundary, the operations being compared live in a data-access module that handlers of DIFFERENT trust
# contexts share, and a route file mixing public with authenticated handlers is the normal shape -- so the
# asymmetry stops implying inconsistency.
#
# Measured on NodeGoat, where this was not hypothetical: `handleSignup` looks a user up by name to check
# the name is free, which is public by design, and two authenticated siblings in the same file pass
# `req.session.userId` into the same module. The asymmetry is real and the defect is not. That question --
# does this operation require a check, or is the route legitimately public? -- is exactly what the
# corroborated band's residual asks, so a carried row is capped there and the judge decides it.
if guarded_methods and not target.get("viaLine"):
witness = (
f"{where}: no discharging guard, while {len(guarded_methods)} sibling handler(s) are guarded ({', '.join(guarded_methods[:3])})"
)
return Verdict(rung=Rung.ENTAILED, family=spec.family, witness=witness, location=_location(target))
if guarded_methods:
witness = (
f"{where}: no discharging guard, while {len(guarded_methods)} sibling handler(s) reaching the same "
f"module are guarded ({', '.join(guarded_methods[:3])}); the obligation was carried across a module "
f"boundary, so those siblings need not share this route's trust context"
)
return Verdict(rung=Rung.CORROBORATED, family=spec.family, witness=witness, location=_location(target))
witness = f"{where}: obligated operation with no discharging guard, and no guarded sibling"
return Verdict(rung=Rung.CORROBORATED, family=spec.family, witness=witness, location=_location(target))
def _where(row: Mapping[str, object]) -> str:
"""The sites a reader has to open. Two of them when the obligation crossed a module boundary.
An application that puts its route in one file and its query in another raised no obligation at all until
the query followed the call, and a witness that named only the operation would send a contributor to a
data-access module that is not where the decision was made. Naming only the handler would hide what the
claim is about. So a carried row names both, in the order a reader needs them.
"""
method = str(row["opMethod"])
via_line = str(row.get("viaLine") or "")
if not via_line:
return f"{method} line {row['opLine']}"
call = str(row.get("viaCall") or "").strip()
return f"{method} line {via_line} calls {call}, whose operation at {row.get('opFile')}:{row['opLine']}"
def _location(row: Mapping[str, object]) -> str:
"""``path:line:function`` of the obligated operation, when the engine reported a file.
Taint and config verdicts have always carried this; dominance did not, so every access-control finding
reached a contributor as `api_views/users.py:?:update_password` -- the right file by accident, because
the region was asked about that file, and no line at all.
"""
# A carried obligation is located at the CALL, not at the operation: that is the line the contributor
# changed and the line an admission decision has to be attributable to. The operation's own site travels
# in the witness.
carried_line = str(row.get("viaLine") or "")
if carried_line.lstrip("-").isdigit() and int(carried_line) >= 0 and row.get("viaFile"):
return f"{row['viaFile']}:{carried_line}:{row.get('opMethod') or '?'}"
where = str(row.get("opFile") or "")
line = str(row.get("opLine") or "")
if not where or not line.lstrip("-").isdigit() or int(line) < 0:
return ""
return f"{where}:{line}:{row.get('opMethod') or '?'}"
def _operations(rows: object) -> list[dict[str, object]]:
"""The query's rows. A non-list (an engine failure) yields nothing, never a false absence."""
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
guards = row.get("dominatingGuards")
kept.append(
{
"operation": str(row.get("operation", "")),
"opLine": str(row.get("opLine", "")),
"opMethod": str(row.get("opMethod", "")),
"opFile": str(row.get("opFile", "")),
"viaCall": str(row.get("viaCall", "")),
"viaLine": str(row.get("viaLine", "")),
"viaFile": str(row.get("viaFile", "")),
"guards": [str(g) for g in guards] if isinstance(guards, Sequence) and not isinstance(guards, str) else [],
}
)
return kept
__all__ = ["request_params", "verdict"]