mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-18 04:29:27 +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
@@ -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---")
|
||||
}
|
||||
Reference in New Issue
Block a user