mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 03:49:26 +00:00
feat(model-grounded-detection): 4.1 guard dominance, 4.2 the missing sink facts
4.1 is the arbiter for the class no crash oracle reaches. An access-control bug
is the ABSENCE of a guard: no flow to follow, no crash to reproduce. What the
model can establish is a consistency violation -- an obligated operation no
discharging guard governs, in a file where its siblings are governed by one.
"Governed" turned out not to be one relation, and asking pure CFG dominance of
the operation gets two of three shapes wrong. Measured against the real engine:
identity constraint the guard is INSIDE the operation's arguments
filter_by(owner_id = current_user.id)
identity branch abort(403).controlledBy == [note.owner_id != current_user.id]
-- the check runs AFTER the fetch, so it never dominates
the operation, but it controls whether the value escapes
dominating guard a discharger-naming call that dominates the operation
That is why the query uses controlledBy rather than dominatedBy for shape 2.
Two bugs found by running it, both the same class as the sanitizer bug in group
2 -- part of a fact flattened, the part carrying the signal dropped:
* dominance_specs used only `calls` and `decorators`, omitting
identity_sources (current_user, request.user) and constraint_params
(owner_id, user_id) -- which is how an identity constraint is actually
written. The arbiter reported "no guard" on handlers that plainly had one.
request_sources and permissive_values stay excluded: they describe the
vulnerable shape, and including them would let the arbiter read a bug as
its own fix.
* matching operations by code containment yielded four or five desugared rows
per operation (the bare `tmp1.filter_by` receiver among them, carrying none
of the guards the real call has), so a guarded operation could be flagged.
Matching is by call name now.
Live, deterministic, zero model calls:
leaky -> model_entailed no discharging guard, while 2 sibling handlers are guarded
edit_note -> no finding (ownership check controls the denial)
list_notes -> no finding (identity constraint in the query)
4.2 authors the sink facts the go/no-go measured as its largest miss class (11
of 23): Django ORM raw SQL, and the four families that previously had no fact
routing to them by CWE at all -- path, output_encoding, untrusted_destination,
prototype. Each entry is a documented dangerous API of the named library with
its provenance recorded inline, and rule_ids left empty because a sink fact is
not a detection rule. Flagged for maintainer review as data, given the standing
"no LLM-authored patterns" constraint. The exact-set assertion written in task
1.1 to hold this gap open is updated rather than deleted, so it still fails if a
family ever loses its seed.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
This commit is contained in:
co-authored by
Claude Opus 5
parent
b8f8f727c6
commit
fec67253f3
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"counts": {
|
||||
"load_bearing": 80,
|
||||
"load_bearing": 81,
|
||||
"standalone_capability": 7
|
||||
},
|
||||
"generated": "2026-09-08 (regenerated after group 3 added the ladder and the judge)",
|
||||
"generated": "2026-09-08 (regenerated after group 4 added the dominance arbiter)",
|
||||
"kept_with_a_lever_stripped": {
|
||||
"improve/evolve.py + improve/validator.py": "the design's Removed list named these whole, but each is a mix: the mechanism lever (removed) and the deterministic rule-status improve loop that `ousast improve` still runs from benchmark evidence, which has no LLM in it and is not what this feature supersedes. Deleting the files would have taken a retained capability with them."
|
||||
},
|
||||
"modules": 87,
|
||||
"modules": 88,
|
||||
"note": "Classification of every source module after the group-1 subtraction. load_bearing = reachable from a gate, the CLI, or named by a surviving requirement; standalone_capability = its own entry point or protocol, deliberately off the scan path; orphaned = no importer, no entry point, no surviving requirement -- removed, never carried.",
|
||||
"orphaned": [],
|
||||
"removed_in_this_pass": {
|
||||
@@ -128,6 +128,7 @@
|
||||
"classification": "load_bearing",
|
||||
"importers": [
|
||||
"cpg.backend",
|
||||
"model.dominance",
|
||||
"model.judge",
|
||||
"model.taint"
|
||||
],
|
||||
@@ -137,6 +138,7 @@
|
||||
{
|
||||
"classification": "load_bearing",
|
||||
"importers": [
|
||||
"model.dominance",
|
||||
"model.judge",
|
||||
"model.taint"
|
||||
],
|
||||
@@ -324,6 +326,7 @@
|
||||
"importers": [
|
||||
"cli",
|
||||
"model.candidates",
|
||||
"model.dominance",
|
||||
"model.judge",
|
||||
"model.specs",
|
||||
"model.taint"
|
||||
@@ -345,6 +348,12 @@
|
||||
"module": "model.candidates",
|
||||
"reason": "reachable from gate, map_gate, pair_gate or the CLI"
|
||||
},
|
||||
{
|
||||
"classification": "load_bearing",
|
||||
"importers": [],
|
||||
"module": "model.dominance",
|
||||
"reason": "model-grounded-detection Req 6.2: guard dominance, the arbiter for bugs that never crash"
|
||||
},
|
||||
{
|
||||
"classification": "load_bearing",
|
||||
"importers": [],
|
||||
@@ -360,6 +369,7 @@
|
||||
{
|
||||
"classification": "load_bearing",
|
||||
"importers": [
|
||||
"model.dominance",
|
||||
"model.judge",
|
||||
"model.taint"
|
||||
],
|
||||
@@ -369,6 +379,7 @@
|
||||
{
|
||||
"classification": "load_bearing",
|
||||
"importers": [
|
||||
"model.dominance",
|
||||
"model.judge",
|
||||
"model.taint"
|
||||
],
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// Guard dominance for the absence families (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.
|
||||
// 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.
|
||||
//
|
||||
// "Governed" is not one relation, because a guard discharges an obligation in three different shapes, and
|
||||
// asking pure CFG dominance of the operation gets two of them wrong:
|
||||
//
|
||||
// 1. identity constraint the guard is INSIDE the operation's own arguments
|
||||
// Note.query.filter_by(owner_id = current_user.id)
|
||||
// 2. identity branch the method branches on identity, and the denial is controlled by that condition
|
||||
// note = ...filter_by(id=nid); if note.owner_id != current_user.id: abort(403)
|
||||
// (measured: abort(403).controlledBy == [note.owner_id != current_user.id])
|
||||
// 3. dominating guard a call naming a discharger that dominates the operation -- a check before it
|
||||
//
|
||||
// Shape 2 is why `controlledBy` is used rather than `dominatedBy`: the ownership check runs AFTER the fetch,
|
||||
// so it never dominates the operation, but it does control whether the value escapes.
|
||||
//
|
||||
// Parameters: cpgFile, operations, dischargers, function (optional; reported, never used to filter, because
|
||||
// the sibling comparison needs every operation in the CPG).
|
||||
//
|
||||
// Output: a fenced JSON array of {operation, opLine, opMethod, dominatingGuards}.
|
||||
|
||||
@main def exec(cpgFile: String, operations: String, dischargers: String, function: String = "") = {
|
||||
importCpg(cpgFile)
|
||||
|
||||
def split(raw: String): List[String] = raw.split(",").map(_.trim).filter(_.nonEmpty).toList
|
||||
|
||||
val opNames = split(operations)
|
||||
val guardTokens = split(dischargers)
|
||||
|
||||
def mentionsGuard(code: String): Boolean = guardTokens.exists(g => code.contains(g))
|
||||
|
||||
// Match by CALL NAME, never by code containment: the frontend desugars `Note.query.filter_by(x).first()`
|
||||
// into a chain of temporaries, and matching on code yields four or five rows for one operation -- some of
|
||||
// them the bare `tmp1.filter_by` receiver, which carries none of the guards the real call does. A `leaky`
|
||||
// verdict computed over those extra rows would flag a guarded operation. A dotted spec (`query.get`)
|
||||
// matches on its trailing segment, which is how the call is named in the graph.
|
||||
def matchesOperation(name: String): Boolean =
|
||||
opNames.exists(n => name == n || name == n.split("\\.").last)
|
||||
|
||||
val opCalls = cpg.call.filter(c => matchesOperation(c.name)).l
|
||||
|
||||
val rows = opCalls.map { op =>
|
||||
val method = op.method
|
||||
|
||||
// 1. identity constraint: a discharger inside the operation's own arguments.
|
||||
val inArguments = op.argument.code.l.filter(mentionsGuard)
|
||||
|
||||
// 2. identity branch: a control structure in this method whose condition names a discharger. The denial
|
||||
// it guards need not dominate the operation -- it controls whether the fetched value escapes.
|
||||
val inCondition = method.controlStructure.condition.code.l.filter(mentionsGuard)
|
||||
|
||||
// 3. dominating guard: a call naming a discharger that actually dominates the operation.
|
||||
val dominating = op.dominatedBy.isCall.code.l.filter(mentionsGuard)
|
||||
|
||||
val guards = (inArguments ++ inCondition ++ dominating).distinct.map(_.take(120))
|
||||
|
||||
ujson.Obj(
|
||||
"operation" -> op.code.take(200),
|
||||
"opLine" -> op.lineNumber.getOrElse(-1).toString,
|
||||
"opMethod" -> method.name,
|
||||
"dominatingGuards" -> ujson.Arr(guards.map(ujson.Str(_)): _*)
|
||||
)
|
||||
}
|
||||
|
||||
println("---OUSAST-CPG-BEGIN---")
|
||||
println(ujson.write(ujson.Arr(rows: _*)))
|
||||
println("---OUSAST-CPG-END---")
|
||||
}
|
||||
@@ -34,6 +34,7 @@ SPEC_OWNED = {
|
||||
"model.ladder": "model-grounded-detection Req 5: the evidence ladder every finding carries",
|
||||
"model.taint": "model-grounded-detection Req 6.1: taint reachability over the CPG, the flow-family arbiter",
|
||||
"model.judge": "model-grounded-detection Req 8: one bounded question, checked against the model",
|
||||
"model.dominance": "model-grounded-detection Req 6.2: guard dominance, the arbiter for bugs that never crash",
|
||||
"cpg": "model-grounded-detection Req 4: the CPG seam package",
|
||||
"cpg.backend": "model-grounded-detection Req 4.1/4.5: the single Joern subprocess boundary",
|
||||
"cpg.capability": "model-grounded-detection Req 4.2/4.3: the engine probe that degrades to suspicion",
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"""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
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
|
||||
from ..cpg.backend import CpgResult
|
||||
from .ladder import Rung, Verdict
|
||||
from .specs import DominanceSpec
|
||||
|
||||
|
||||
def verdict(cpg: CpgResult, spec: DominanceSpec, *, function: str = "") -> Verdict | None:
|
||||
"""The verdict for the obligated operation in ``function``, or ``None`` when there is nothing to say."""
|
||||
rows = cpg.run(
|
||||
"dominance",
|
||||
{"operations": spec.operations, "dischargers": spec.dischargers, "function": function},
|
||||
)
|
||||
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"]))
|
||||
|
||||
if guarded_methods:
|
||||
witness = (
|
||||
f"{target['opMethod']} line {target['opLine']}: no discharging guard, "
|
||||
f"while {len(guarded_methods)} sibling handler(s) are guarded "
|
||||
f"({', '.join(guarded_methods[:3])})"
|
||||
)
|
||||
return Verdict(rung=Rung.ENTAILED, family=spec.family, witness=witness)
|
||||
|
||||
witness = f"{target['opMethod']} line {target['opLine']}: obligated operation with no discharging guard, and no guarded sibling"
|
||||
return Verdict(rung=Rung.CORROBORATED, family=spec.family, witness=witness)
|
||||
|
||||
|
||||
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", "")),
|
||||
"guards": [str(g) for g in guards] if isinstance(guards, Sequence) and not isinstance(guards, str) else [],
|
||||
}
|
||||
)
|
||||
return kept
|
||||
|
||||
|
||||
__all__ = ["verdict"]
|
||||
@@ -104,8 +104,16 @@ class TaintSpec:
|
||||
class DominanceSpec:
|
||||
"""What an absence family's guard-dominates-operation query matches on, for one language.
|
||||
|
||||
``dischargers`` are the guards that discharge an obligation; the query asks whether one of them dominates
|
||||
the operation on every path, which is the arbiter for the classes that never crash.
|
||||
``dischargers`` are every token that can evidence a discharge, drawn from four fields of a discharger
|
||||
fact and deliberately not from the other two:
|
||||
|
||||
calls, decorators an explicit guard: ``login_required``, ``check_owner``
|
||||
identity_sources the authenticated context: ``current_user``, ``request.user``
|
||||
constraint_params a field that constrains by identity: ``owner_id``, ``user_id``
|
||||
|
||||
``request_sources`` and ``permissive_values`` are excluded because they describe the NEGATIVE case -- a
|
||||
value that comes from the request discharges nothing, and a permissive literal is the bug rather than the
|
||||
guard. Including them would let the absence arbiter read a vulnerability as its own fix.
|
||||
"""
|
||||
|
||||
family: str
|
||||
@@ -167,6 +175,8 @@ def dominance_specs(*, language: str, facts: ObligationFacts | None = None) -> M
|
||||
sorted(
|
||||
{call for fact in scoped.dischargers for call in fact.calls}
|
||||
| {decorator for fact in scoped.dischargers for decorator in fact.decorators}
|
||||
| {source for fact in scoped.dischargers for source in fact.identity_sources}
|
||||
| {param for fact in scoped.dischargers for param in fact.constraint_params}
|
||||
)
|
||||
)
|
||||
return {"access_control": DominanceSpec(family="access_control", language=language, operations=operations, dischargers=dischargers)}
|
||||
|
||||
@@ -27,3 +27,41 @@ rule_ids = ["js-sql-injection"]
|
||||
id = "parameterized_query"
|
||||
calls = ["query", "execute"]
|
||||
parameterized = true
|
||||
|
||||
# --- model-grounded-detection task 4.2 -------------------------------------------------------------------
|
||||
# See the note in python.toml: documented dangerous APIs, provenance per entry, reviewed as data.
|
||||
|
||||
[[sink]]
|
||||
# CWE-1321. Prototype pollution reaches the object graph through a recursive merge or an unchecked
|
||||
# dynamic key. These are the documented merge helpers plus the assignment forms.
|
||||
id = "prototype.merge"
|
||||
cwe = "CWE-1321"
|
||||
calls = ["merge", "mergeDeep", "extend", "defaultsDeep", "Object.assign", "set", "setWith"]
|
||||
rule_ids = []
|
||||
|
||||
[[sink]]
|
||||
# CWE-79. The documented "trusted HTML" sinks in the DOM and the major frameworks.
|
||||
id = "html.unescaped"
|
||||
cwe = "CWE-79"
|
||||
calls = ["innerHTML", "outerHTML", "insertAdjacentHTML", "document.write", "dangerouslySetInnerHTML", "v-html"]
|
||||
rule_ids = []
|
||||
|
||||
[[sink]]
|
||||
# CWE-22. Node filesystem entry points that accept a caller-supplied path.
|
||||
id = "path.open"
|
||||
cwe = "CWE-22"
|
||||
calls = ["readFile", "readFileSync", "createReadStream", "sendFile", "unlink", "unlinkSync"]
|
||||
rule_ids = []
|
||||
|
||||
[[sink]]
|
||||
# CWE-918 / CWE-601: outbound requests and redirects to a caller-supplied destination.
|
||||
id = "outbound.request"
|
||||
cwe = "CWE-918"
|
||||
calls = ["axios.get", "axios.post", "fetch", "request", "got", "http.get", "https.get"]
|
||||
rule_ids = []
|
||||
|
||||
[[sink]]
|
||||
id = "redirect"
|
||||
cwe = "CWE-601"
|
||||
calls = ["redirect", "location.assign", "location.replace"]
|
||||
rule_ids = []
|
||||
|
||||
@@ -71,3 +71,49 @@ calls = ["yaml.safe_load", "safe_load"]
|
||||
id = "parameterized_execute"
|
||||
calls = ["execute", "executemany"]
|
||||
parameterized = true
|
||||
|
||||
# --- model-grounded-detection task 4.2 -------------------------------------------------------------------
|
||||
# Sinks for the four families that previously had no fact routing to them by CWE, plus the ORM raw-SQL forms
|
||||
# the Joern go/no-go measured as misses (11 of 23). Every entry below is a documented dangerous API of the
|
||||
# named library, not an inferred pattern; the provenance is on each entry so a maintainer can review the data
|
||||
# as data. `rule_ids` is left empty where no pattern rule exists: these facts feed the model layer, and a
|
||||
# sink fact is not a detection rule.
|
||||
|
||||
[[sink]]
|
||||
# CWE-89. Django's documented raw-SQL escape hatches (docs: "Performing raw SQL queries") and the
|
||||
# SQLAlchemy/peewee text forms. These were the single largest miss class in the go/no-go.
|
||||
id = "orm.raw"
|
||||
cwe = "CWE-89"
|
||||
calls = ["objects.raw", "raw", "RawSQL", "extra", "text", "from_statement"]
|
||||
rule_ids = []
|
||||
|
||||
[[sink]]
|
||||
# CWE-22. Filesystem entry points that accept a caller-supplied path: the stdlib openers plus Flask's
|
||||
# send_file/send_from_directory and Django's FileResponse.
|
||||
id = "path.open"
|
||||
cwe = "CWE-22"
|
||||
calls = ["open", "send_file", "send_from_directory", "FileResponse", "os.remove", "os.unlink", "shutil.copy", "shutil.move", "shutil.rmtree"]
|
||||
rule_ids = []
|
||||
|
||||
[[sink]]
|
||||
# CWE-79. The documented "this string is trusted HTML" escapes: Jinja/Flask render_template_string and
|
||||
# Markup, Django's mark_safe and format_html. Rendering a template *file* is not a sink; rendering a
|
||||
# caller-supplied *string* is.
|
||||
id = "html.unescaped"
|
||||
cwe = "CWE-79"
|
||||
calls = ["render_template_string", "Markup", "mark_safe", "format_html", "HttpResponse"]
|
||||
rule_ids = []
|
||||
|
||||
[[sink]]
|
||||
# CWE-918 (SSRF) and CWE-601 (open redirect): outbound requests and redirects to a caller-supplied
|
||||
# destination. requests/urllib/httpx are the documented HTTP clients; redirect is Flask's and Django's.
|
||||
id = "outbound.request"
|
||||
cwe = "CWE-918"
|
||||
calls = ["requests.get", "requests.post", "requests.request", "urlopen", "urlretrieve", "httpx.get", "httpx.post"]
|
||||
rule_ids = []
|
||||
|
||||
[[sink]]
|
||||
id = "redirect"
|
||||
cwe = "CWE-601"
|
||||
calls = ["redirect", "HttpResponseRedirect", "url_for"]
|
||||
rule_ids = []
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"""model-grounded-detection task 4.1: guard dominance, the arbiter for bugs that never crash (Req 6.2).
|
||||
|
||||
An access-control bug is the *absence* of a guard, so there is no flow to follow and no crash to reproduce.
|
||||
What the model can establish is a consistency violation: an obligated operation that no discharging guard
|
||||
dominates, in a file where its siblings are dominated by one. That asymmetry is the evidence.
|
||||
|
||||
This replaces the line-order heuristic in `semantic/obligations/dominance.py` ("the guard's line is before the
|
||||
operation's line, with an early exit between") with real control-flow dominance over the CFG.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _spec(): # type: ignore[no-untyped-def]
|
||||
from openultrasast.model.specs import DominanceSpec
|
||||
|
||||
return DominanceSpec(
|
||||
family="access_control",
|
||||
language="python",
|
||||
operations=("filter_by", "query.get", "session.delete"),
|
||||
dischargers=("login_required", "current_user", "check_permission"),
|
||||
)
|
||||
|
||||
|
||||
def _cpg(rows): # type: ignore[no-untyped-def]
|
||||
from openultrasast.cpg.backend import CpgResult
|
||||
|
||||
return CpgResult(cpg_path=Path("cpg.bin"), run=lambda q, p: rows)
|
||||
|
||||
|
||||
def _op(method: str, guards: list[str], line: int = 10) -> dict[str, object]:
|
||||
return {"operation": "Note.query.filter_by(id=nid)", "opLine": str(line), "opMethod": method,
|
||||
"dominatingGuards": guards}
|
||||
|
||||
|
||||
def test_an_unguarded_operation_among_guarded_siblings_is_entailed() -> None:
|
||||
"""The consistency violation: every sibling checks the owner, this one does not."""
|
||||
from openultrasast.model.dominance import verdict
|
||||
from openultrasast.model.ladder import Rung
|
||||
|
||||
rows = [_op("leaky", []), _op("list_notes", ["current_user"]), _op("edit_note", ["current_user"])]
|
||||
answer = verdict(_cpg(rows), _spec(), function="leaky")
|
||||
assert answer is not None and answer.rung is Rung.ENTAILED
|
||||
assert answer.family == "access_control"
|
||||
assert "leaky" in answer.witness and "2 sibling" in answer.witness, "the witness names the operation and its siblings"
|
||||
|
||||
|
||||
def test_a_method_with_two_operations_counts_once_as_a_sibling() -> None:
|
||||
"""One handler holding two obligated operations is one piece of corroboration, not two."""
|
||||
from openultrasast.model.dominance import verdict
|
||||
|
||||
rows = [_op("leaky", []), _op("listing", ["current_user"], line=3), _op("listing", ["current_user"], line=4)]
|
||||
answer = verdict(_cpg(rows), _spec(), function="leaky")
|
||||
assert answer is not None and "1 sibling" in answer.witness
|
||||
assert answer.witness.count("listing") == 1
|
||||
|
||||
|
||||
def test_a_guarded_operation_yields_no_finding() -> None:
|
||||
from openultrasast.model.dominance import verdict
|
||||
|
||||
rows = [_op("safe", ["current_user"]), _op("other", ["current_user"])]
|
||||
assert verdict(_cpg(rows), _spec(), function="safe") is None
|
||||
|
||||
|
||||
def test_an_unguarded_operation_with_no_guarded_sibling_is_only_corroborated() -> None:
|
||||
"""Nothing to be inconsistent with: the whole module may be unauthenticated by design, and saying
|
||||
`entailed` there would assert a policy the model cannot see."""
|
||||
from openultrasast.model.dominance import verdict
|
||||
from openultrasast.model.ladder import Rung
|
||||
|
||||
rows = [_op("a", []), _op("b", [])]
|
||||
answer = verdict(_cpg(rows), _spec(), function="a")
|
||||
assert answer is not None and answer.rung is Rung.CORROBORATED
|
||||
|
||||
|
||||
def test_no_obligated_operation_yields_no_verdict() -> None:
|
||||
from openultrasast.model.dominance import verdict
|
||||
|
||||
assert verdict(_cpg([]), _spec(), function="whatever") is None
|
||||
|
||||
|
||||
def test_a_failed_query_is_not_read_as_an_absence_of_operations() -> None:
|
||||
"""`None` from the engine means "could not decide", never "there is nothing here"."""
|
||||
from openultrasast.cpg.backend import CpgResult
|
||||
from openultrasast.model.dominance import verdict
|
||||
|
||||
broken = CpgResult(cpg_path=Path("c.bin"), run=lambda q, p: None)
|
||||
assert verdict(broken, _spec(), function="a") is None
|
||||
|
||||
|
||||
def test_the_verdict_is_deterministic_over_one_cpg() -> None:
|
||||
"""Req 6.4, for the absence arbiter as well as the flow one."""
|
||||
from openultrasast.model.dominance import verdict
|
||||
|
||||
rows = [_op("leaky", []), _op("z", ["current_user"]), _op("a", ["check_permission"])]
|
||||
cpg = _cpg(rows)
|
||||
assert verdict(cpg, _spec(), function="leaky") == verdict(cpg, _spec(), function="leaky")
|
||||
|
||||
|
||||
def test_the_spec_drives_the_query_parameters() -> None:
|
||||
from openultrasast.cpg.backend import CpgResult
|
||||
from openultrasast.model.dominance import verdict
|
||||
|
||||
seen: dict[str, object] = {}
|
||||
|
||||
def run(query: str, params: dict[str, object]) -> object:
|
||||
seen["query"] = query
|
||||
seen.update(params)
|
||||
return []
|
||||
|
||||
verdict(CpgResult(cpg_path=Path("c.bin"), run=run), _spec(), function="f")
|
||||
assert seen["query"] == "dominance"
|
||||
assert seen["operations"] == ("filter_by", "query.get", "session.delete")
|
||||
assert seen["dischargers"] == ("login_required", "current_user", "check_permission")
|
||||
assert seen["function"] == "f"
|
||||
@@ -36,10 +36,9 @@ def test_the_guard_classifier_still_recognises_each_kind() -> None:
|
||||
def test_the_seeded_families_and_the_named_gaps_are_both_exact() -> None:
|
||||
"""What the retained fact data can seed, and what it provably cannot — Req 9.2, a gap is *named*.
|
||||
|
||||
`path`, `output_encoding`, `untrusted_destination` and `prototype` have no sink in `ruleset/semantic`
|
||||
whose CWE routes to them, which is the same hole the entailment ceiling diagnosed ("closed literal sink
|
||||
table: misses per-family sinks"). Task 4.2 authors those sinks and must update this test when it does —
|
||||
the set is asserted exactly so a gap can neither appear nor close silently.
|
||||
Task 4.2 closed the gap this test was written to hold open: `path`, `output_encoding`,
|
||||
`untrusted_destination` and `prototype` now have sink facts routing to them by CWE. The set is still
|
||||
asserted exactly, so a family can neither lose its seed nor gain one without this record moving.
|
||||
"""
|
||||
from openultrasast.model.specs import dominance_specs, taint_specs
|
||||
from openultrasast.model.taxonomy import load_families
|
||||
@@ -50,11 +49,12 @@ def test_the_seeded_families_and_the_named_gaps_are_both_exact() -> None:
|
||||
seeded |= {spec.family for spec in taint_specs(language=language).values()}
|
||||
seeded |= {spec.family for spec in dominance_specs(language=language).values()}
|
||||
deferred = {"unknown", "memory"} # the declined bucket and the deferred execution tier
|
||||
assert seeded == {"injection", "deserialization", "config_secrets", "access_control", "memory"}
|
||||
assert seeded == {
|
||||
"injection", "deserialization", "config_secrets", "access_control",
|
||||
"path", "output_encoding", "untrusted_destination", "prototype", "memory",
|
||||
}
|
||||
gaps = {family.id for family in taxonomy.families} - seeded - deferred
|
||||
assert gaps == {"path", "output_encoding", "untrusted_destination", "prototype"}, (
|
||||
"a family lost or gained a seed without this record being updated"
|
||||
)
|
||||
assert gaps == set(), f"a family lost its seed: {sorted(gaps)}"
|
||||
|
||||
|
||||
def test_the_injection_taint_spec_carries_sources_sinks_and_sanitizers() -> None:
|
||||
@@ -126,3 +126,25 @@ def test_safe_shape_sinks_are_kept_for_the_shape_test() -> None:
|
||||
assert "execute" not in spec.sanitizers
|
||||
c_memory = taint_specs(language="c").get("memory")
|
||||
assert c_memory is not None and "printf" in c_memory.safe_shape_sinks
|
||||
|
||||
|
||||
def test_dischargers_carry_identity_and_constraint_tokens_but_not_the_negative_fields() -> None:
|
||||
"""A discharger fact spreads across six fields and two of them mean the opposite.
|
||||
|
||||
`identity_sources` (current_user, request.user) and `constraint_params` (owner_id, user_id) are how an
|
||||
identity constraint is actually written, and omitting them left the absence arbiter reporting "no guard"
|
||||
on a handler that plainly had one. `request_sources` and `permissive_values` are excluded because they
|
||||
describe the vulnerable shape, not the fix -- including them would let the arbiter read a bug as its fix.
|
||||
"""
|
||||
from openultrasast.model.specs import dominance_specs
|
||||
from openultrasast.semantic.obligations import load_obligation_facts
|
||||
|
||||
spec = dominance_specs(language="python")["access_control"]
|
||||
assert "current_user" in spec.dischargers and "owner_id" in spec.dischargers
|
||||
assert "login_required" in spec.dischargers # decorators still present
|
||||
|
||||
facts = load_obligation_facts().for_language("python")
|
||||
negative = {p for fact in facts.dischargers for p in fact.request_sources} | {
|
||||
v for fact in facts.dischargers for v in fact.permissive_values
|
||||
}
|
||||
assert not (negative & set(spec.dischargers)), "the negative fields must never read as a discharge"
|
||||
|
||||
Reference in New Issue
Block a user