fix(model): a guard discharges the obligation it is a guard of

contributor-scan 2.6. Access control entailed 7 of 9 VAmPI handlers,
including three that do authorize and three the OpenAPI spec declares
public. Five separate defects, each invisible to a corpus of one-function
excerpts and each found by asking the query what it actually matched.

**A trailing segment is not a match.** `query.get` was matched by its last
segment, so every `.get(` in the repository became a protected database
operation -- `request.headers.get('Authorization')` and
`request_data.get('username')` included. Four of the seven false positives
came from this, and so did the apparent detection of the known BOLA: it was
entailed because fetching the Authorization header had no dominating guard,
not because of the defect. A qualified token now requires its qualifier.

**Configuration is not authorization.** dominance_specs took every
obligation fact. `security_setting` operations are discharged by a VALUE,
which config_specs already claims them for; pooled in here they made
`app.run(...)` an operation requiring an authorization guard and put
thirteen tokens in BOTH lists -- an obligation that is its own discharge,
the same shape as the taint sink that used to sanitize itself.

**Input validation is not authorization.** `jsonschema.validate` was
counted as a guard, so handlers with no authorization check at all read as
guarded. No operation in the table requires validated_input.

**A guard discharges the obligation it is a guard of.** The largest one.
`orm-read` requires an identity_constraint or an ownership_check;
`token_validator` is a path_guard. Flattened into one list, authenticating
the caller discharged an object-level obligation -- a handler that
authenticates and then looks the record up by a PATH parameter read as
fully guarded. The facts state that relation and the spec was discarding
it; it is now carried and the engine asks per operation.

**A word boundary needs a word.** Found while fixing the above. Guard
matching wrapped every token in `(?<![A-Za-z0-9_])...(?![A-Za-z0-9_])`.
The identity token `['sub']` begins with `[`, and the character before it
in `resp['sub']` is `p` -- so the assertion could never hold. Every
identity token starting with punctuation was unmatchable, and the identity
constraint that separates a correct lookup from an IDOR was invisible.

Also: dominance rows were repository-wide, so the model's unguarded
`delete_user` was attributed to the API handler of the same name. Rows are
scoped to the region's file, which is what the rung's own justification
says -- the evidence is a file contradicting itself.

Measured against VAmPI's OpenAPI `security` blocks, which declare per
operation whether auth is required, and agree with the code:

  vampi precision   28.6% (2/7) -> 50.0% (2/4)
  known found       3 of 3, all model_entailed

And on the labelled access-control pairs, which nobody tuned this against:

  all 9 pairs       pair_correct 3 -> 3, leak 44.4% -> 11.1%
  7 non-VAmPI       pair_correct 2 -> 2, leak 42.9% -> 0.0%

Every detection lost was on a pair whose FIXED side was flagged too, which
scores nothing. No correct detection was traded for the precision.

get_by_title was flagged before it was in the recipe: its code carries the
comment "# Broken Object Level Authorization" and the README lists it, so a
real finding was being scored as a false positive. It is now the recipe's
third known vulnerability.

Both remaining false positives are endpoints the spec declares public.
Recorded as 2.8, with the reason the mapper's access_level cannot simply be
trusted: for decorator frameworks "public" means "no decorator found",
which is the bug itself.

Gates byte-identical.

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-09 11:02:48 +02:00
co-authored by Claude Opus 5
parent 0fcd5feade
commit d15985f695
9 changed files with 462 additions and 28 deletions
+17 -1
View File
@@ -144,7 +144,7 @@
- _Requirements: 4.4_ - _Requirements: 4.4_
- _Depends: 2.2_ - _Depends: 2.2_
- [ ] 2.6 Access control that does not entail every public endpoint - [x] 2.6 Access control that does not entail every public endpoint
- 7 of 9 VAmPI handlers were entailed, including `register_user` and `get_all_users`, which are meant to - 7 of 9 VAmPI handlers were entailed, including `register_user` and `get_all_users`, which are meant to
be unauthenticated. The dominance rule -- unguarded, with guarded siblings -- was calibrated on pairs be unauthenticated. The dominance rule -- unguarded, with guarded siblings -- was calibrated on pairs
where a guarded twin always existed. At repository scale every public endpoint has guarded siblings. where a guarded twin always existed. At repository scale every public endpoint has guarded siblings.
@@ -162,6 +162,22 @@
- _Requirements: 5.1_ - _Requirements: 5.1_
- _Depends: 2.2_ - _Depends: 2.2_
- [ ] 2.8 An endpoint that declares itself public carries no obligation
- **2.6's remainder.** Both false positives left on VAmPI are `register_user` and `login_user`, which its
OpenAPI spec declares public with no `security` block. The arbiter cannot see that declaration, so it
reports a missing guard on an endpoint whose contract says there is nothing to miss.
- The mapper's `access_level` is NOT a safe substitute on its own. For an OpenAPI route it comes from the
declared `security` block and is trustworthy; for a decorator-based framework "public" means only that
no `@login_required` was found, which is precisely the bug this family exists to report. Suppressing on
that would silence the primary detection case for Flask and Express.
- So the work is to carry the PROVENANCE of the access level, not just its value: a declared contract
removes the obligation, an absent decorator does not. `EntryPointRecord.provenance` currently only
echoes the level, so this needs a real signal rather than a string match on evidence text.
- Observable: `register_user` and `login_user` no longer entailed on vampi, with the pair-corpus leak rate
unchanged at 0% on the non-VAmPI pairs -- the check that this suppressed a declaration and not a family.
- _Requirements: 8.1, 8.2_
- _Depends: 2.6_
## Group 3 — Ship it ## Group 3 — Ship it
- [ ] 3.1 Contributor output - [ ] 3.1 Contributor output
@@ -0,0 +1,213 @@
{
"against": "benchmarks/measurements/2026-09-09-repo-vampi-module-scope.json (task 2.5, same checkout and budget)",
"caveat": "9 pairs and one repository. The pair excerpts are single functions, so they can only ever reach model_corroborated -- there is no sibling to be inconsistent with -- and one leak that remains (vampi-books-get-by-title) is a corpus artifact: VAmPI keeps the vulnerable and fixed branches in the same function, so both sides of the excerpt contain the unguarded query.",
"completed": true,
"config": "shipped defaults",
"degradations": [
{
"detail": "no path records; obligations evaluated within entry-point handlers only",
"files": [
"api_views/books.py",
"api_views/users.py",
"models/books_model.py",
"models/user_model.py"
],
"language": "python",
"reason": "obligations_function_local",
"stage": "obligations"
},
{
"reason": "budget_exhausted",
"regions_unasked": 20,
"stage": "model"
},
{
"reason": "hunter_model_unavailable",
"stage": "map"
}
],
"exit_code": 0,
"findings_by_rung": {
"execution_confirmed": 0,
"model_corroborated": 0,
"model_entailed": 6,
"suspicion": 54
},
"findings_total": 60,
"found_before_it_was_recorded": "get_by_title was flagged by the engine before it was in the recipe. Its code carries the comment '# Broken Object Level Authorization' and the README lists it, so it was a real finding being scored as a false positive. It is now the recipe's third known vulnerability, and all three are entailed.",
"generated": "2026-09-09",
"known_outcomes": [
{
"family": "injection",
"finding_id": "model:injection:models/user_model.py:73:get_user",
"id": "VAmPI-SQLI",
"line": 73,
"location": "models/user_model.py:get_user:73",
"matches": 3,
"result": "found",
"rung": "model_entailed"
},
{
"family": "access_control",
"finding_id": "model:access_control:api_views/users.py:?:update_password",
"id": "VAmPI-BOLA",
"line": null,
"location": "api_views/users.py:update_password:187",
"matches": 5,
"result": "found",
"rung": "model_entailed"
},
{
"family": "access_control",
"finding_id": "model:access_control:api_views/books.py:?:get_by_title",
"id": "VAmPI-BOLA-BOOKS",
"line": null,
"location": "api_views/books.py:get_by_title:51",
"matches": 9,
"result": "found",
"rung": "model_entailed"
}
],
"mode": "standard",
"model": {
"arbitrate_seconds": 123.96,
"build_seconds": 6.46,
"by_rung": {
"execution_confirmed": 0,
"model_corroborated": 0,
"model_entailed": 6,
"suspicion": 38
},
"cost_usd": 0.007,
"model_calls": 103,
"query_seconds": 34.27,
"regions_scanned": 25,
"regions_unasked": 20,
"regions_unjudged": 0,
"seconds": 164.69,
"unjudged_sample": []
},
"oracle": {
"declared_public": [
"populate_db",
"basic",
"get_all_users",
"debug",
"register_user",
"login_user",
"get_by_username",
"get_all_books"
],
"documented_access_control_defects": [
"update_password",
"get_by_title"
],
"pairs": "14 pair cases carry an access_control expectation with a gold label; 9 build a CPG, and 7 of those are from projects unrelated to VAmPI.",
"vampi": "VAmPI ships an OpenAPI 3 spec whose per-operation `security: bearerAuth` block declares which endpoints require authorization, and every endpoint that declares it calls token_validator in code. Two independent sources agreeing is the ground truth used here, not a reading of the handlers."
},
"peak_rss_mb": 554.5,
"precision": {
"pairs_all": {
"leak_after": "1 (11.1%)",
"leak_before": "4 (44.4%)",
"n": 9,
"pair_correct_after": "3 (33.3%)",
"pair_correct_before": "3 (33.3%)"
},
"pairs_excluding_vampi_excerpts": {
"leak_after": "0 (0.0%)",
"leak_before": "3 (42.9%)",
"n": 7,
"pair_correct_after": "2 (28.6%)",
"pair_correct_before": "2 (28.6%)"
},
"vampi_after": {
"entailed": 4,
"false_positives": 2,
"flagged": [
"login_user",
"register_user",
"get_by_title",
"update_password"
],
"note": "both remaining false positives are endpoints the spec declares public. That class is untouched by this task.",
"precision": "50.0%",
"true_positives": 2
},
"vampi_before": {
"entailed": 7,
"false_positives": 5,
"flagged": [
"get_all_books",
"get_all_users",
"register_user",
"get_by_title",
"delete_user",
"me",
"update_password"
],
"note": "me, delete_user and get_by_title all DO call an authorization guard; get_all_books, get_all_users and register_user are declared public.",
"precision": "28.6%",
"true_positives": 2
}
},
"reading": "Precision on the repository rises 28.6% -> 50.0% and every known vulnerability is still found. The independent check matters more: on the labelled access-control pairs, leak falls 44.4% -> 11.1% with pair_correct unchanged, and on the seven pairs from projects unrelated to VAmPI leak falls 42.9% -> 0.0% with pair_correct unchanged. Every detection lost was on a pair where the FIXED side was flagged too, which scores nothing; no correct detection was traded away.",
"repo": {
"commit": "f16052dce83f05847133ec98f01c5193a41de7d8",
"known": [
{
"family": "injection",
"id": "VAmPI-SQLI",
"in_scope": true,
"location": "models/user_model.py:get_user:73",
"weakness": "SQL injection: the username is interpolated into the query string at line 72 and executed at 73"
},
{
"family": "access_control",
"id": "VAmPI-BOLA",
"in_scope": true,
"location": "api_views/users.py:update_password:187",
"weakness": "unauthorized password change: the path username is used instead of the authenticated token subject"
},
{
"family": "access_control",
"id": "VAmPI-BOLA-BOOKS",
"in_scope": true,
"location": "api_views/books.py:get_by_title:51",
"weakness": "broken object level authorization: a book is fetched by title with no ownership constraint and its secret returned"
}
],
"language": "python",
"license": "MIT",
"measures": [
"detection"
],
"name": "vampi",
"url": "https://github.com/erev0s/VAmPI.git"
},
"scan_seconds": 182.45,
"spec": "contributor-scan 2.6 (Req 8.1, 8.2)",
"stages": {
"completed": [
"static",
"map"
],
"requested": [
"static",
"map"
],
"skipped": []
},
"still_open": [
"Both remaining VAmPI false positives (register_user, login_user) are endpoints the OpenAPI spec declares public. The arbiter cannot see that declaration, and the mapper's access_level is not a safe substitute: for decorator-based frameworks 'public' means 'no decorator found', which is the bug itself. Recorded as 2.8.",
"2.7 dominance witnesses carry no line, so access-control sites still read api_views/users.py:?:update_password."
],
"what_was_wrong": {
"a_guard_discharges_the_obligation_it_is_a_guard_of": "The largest one. orm-read requires an identity_constraint or an ownership_check; token_validator is a path_guard. Flattened into one list, authenticating the caller discharged an object-level obligation -- so a handler that authenticates and then looks the record up by a PATH parameter read as fully guarded. The facts state the relation; the spec was throwing it away.",
"a_trailing_segment_is_not_a_match": "`query.get` was matched by its last segment, so every `.get(` in the repository was a protected database operation: request.headers.get('Authorization') and request_data.get('username') included. Four of the seven false positives came from this, including three handlers that do authorize.",
"a_word_boundary_needs_a_word": "Found while fixing the above. Guard matching wrapped every token in (?<![A-Za-z0-9_]) ... (?![A-Za-z0-9_]). The identity token ['sub'] begins with [ and the character before it in resp['sub'] is p, so the assertion could never hold: every identity token starting with punctuation was unmatchable, and the identity constraint that separates a correct lookup from an IDOR was invisible. The boundary now applies only at ends that are word characters.",
"configuration_is_not_authorization": "dominance_specs took every obligation fact. `security_setting` operations are configuration, discharged by a VALUE, which config_specs already claims them for. They made app.run(...) an operation requiring an authorization guard and put thirteen tokens in BOTH the operation and discharger lists -- an obligation that is its own discharge.",
"input_validation_is_not_authorization": "`validated_input` dischargers (jsonschema.validate, parse_obj) were pooled with the guards. No operation in the table requires them, and counting them marked handlers with no authorization check at all as guarded.",
"two_functions_can_share_a_name": "dominance rows were repository-wide, so the model's unguarded delete_user was attributed to the API handler's region. Rows are now scoped to the region's file, which is also what the rung's own justification says: the evidence is a FILE contradicting itself."
}
}
+14
View File
@@ -45,3 +45,17 @@ function = "update_password"
line = 187 line = 187
reference = "README.md 'Unauthorized Password Change'; the non-vulnerable branch uses resp['sub']" reference = "README.md 'Unauthorized Password Change'; the non-vulnerable branch uses resp['sub']"
in_scope = true in_scope = true
# Found by the engine before it was in this file. The first repository run flagged get_by_title, and the code
# says why in its own comment: the vulnerable branch fetches a book by title with no ownership constraint and
# returns its secret. It is listed in the README alongside the password one, and leaving it out of the recipe
# would have scored a real finding as a false positive.
[[known]]
id = "VAmPI-BOLA-BOOKS"
family = "access_control"
weakness = "broken object level authorization: a book is fetched by title with no ownership constraint and its secret returned"
file = "api_views/books.py"
function = "get_by_title"
line = 51
reference = "the code comment '# Broken Object Level Authorization' on the vulnerable branch; README 'Broken Object Level Authorization'"
in_scope = true
+94 -12
View File
@@ -18,7 +18,15 @@
// so it never dominates the operation, but it does control whether the value escapes. // 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 // Parameters: cpgFile, operations, dischargers, function (optional; reported, never used to filter, because
// the sibling comparison needs every operation in the CPG). // the sibling comparison needs the file's other operations), file (optional: the source file the region
// belongs to), operationRequires and dischargersByKind (optional JSON, see below).
//
// A discharger only discharges the obligation it is a discharger OF. The facts say `orm-read` requires an
// identity_constraint or an ownership_check, and that `token_validator` is a path_guard; pooling them made
// authenticating the caller discharge an object-level obligation, so a handler that authenticates and then
// looks a record up by a PATH parameter read as fully guarded. `operationRequires` maps an operation call to
// the kinds that would discharge it and `dischargersByKind` maps a kind to its tokens; when both are absent
// the flat list is used, which is the committed single-request behaviour.
// //
// Output: a fenced JSON array of {operation, opLine, opMethod, dominatingGuards}. // Output: a fenced JSON array of {operation, opLine, opMethod, dominatingGuards}.
@@ -31,13 +39,27 @@
operations: String = "", operations: String = "",
dischargers: String = "", dischargers: String = "",
function: String = "", function: String = "",
file: String = "",
operationRequires: String = "",
dischargersByKind: String = "",
requests: String = "" requests: String = ""
) = { ) = {
importCpg(cpgFile) importCpg(cpgFile)
def split(raw: String): List[String] = raw.split(",").map(_.trim).filter(_.nonEmpty).toList def split(raw: String): List[String] = raw.split(",").map(_.trim).filter(_.nonEmpty).toList
def rowsFor(operationsS: String, dischargersS: String, functionS: String): List[ujson.Obj] = { def parseMap(raw: String): Map[String, List[String]] =
if (raw.isEmpty) Map.empty
else ujson.read(raw).obj.map { case (k, v) => k -> v.arr.map(_.str).toList }.toMap
def rowsFor(
operationsS: String,
dischargersS: String,
functionS: String,
fileS: String,
requiresS: String,
byKindS: String
): List[ujson.Obj] = {
val opNames = split(operationsS) val opNames = split(operationsS)
val guardTokens = split(dischargersS) val guardTokens = split(dischargersS)
@@ -46,31 +68,82 @@
// Word-boundary matching, never substring. A discharger token like `user` matched as a substring hits // Word-boundary matching, never substring. A discharger token like `user` matched as a substring hits
// `users` inside "SELECT * FROM users WHERE id = ?", marking a textbook IDOR as guarded -- a silent false // `users` inside "SELECT * FROM users WHERE id = ?", marking a textbook IDOR as guarded -- a silent false
// negative on the one family this arbiter exists for. // negative on the one family this arbiter exists for.
def mentionsGuard(code: String): Boolean = val requiresByOp = parseMap(requiresS)
guardTokens.exists(g => java.util.regex.Pattern.compile("(?<![A-Za-z0-9_])" + java.util.regex.Pattern.quote(g) + "(?![A-Za-z0-9_])").matcher(code).find()) val tokensByKind = parseMap(byKindS)
// Word-boundary matching, never substring: a token like `user` matched as a substring hits `users` inside
// "SELECT * FROM users WHERE id = ?", marking a textbook IDOR as guarded.
//
// But the boundary only applies where there IS a word character to bound. `['sub']` begins with `[`, and
// the character before it in `resp['sub']` is `p` -- so an unconditional lookbehind made every identity
// token that starts with punctuation unmatchable, and the identity constraint that distinguishes a
// correct lookup from an IDOR could never be seen.
def boundedPattern(token: String): java.util.regex.Pattern = {
val before = if (token.headOption.exists(c => c.isLetterOrDigit || c == '_')) "(?<![A-Za-z0-9_])" else ""
val after = if (token.lastOption.exists(c => c.isLetterOrDigit || c == '_')) "(?![A-Za-z0-9_])" else ""
java.util.regex.Pattern.compile(before + java.util.regex.Pattern.quote(token) + after)
}
def mentions(code: String, tokens: List[String]): Boolean =
tokens.exists(g => boundedPattern(g).matcher(code).find())
def mentionsGuard(code: String): Boolean = mentions(code, guardTokens)
// The tokens that can discharge THIS operation, rather than every token that can discharge anything.
def guardsFor(name: String, code: String): List[String] =
if (requiresByOp.isEmpty || tokensByKind.isEmpty) guardTokens
else {
val kinds = opNames
.filter(n => name == n || (n.split("\\.").length > 1 && name == n.split("\\.").last && n.split("\\.").init.forall(code.contains)))
.flatMap(n => requiresByOp.getOrElse(n, Nil))
.distinct
if (kinds.isEmpty) guardTokens else kinds.flatMap(k => tokensByKind.getOrElse(k, Nil)).distinct
}
// Match by CALL NAME, never by code containment: the frontend desugars `Note.query.filter_by(x).first()` // 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 // 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` // 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`) // 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. // 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) // But the trailing segment ALONE is not enough, and on a repository it is catastrophic: `query.get` ends
// in `get`, so it matched `request.headers.get('Authorization')` and `request_data.get('username')` --
// ordinary dictionary access read as a protected database operation. Four of seven false positives on the
// first repository measured came from exactly that, including three handlers that do call an
// authorization guard. A qualified token means the qualification matters, so the receiver must appear
// too; the desugared chain still carries it.
def matchesOperation(name: String, code: String): Boolean =
opNames.exists { n =>
if (name == n) true
else {
val parts = n.split("\\.")
parts.length > 1 && name == parts.last && parts.init.forall(q => code.contains(q))
}
}
val opCalls = cpg.call.filter(c => matchesOperation(c.name)).l // Scoped to the region's own file. `delete_user` exists in both the API handler and the model of the
// repository this was measured on, and unscoped rows attributed the model's unguarded query to the
// handler. The sibling comparison is a statement about a FILE contradicting itself, which is what the
// rung's justification says, so the file is also the right scope for it.
val opCalls = {
val all = cpg.call.filter(c => matchesOperation(c.name, c.code)).l
if (fileS.isEmpty) all else all.filter(_.method.filename.endsWith(fileS))
}
val rows = opCalls.map { op => val rows = opCalls.map { op =>
val method = op.method val method = op.method
val applicable = guardsFor(op.name, op.code)
// 1. identity constraint: a discharger inside the operation's own arguments. // 1. identity constraint: a discharger inside the operation's own arguments.
val inArguments = op.argument.code.l.filter(mentionsGuard) val inArguments = op.argument.code.l.filter(c => mentions(c, applicable))
// 2. identity branch: a control structure in this method whose condition names a discharger. The denial // 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. // it guards need not dominate the operation -- it controls whether the fetched value escapes.
val inCondition = method.controlStructure.condition.code.l.filter(mentionsGuard) val inCondition = method.controlStructure.condition.code.l.filter(c => mentions(c, applicable))
// 3. dominating guard: a call naming a discharger that actually dominates the operation. // 3. dominating guard: a call naming a discharger that actually dominates the operation.
val dominating = op.dominatedBy.isCall.code.l.filter(mentionsGuard) val dominating = op.dominatedBy.isCall.code.l.filter(c => mentions(c, applicable))
val guards = (inArguments ++ inCondition ++ dominating).distinct.map(_.take(120)) val guards = (inArguments ++ inCondition ++ dominating).distinct.map(_.take(120))
@@ -90,11 +163,20 @@
val parsed = ujson.read(requests).obj val parsed = ujson.read(requests).obj
val answers = parsed.map { case (id, req) => val answers = parsed.map { case (id, req) =>
def field(name: String): String = req.obj.get(name).map(_.str).getOrElse("") def field(name: String): String = req.obj.get(name).map(_.str).getOrElse("")
id -> ujson.Arr(rowsFor(field("operations"), field("dischargers"), field("function")): _*) id -> ujson.Arr(
rowsFor(
field("operations"),
field("dischargers"),
field("function"),
field("file"),
field("operationRequires"),
field("dischargersByKind")
): _*
)
} }
println(ujson.write(ujson.Obj.from(answers))) println(ujson.write(ujson.Obj.from(answers)))
} else { } else {
println(ujson.write(ujson.Arr(rowsFor(operations, dischargers, function): _*))) println(ujson.write(ujson.Arr(rowsFor(operations, dischargers, function, file, operationRequires, dischargersByKind): _*)))
} }
println("---OUSAST-CPG-END---") println("---OUSAST-CPG-END---")
} }
+21 -5
View File
@@ -19,6 +19,7 @@ The rungs are deliberately asymmetric:
from __future__ import annotations from __future__ import annotations
import json
from collections.abc import Mapping, Sequence from collections.abc import Mapping, Sequence
from ..cpg.backend import CpgResult from ..cpg.backend import CpgResult
@@ -26,14 +27,29 @@ from .ladder import Rung, Verdict
from .specs import DominanceSpec from .specs import DominanceSpec
def request_params(spec: DominanceSpec, *, function: str = "") -> dict[str, object]: 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`.""" """The query parameters this arbiter sends. Shared with the batcher; see the note in `taint.request_params`.
return {"operations": spec.operations, "dischargers": spec.dischargers, "function": function}
``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,
"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 = "") -> Verdict | None: 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.""" """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)) rows = cpg.run("dominance", request_params(spec, function=function, file=file))
operations = _operations(rows) operations = _operations(rows)
if not operations: if not operations:
return None return None
+1 -1
View File
@@ -82,7 +82,7 @@ def _arbitrate(
pairs read as `both_silent` on the first end-to-end run. pairs read as `both_silent` on the first end-to-end run.
""" """
if isinstance(spec, DominanceSpec): if isinstance(spec, DominanceSpec):
return dominance_verdict(cpg, spec, function=function) return dominance_verdict(cpg, spec, function=function, file=path)
if isinstance(spec, ConfigSpec): if isinstance(spec, ConfigSpec):
return config_verdict(cpg, spec, function=function, file=path) return config_verdict(cpg, spec, function=function, file=path)
return taint_verdict(cpg, spec, function=function, file=path, parameter_sources=parameter_sources, call_depth=call_depth) return taint_verdict(cpg, spec, function=function, file=path, parameter_sources=parameter_sources, call_depth=call_depth)
+1 -1
View File
@@ -237,7 +237,7 @@ def _grouped(work: Sequence[tuple[str, ScanRegion, ArbiterSpec]]) -> dict[str, d
function = region.function or "" function = region.function or ""
entry = _is_entry_point(region) entry = _is_entry_point(region)
if isinstance(spec, DominanceSpec): if isinstance(spec, DominanceSpec):
kind, params = "dominance", dominance_params(spec, function=function) kind, params = "dominance", dominance_params(spec, function=function, file=region.path)
elif isinstance(spec, ConfigSpec): elif isinstance(spec, ConfigSpec):
kind, params = "config", config_params(spec, function=function, file=region.path) kind, params = "config", config_params(spec, function=function, file=region.path)
else: else:
+55 -8
View File
@@ -19,7 +19,7 @@ from __future__ import annotations
import re import re
from collections.abc import Mapping from collections.abc import Mapping
from dataclasses import dataclass from dataclasses import dataclass, field
from ..semantic.facts import SemanticFacts, load_facts from ..semantic.facts import SemanticFacts, load_facts
from ..semantic.obligations.facts import ObligationFacts, load_obligation_facts from ..semantic.obligations.facts import ObligationFacts, load_obligation_facts
@@ -119,12 +119,24 @@ class DominanceSpec:
evidences nothing and treating it as a guard silences the whole family. evidences nothing and treating it as a guard silences the whole family.
request_sources a value from the request discharges nothing, by definition. request_sources a value from the request discharges nothing, by definition.
permissive_values a permissive literal is the bug, not the guard. permissive_values a permissive literal is the bug, not the guard.
``dischargers`` is the flat union, kept because a caller may not care which kind discharged what.
``requirements`` and ``dischargers_by_kind`` carry the relation the facts actually state: an operation
names the obligation KINDS that would discharge it, and only a discharger of one of those kinds counts.
Flattening that relation away was a false-negative engine. `orm-read` requires an ``identity_constraint``
or an ``ownership_check``; `token_validator` is a ``path_guard``. Pooled together, authenticating the
caller discharged an object-level obligation -- so a handler that authenticates and then looks the
record up by a PATH parameter read as fully guarded. That is the shape of VAmPI's broken object-level
authorization, and of most real IDORs.
""" """
family: str family: str
language: str language: str
operations: tuple[str, ...] operations: tuple[str, ...]
dischargers: tuple[str, ...] dischargers: tuple[str, ...]
requirements: Mapping[str, tuple[str, ...]] = field(default_factory=dict)
dischargers_by_kind: Mapping[str, tuple[str, ...]] = field(default_factory=dict)
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -219,21 +231,56 @@ def taint_specs(
def dominance_specs(*, language: str, facts: ObligationFacts | None = None) -> Mapping[str, DominanceSpec]: def dominance_specs(*, language: str, facts: ObligationFacts | None = None) -> Mapping[str, DominanceSpec]:
"""One ``DominanceSpec`` per absence family for this language. """One ``DominanceSpec`` per absence family for this language.
The obligation facts are authored for authorization, so every obligated operation routes to Not every obligation fact is an authorization one, and taking them all was wrong in both directions.
``access_control``; the shape generalises when a second absence family is added to the taxonomy.
* ``security_setting`` operations are configuration: a permissive CORS origin or a debug flag is
discharged by a VALUE, which is what ``config_specs`` already claims them for and what this arbiter
cannot decide. Pooled in here they made `app.run(...)` an operation requiring an authorization guard,
and put thirteen tokens in both the operation and discharger lists -- an obligation that is its own
discharge, the same shape as the taint sink that used to sanitize itself.
* ``validated_input`` dischargers are schema checks. `jsonschema.validate` does not establish who the
caller is, and no operation in the table requires it; counting it as a guard marked handlers with no
authorization check at all as guarded.
So both sides are filtered by kind, and the filter is derived from the facts' own ``requires`` relation
rather than a list maintained here.
""" """
scoped = (facts if facts is not None else load_obligation_facts()).for_language(language) scoped = (facts if facts is not None else load_obligation_facts()).for_language(language)
if not scoped.operations: if not scoped.operations:
return {} return {}
operations = tuple(sorted({call for fact in scoped.operations for call in fact.calls})) guarded = tuple(fact for fact in scoped.operations if fact.kind != "security_setting")
if not guarded:
return {}
# What the retained operations actually ask for. A discharger of any other kind discharges nothing here.
required = {kind for fact in guarded for kind in fact.requires}
dischargers_for = tuple(fact for fact in scoped.dischargers if fact.kind in required)
operations = tuple(sorted({call for fact in guarded for call in fact.calls}))
dischargers = tuple( dischargers = tuple(
sorted( sorted(
{call for fact in scoped.dischargers for call in fact.calls} {call for fact in dischargers_for for call in fact.calls}
| {decorator for fact in scoped.dischargers for decorator in fact.decorators} | {decorator for fact in dischargers_for for decorator in fact.decorators}
| {source for fact in scoped.dischargers for source in fact.identity_sources} | {source for fact in dischargers_for for source in fact.identity_sources}
) )
) )
return {"access_control": DominanceSpec(family="access_control", language=language, operations=operations, dischargers=dischargers)} if not operations or not dischargers:
return {}
# The relation the facts state, kept rather than flattened: which obligation kinds each operation call
# would accept as a discharge, and which tokens evidence each kind.
requirements = {call: tuple(sorted(set(fact.requires))) for fact in guarded for call in fact.calls}
by_kind: dict[str, tuple[str, ...]] = {}
for fact in dischargers_for:
tokens = set(fact.calls) | set(fact.decorators) | set(fact.identity_sources)
by_kind[fact.kind] = tuple(sorted(set(by_kind.get(fact.kind, ())) | tokens))
return {
"access_control": DominanceSpec(
family="access_control",
language=language,
operations=operations,
dischargers=dischargers,
requirements=requirements,
dischargers_by_kind=by_kind,
)
}
__all__ = [ __all__ = [
+46
View File
@@ -175,3 +175,49 @@ def test_constraint_params_are_not_dischargers_on_their_own() -> None:
assert params, "the fixture would be vacuous if the facts carried no constraint params" assert params, "the fixture would be vacuous if the facts carried no constraint params"
assert not (params & set(spec.dischargers)), f"constraint params must not discharge alone: {sorted(params & set(spec.dischargers))}" assert not (params & set(spec.dischargers)), f"constraint params must not discharge alone: {sorted(params & set(spec.dischargers))}"
assert "current_user" in spec.dischargers, "identity sources still do discharge" assert "current_user" in spec.dischargers, "identity sources still do discharge"
def test_configuration_facts_do_not_become_authorization_obligations() -> None:
"""contributor-scan 2.6. A permissive CORS origin is discharged by a VALUE, not by a guard.
Taking every obligation fact made `app.run(...)` an operation requiring an authorization check, and put
thirteen tokens in both the operation and the discharger list -- an obligation that is its own discharge,
the same shape as the taint sink that used to sanitize itself.
"""
from openultrasast.model.specs import dominance_specs
spec = dominance_specs(language="python")["access_control"]
assert not set(spec.operations) & set(spec.dischargers), "nothing may be both the obligation and its discharge"
assert "app.run" not in spec.operations
assert "CORS" not in spec.operations
def test_input_validation_does_not_discharge_an_authorization_obligation() -> None:
"""`jsonschema.validate` establishes the shape of a request, never who is making it."""
from openultrasast.model.specs import dominance_specs
spec = dominance_specs(language="python")["access_control"]
assert "validate" not in spec.dischargers
assert "parse_obj" not in spec.dischargers
assert "token_validator" in spec.dischargers, "a real route guard must survive the filter"
def test_a_guard_discharges_only_the_obligation_it_is_a_guard_of() -> None:
"""The relation the facts state, which the flat list was throwing away.
`filter_by` requires an identity_constraint or an ownership_check; `token_validator` is a path_guard.
Pooled together, authenticating the caller discharged an object-level obligation, so a handler that
authenticates and then looks the record up by a PATH parameter read as fully guarded -- VAmPI's broken
object-level authorization, and the shape of most real IDORs.
"""
from openultrasast.model.specs import dominance_specs
spec = dominance_specs(language="python")["access_control"]
assert spec.requirements["filter_by"] == ("identity_constraint", "ownership_check")
identity = set(spec.dischargers_by_kind["identity_constraint"])
assert "token_validator" not in identity, "authentication is not an object-level check"
assert "token_validator" in set(spec.dischargers_by_kind["path_guard"])
assert "['sub']" in identity or '["sub"]' in identity