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:
Marius Ciepluch
2026-09-08 14:45:12 +02:00
co-authored by Claude Opus 5
parent b8f8f727c6
commit fec67253f3
9 changed files with 417 additions and 13 deletions
+1
View File
@@ -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",
+88
View File
@@ -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"]
+12 -2
View File
@@ -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)}