feat(model): an endpoint its contract declares open carries no obligation

contributor-scan 2.8. VAmPI's last two false positives were register_user
and login_user, both declared open by its OpenAPI spec: the arbiter was
reporting a missing guard on endpoints whose contract says there is
nothing to miss.

**The fix keys on provenance, not on the value, and that distinction is
the whole task.** The mapper says "public" in two very different
situations. An OpenAPI operation with no `security` block is DECLARED
open, because the spec is a complete contract. A Flask handler with no
`@login_required` is "public" only because nothing was found -- which is
precisely the bug this family exists to report. Suppressing on the value
alone would have silenced the detector on its primary case, so
EntryPointRecord now carries `access_declared` and only a declaration
removes the obligation.

WordPress supplied the second source of declared access on the same day --
wp_ajax_nopriv_ for a logged-out handler, wp_ajax_ for a logged-in one,
permission_callback => __return_true for an open REST route. A rule resting
on one framework's convention would be a special case; two make it a shape.

  vampi entailed              6 -> 4
  access-control precision    2 of 4 -> 2 of 2
  known vulnerabilities       3 of 3 still entailed
  access_control offered to   6 of 14 handlers, exactly the six the spec
                              declares authenticated

**And the guard that decides whether it is a real improvement:** the
access-control pair corpus, which nobody tuned this against, is unmoved --
pair_correct 3 and leak 1 across all nine, pair_correct 2 and leak 0.0%
across the seven from projects unrelated to VAmPI, identical to task 2.6.
The precision was not bought by silencing the family. Pair excerpts carry
no route manifest and no hook, so nothing in them is declared open and the
rule correctly does not fire.

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 15:39:58 +02:00
co-authored by Claude Opus 5
parent 3e1d13e4b6
commit 60d2a1a92c
5 changed files with 88 additions and 5 deletions
+21 -2
View File
@@ -74,6 +74,14 @@ class EntryPointRecord:
conditions: list[str]
provenance: str
rationale: str
# Did the access level come from a DECLARATION, or from the absence of one?
#
# The distinction decides whether "public" can be trusted. An OpenAPI operation with no `security` block
# is declared open, because the spec is a complete contract; a WordPress `wp_ajax_nopriv_` hook is
# WordPress saying logged-out callers reach this. But a Flask handler with no `@login_required` is
# "public" only because nothing was found -- which is precisely the bug the access-control family exists
# to report, so treating it as a declaration would silence the detector on its primary case.
access_declared: bool = False
def ingest_sarif(path: Path) -> list[StaticHint]:
@@ -221,7 +229,9 @@ def _php_entry_points(target: FileTarget, text: str) -> list[EntryPointRecord]:
hook, handler = match.group("hook"), match.group("handler")
access, evidence = _wordpress_hook_access(match.group("call"), hook)
start, end = bounds.get(handler, (number, number))
records.append(_entry(target, start, end, handler, f"wp:{hook}", "route", access, "http_request", evidence, []))
records.append(
_entry(target, start, end, handler, f"wp:{hook}", "route", access, "http_request", evidence, [], access_declared=True)
)
if _PHP_REST_ROUTE.search(line):
window = "\n".join(lines[number - 1 : number + 12])
callback = _PHP_CALLBACK.search(window)
@@ -230,7 +240,11 @@ def _php_entry_points(target: FileTarget, text: str) -> list[EntryPointRecord]:
permission = _PHP_PERMISSION.search(window)
access, evidence = _rest_route_access(permission.group("value").strip() if permission else None)
start, end = bounds.get(handler, (number, number))
records.append(_entry(target, start, end, handler, "wp:rest_route", "route", access, "http_request", evidence, []))
records.append(
_entry(
target, start, end, handler, "wp:rest_route", "route", access, "http_request", evidence, [], access_declared=True
)
)
registered = {record.function_name for record in records}
for name, (start, end) in sorted(bounds.items()):
@@ -425,6 +439,9 @@ def _route_manifest_entry_points(root: Path, targets: Sequence[FileTarget]) -> l
conditions=[],
provenance=f"entrypoint:route:{access}",
rationale=f"registered by operationId {operation} in {relative}",
# A route manifest is a complete contract: an operation with no `security` block is
# declared open, not merely undecorated.
access_declared=True,
)
)
return records
@@ -679,6 +696,7 @@ def _entry(
trust_boundary: str,
access_evidence: list[str],
conditions: list[str],
access_declared: bool = False,
) -> EntryPointRecord:
return EntryPointRecord(
path=target.path,
@@ -693,6 +711,7 @@ def _entry(
conditions=conditions,
provenance=f"entrypoint:{kind}:{access_level}",
rationale=f"{kind} surface classified as {access_level} at {trust_boundary}",
access_declared=access_declared,
)
+7 -1
View File
@@ -86,7 +86,13 @@ def regions_for(entries: Sequence[object], targets: Sequence[object], *, shipped
language = by_path.get(path)
if language is None:
continue
families = _families(language, has_handler=True)
# A handler its own contract declares OPEN carries no authorization obligation: there is nothing
# for a guard to be missing from. The declaration is what makes this sound -- an OpenAPI operation
# with no `security` block, a `wp_ajax_nopriv_` hook, a REST `permission_callback => __return_true`.
# An INFERRED "public" is the opposite case and must keep the family, because for a decorator
# framework it means only that no `@login_required` was found, which is the bug itself.
declared_open = bool(getattr(entry, "access_declared", False)) and str(getattr(entry, "access_level", "")) == "public"
families = _families(language, has_handler=not declared_open)
if not families:
continue
function = getattr(entry, "function_name", None) or None