mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 03:49:26 +00:00
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
151 lines
7.7 KiB
Python
151 lines
7.7 KiB
Python
"""model-grounded-detection task 1.1: the security vocabularies survive the modules that carried them.
|
|
|
|
The guard vocabulary lived in `semantic/variants.py`, whose optimisation loop this feature deletes. The
|
|
source/sink/sanitizer facts and the obligation facts live in `ruleset/` data and are retained, so the specs
|
|
*derive* from them rather than copying them — a copy would be a second place to update. What must be ported
|
|
verbatim is the guard vocabulary, because nothing else holds it.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
from pathlib import Path
|
|
|
|
SPECS = Path("src/openultrasast/model/specs.py")
|
|
|
|
|
|
def test_the_guard_vocabulary_is_preserved_verbatim() -> None:
|
|
from openultrasast.model.specs import GUARD_KINDS
|
|
|
|
assert GUARD_KINDS == ("null_test", "bounds_test", "allowlist_test", "auth_check", "parameterized_call", "type_change", "none")
|
|
|
|
|
|
def test_the_guard_classifier_still_recognises_each_kind() -> None:
|
|
"""The patterns classify the *fix*, first match wins — the order is part of the vocabulary."""
|
|
from openultrasast.model.specs import classify_guard_text
|
|
|
|
assert classify_guard_text('cur.execute("SELECT * FROM t WHERE id = %s", (uid,))') == "parameterized_call"
|
|
assert classify_guard_text("if not current_user.is_authenticated: abort(403)") == "auth_check"
|
|
assert classify_guard_text('if name not in ALLOWED: abort(400)') == "allowlist_test"
|
|
assert classify_guard_text("if len(buf) > 64: return") == "bounds_test"
|
|
assert classify_guard_text("if value is None: return") == "null_test"
|
|
assert classify_guard_text("page = int(raw)") == "type_change"
|
|
assert classify_guard_text("total = total + 1") == "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*.
|
|
|
|
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
|
|
|
|
taxonomy = load_families()
|
|
seeded = set()
|
|
for language in ("python", "javascript", "java", "c"):
|
|
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",
|
|
"path", "output_encoding", "untrusted_destination", "prototype", "memory",
|
|
}
|
|
gaps = {family.id for family in taxonomy.families} - seeded - deferred
|
|
assert gaps == set(), f"a family lost its seed: {sorted(gaps)}"
|
|
|
|
|
|
def test_the_injection_taint_spec_carries_sources_sinks_and_sanitizers() -> None:
|
|
from openultrasast.model.specs import taint_specs
|
|
|
|
spec = taint_specs(language="python")["injection"]
|
|
assert any("request.args" in source for source in spec.sources)
|
|
assert any(sink in ("eval", "exec", "os.system") for sink in spec.sinks)
|
|
assert spec.sanitizers, "a family with no sanitizer model can never distinguish a fixed twin"
|
|
|
|
|
|
def test_the_access_control_dominance_spec_carries_operations_and_dischargers() -> None:
|
|
from openultrasast.model.specs import dominance_specs
|
|
|
|
spec = dominance_specs(language="python")["access_control"]
|
|
assert any("filter_by" in operation for operation in spec.operations)
|
|
assert spec.dischargers, "an absence family with no discharger model can only ever report suspicion"
|
|
|
|
|
|
def test_specs_import_nothing_from_the_removed_packages() -> None:
|
|
"""Req 1.2: the vocabularies must not depend on the modules this feature deletes."""
|
|
tree = ast.parse(SPECS.read_text())
|
|
imported = set()
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.ImportFrom) and node.module:
|
|
imported.add(node.module)
|
|
elif isinstance(node, ast.Import):
|
|
imported.update(alias.name for alias in node.names)
|
|
forbidden = ("learning", "improve", "variants", "mechanisms", "evolve")
|
|
offending = [name for name in imported if any(token in name for token in forbidden)]
|
|
assert not offending, f"model/specs.py imports removed code: {offending}"
|
|
|
|
|
|
def test_the_specs_module_exposes_no_writer() -> None:
|
|
"""Req 7.4, the verifier boundary: the models are read-only data no LLM or optimiser can rewrite."""
|
|
tree = ast.parse(SPECS.read_text())
|
|
writers = [
|
|
node.name
|
|
for node in tree.body
|
|
if isinstance(node, ast.FunctionDef)
|
|
and any(node.name.startswith(verb) for verb in ("write", "save", "dump", "store", "append", "update", "set_"))
|
|
]
|
|
assert not writers, f"specs must be read-only, found writers: {writers}"
|
|
assert "open(" not in SPECS.read_text().replace("# ", ""), "specs must not open a file for writing"
|
|
|
|
|
|
def test_a_sink_is_never_its_own_sanitizer() -> None:
|
|
"""A parameterized fact names a safe sink *shape*, not a cleansing call on the path.
|
|
|
|
Two shipped facts have this shape: `parameterized_execute` ("execute with bound parameters is safe") and
|
|
C's `literal_format` ("printf with a constant format string is safe"). Flattened into the sanitizer list
|
|
each makes its sink its own sanitizer, so every flow through it reports as already-clean and nothing is
|
|
ever entailed. Both were caught on the first live Joern run — the second only because the first was fixed
|
|
as a class rather than as an instance.
|
|
"""
|
|
from openultrasast.model.specs import taint_specs
|
|
|
|
for language in ("python", "javascript", "java", "c"):
|
|
for spec in taint_specs(language=language).values():
|
|
overlap = set(spec.sinks) & set(spec.sanitizers)
|
|
assert not overlap, f"{language}/{spec.family}: sink is its own sanitizer: {sorted(overlap)}"
|
|
|
|
|
|
def test_safe_shape_sinks_are_kept_for_the_shape_test() -> None:
|
|
from openultrasast.model.specs import taint_specs
|
|
|
|
spec = taint_specs(language="python")["injection"]
|
|
assert "execute" in spec.safe_shape_sinks, "the safe-shape names must survive for a later shape test"
|
|
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"
|