mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 03:49:26 +00:00
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:
co-authored by
Claude Opus 5
parent
0fcd5feade
commit
d15985f695
@@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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---")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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__ = [
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user