diff --git a/.kiro/specs/contributor-scan/tasks.md b/.kiro/specs/contributor-scan/tasks.md index 70d8df7..adee3d9 100644 --- a/.kiro/specs/contributor-scan/tasks.md +++ b/.kiro/specs/contributor-scan/tasks.md @@ -144,7 +144,7 @@ - _Requirements: 4.4_ - _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 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. @@ -162,6 +162,22 @@ - _Requirements: 5.1_ - _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 - [ ] 3.1 Contributor output diff --git a/benchmarks/measurements/2026-09-09-repo-vampi-authz-precision.json b/benchmarks/measurements/2026-09-09-repo-vampi-authz-precision.json new file mode 100644 index 0000000..a83711b --- /dev/null +++ b/benchmarks/measurements/2026-09-09-repo-vampi-authz-precision.json @@ -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 (? 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 guardTokens = split(dischargersS) @@ -46,31 +68,82 @@ // 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 // negative on the one family this arbiter exists for. - def mentionsGuard(code: String): Boolean = - guardTokens.exists(g => java.util.regex.Pattern.compile("(? c.isLetterOrDigit || 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()` // 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) + // + // 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 method = op.method + val applicable = guardsFor(op.name, op.code) + // 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 // 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. - 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)) @@ -90,11 +163,20 @@ val parsed = ujson.read(requests).obj val answers = parsed.map { case (id, req) => 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))) } 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---") } diff --git a/src/openultrasast/model/dominance.py b/src/openultrasast/model/dominance.py index 3644613..650c33b 100644 --- a/src/openultrasast/model/dominance.py +++ b/src/openultrasast/model/dominance.py @@ -19,6 +19,7 @@ The rungs are deliberately asymmetric: from __future__ import annotations +import json from collections.abc import Mapping, Sequence from ..cpg.backend import CpgResult @@ -26,14 +27,29 @@ from .ladder import Rung, Verdict from .specs import DominanceSpec -def request_params(spec: DominanceSpec, *, function: str = "") -> dict[str, object]: - """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} +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`. + + ``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.""" - rows = cpg.run("dominance", request_params(spec, function=function)) + rows = cpg.run("dominance", request_params(spec, function=function, file=file)) operations = _operations(rows) if not operations: return None diff --git a/src/openultrasast/model/pipeline.py b/src/openultrasast/model/pipeline.py index 6670a81..22a061a 100644 --- a/src/openultrasast/model/pipeline.py +++ b/src/openultrasast/model/pipeline.py @@ -82,7 +82,7 @@ def _arbitrate( pairs read as `both_silent` on the first end-to-end run. """ if isinstance(spec, DominanceSpec): - return dominance_verdict(cpg, spec, function=function) + return dominance_verdict(cpg, spec, function=function, file=path) if isinstance(spec, ConfigSpec): 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) diff --git a/src/openultrasast/model/scan.py b/src/openultrasast/model/scan.py index 6ab888f..fafd284 100644 --- a/src/openultrasast/model/scan.py +++ b/src/openultrasast/model/scan.py @@ -237,7 +237,7 @@ def _grouped(work: Sequence[tuple[str, ScanRegion, ArbiterSpec]]) -> dict[str, d function = region.function or "" entry = _is_entry_point(region) 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): kind, params = "config", config_params(spec, function=function, file=region.path) else: diff --git a/src/openultrasast/model/specs.py b/src/openultrasast/model/specs.py index f3189f0..ec9d43b 100644 --- a/src/openultrasast/model/specs.py +++ b/src/openultrasast/model/specs.py @@ -19,7 +19,7 @@ from __future__ import annotations import re from collections.abc import Mapping -from dataclasses import dataclass +from dataclasses import dataclass, field from ..semantic.facts import SemanticFacts, load_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. request_sources a value from the request discharges nothing, by definition. 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 language: str operations: 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) @@ -219,21 +231,56 @@ def taint_specs( def dominance_specs(*, language: str, facts: ObligationFacts | None = None) -> Mapping[str, DominanceSpec]: """One ``DominanceSpec`` per absence family for this language. - The obligation facts are authored for authorization, so every obligated operation routes to - ``access_control``; the shape generalises when a second absence family is added to the taxonomy. + Not every obligation fact is an authorization one, and taking them all was wrong in both directions. + + * ``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) if not scoped.operations: 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( 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} + {call for fact in dischargers_for for call in fact.calls} + | {decorator for fact in dischargers_for for decorator in fact.decorators} + | {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__ = [ diff --git a/tests/test_model_specs.py b/tests/test_model_specs.py index 5330413..0eb6330 100644 --- a/tests/test_model_specs.py +++ b/tests/test_model_specs.py @@ -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 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" + + +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