feat(prover): delivery B: handler-named operations, Symfony, Livewire, Remix and Tornado entries, enforcement witnesses, memoised path walk

Codex delivery under spec access-control-prover plus one fix of mine. Operation rows may now name a handler by
name pattern and framework (Jenkins form validation and list filling, Tornado request methods, Remix actions);
the mapper recognises Symfony, Livewire and Laravel controllers, Remix exports and Tornado handlers;
`obligations/enforcement.py` turns Laravel route middleware, Symfony attributes and security.yaml rules and
Tornado's authenticated decorator into router and decorator witnesses. The path walk looks each node's callers
up once per change and does not walk a tree with no recognised entry: one seatunnel change fell from 670 s to
35 s. One name Codex could not source (Tornado get_current_user) is left out and said so in the facts header.

Smoke on the 23 fix changes: 15 pushes caught (65 percent, Wilson 45 to 81), 55 of 93 locations, precision
15 of 15 flagged, no instrument failure, no change over 120 s. Delivery A caught 7.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
Marius Ciepluch
2026-10-14 17:31:07 +02:00
co-authored by Claude Fable 5.1
parent 7f1b820d5a
commit 17e07b36c4
18 changed files with 1179 additions and 66 deletions
+43 -6
View File
@@ -18,7 +18,7 @@
- _Requirements: 4.1a_ - _Requirements: 4.1a_
- _Boundary: obligations.check (opt-in only)_ - _Boundary: obligations.check (opt-in only)_
- [ ] 2. Entry recognisers - [x] 2. Entry recognisers
- [x] 2.1 Stapler and Jetty recognisers in `mapping.py` (P) - [x] 2.1 Stapler and Jetty recognisers in `mapping.py` (P)
- Public `do[A-Z]...` methods on exposed types or with Stapler-typed or `@QueryParameter` parameters; Jetty - Public `do[A-Z]...` methods on exposed types or with Stapler-typed or `@QueryParameter` parameters; Jetty
`AbstractHandler`, `HttpServlet`, JAX-RS resources; evidence strings; `public` only for `UnprotectedRootAction` `AbstractHandler`, `HttpServlet`, JAX-RS resources; evidence strings; `public` only for `UnprotectedRootAction`
@@ -26,13 +26,13 @@
helper named `doX`, and the existing mapping tests still pass helper named `doX`, and the existing mapping tests still pass
- _Requirements: 1.1, 1.6, 1.7, 1.8_ - _Requirements: 1.1, 1.6, 1.7, 1.8_
- _Boundary: mapping_ - _Boundary: mapping_
- [ ] 2.2 Symfony, Livewire and Laravel controller, Remix export and Tornado recognisers in `mapping.py` (P) - [x] 2.2 Symfony, Livewire and Laravel controller, Remix export and Tornado recognisers in `mapping.py` (P)
- Done when fixture files of each shape yield `route` entries with evidence and the negative fixtures yield none - Done when fixture files of each shape yield `route` entries with evidence and the negative fixtures yield none
- _Requirements: 1.2, 1.3, 1.4, 1.5, 1.7, 1.8_ - _Requirements: 1.2, 1.3, 1.4, 1.5, 1.7, 1.8_
- _Boundary: mapping_ - _Boundary: mapping_
- [ ] 3. Enforcement witnesses and facts - [ ] 3. Enforcement witnesses and facts
- [ ] 3.1 `semantic/obligations/enforcement.py` producing router and decorator `Discharge` witnesses (P) - [x] 3.1 `semantic/obligations/enforcement.py` producing router and decorator `Discharge` witnesses (P)
- Laravel route middleware and controller `middleware`/`authorizeResource`; Symfony `IsGranted`, `Security`, - Laravel route middleware and controller `middleware`/`authorizeResource`; Symfony `IsGranted`, `Security`,
security.yaml `access_control`; Tornado `authenticated`; names listed in the facts headers security.yaml `access_control`; Tornado `authenticated`; names listed in the facts headers
- Done when `tests/test_obligation_enforcement.py` shows a guarded Laravel route, a Symfony attribute and a - Done when `tests/test_obligation_enforcement.py` shows a guarded Laravel route, a Symfony attribute and a
@@ -50,15 +50,15 @@
- _Requirements: 2.3, 2.4, 2.7_ - _Requirements: 2.3, 2.4, 2.7_
- _Boundary: ruleset/obligations_ - _Boundary: ruleset/obligations_
- [ ] 3.3 Handler-named operations: `handlers` and `framework` fields in the facts loader, `find_operations(..., handlers_of=)`, Stapler form-validation rows in `java.toml`, Tornado handler rows in `python.toml`, Remix action rows in `typescript.toml` - [x] 3.3 Handler-named operations: `handlers` and `framework` fields in the facts loader, `find_operations(..., handlers_of=)`, Stapler form-validation rows in `java.toml`, Tornado handler rows in `python.toml`, Remix action rows in `typescript.toml`
- Done when `tests/test_obligation_facts.py` loads a handler-named row, `tests/test_obligation_operations.py` - Done when `tests/test_obligation_facts.py` loads a handler-named row, `tests/test_obligation_operations.py`
(or the existing operations test file) yields the operation at the declaration line only for listed (or the existing operations test file) yields the operation at the declaration line only for listed
handlers, and the prover smoke on the 23 fixes reaches and checks the Jenkins form-validation methods handlers, and the prover smoke on the 23 fixes reaches and checks the Jenkins form-validation methods
- _Requirements: 2.8_ - _Requirements: 2.8_
- _Boundary: obligations.facts, obligations.operations_ - _Boundary: obligations.facts, obligations.operations_
- [ ] 4. The prover - [x] 4. The prover
- [ ] 4.1 `hunt/prover.py`: `prove_change` over the hunter `Context`, `Coverage`, findings as hunt defects - [x] 4.1 `hunt/prover.py`: `prove_change` over the hunter `Context`, `Coverage`, findings as hunt defects
- Touched functions from head lines, build output left out, targets include direct callees holding operations, - Touched functions from head lines, build output left out, targets include direct callees holding operations,
IRs of hop files passed to the checker, extra witnesses from enforcement, coverage counts by reason, no model IRs of hop files passed to the checker, extra witnesses from enforcement, coverage counts by reason, no model
- Done when `tests/test_hunt_prover.py` shows: an unguarded Stapler handler calling `lookupCredentials` gives - Done when `tests/test_hunt_prover.py` shows: an unguarded Stapler handler calling `lookupCredentials` gives
@@ -103,3 +103,40 @@
requested static gate passed (format, Ruff and mypy); the exact pytest gate passed with 112 passed, requested static gate passed (format, Ruff and mypy); the exact pytest gate passed with 112 passed,
zero failed, exit 0. UV_NO_SYNC=1 was set; UV_CACHE_DIR was placed under /tmp because the default zero failed, exit 0. UV_NO_SYNC=1 was set; UV_CACHE_DIR was placed under /tmp because the default
cache is outside the writable sandbox. The unchanged unjustified-path checker test remains intact. cache is outside the writable sandbox. The unchanged unjustified-path checker test remains intact.
- Delivery B (2026-10-14) implements task 2.2's Symfony, Livewire/Laravel, Remix and Tornado
entries, task 3.1's scoped enforcement witnesses, task 3.3's handler facts and operation discovery,
and task 4.1's integration. Changes remain uncommitted as the brief requires.
- Handler facts are explicitly opt-in through file-qualified handler maps in the checker. The prover
uses handler obligations where a Stapler or Tornado entry reaches no listed call operation; existing
call-site findings and Delivery A tests remain unchanged. Remix retains its handler operation even
when listed calls exist, and independently requires a path guard and an ownership check. Public
access waives only the path guard for these handler facts. The checker default is unchanged.
- The prover supplies a local dominance adapter for handler facts: it checks guards within the handler
body while reporting the operation at its declaration. A final ordinary return does not turn a bare
permission predicate into a guard. OrderDominance and the existing producers and arms are unchanged.
- Remix arrow exports are named locally in the prover's IR copy, including TSX route modules. Symfony
recognises positional and named route paths and class prefixes. Tornado recognises local subclasses
and the pool terminal's terminado base when it explicitly invokes Tornado's WebSocketHandler method.
- PHP CST Point.row access crashed on the larger Kimai controller with the installed native parser.
New recognisers derive line numbers from byte offsets instead; a fixture past line 320 and the
23-change source smoke complete without a crash. Existing CST code was not modified.
- Task 3.2 remains partially complete: TypeScript adds clone-verified requireUserId and
authenticateApiRequest, retaining requireUser and authorize; PHP rows are retained and their header
records enforcement sources. Python retains the existing authenticated decorator and gains the
Tornado handler operation. No get_current_user fact was added: Git searches of Mage HEAD, the pool
revision and websocket fix 66440e4d3 found neither that API nor an authenticated decorator use, and
local framework documentation was unavailable. The brief forbids network access and unsourced names.
- Review used the main-context fallback because the brief prohibits model calls. Verdict: APPROVED
for tasks 2.2, 3.1, 3.3 and 4.1; task 3.2 remains open for its source-verification gap. No new
placeholder markers, credentials, dependencies, producer edits or arm edits were introduced.
- Fresh verification: UV_NO_SYNC=1 and UV_CACHE_DIR=/tmp/ousast-uv. The exact static gate passed:
ruff format --check ., ruff check ., mypy src/openultrasast (178 source files). The exact requested
pytest gate plus test_obligation_operations.py passed with 145 passed, zero failed, exit 0.
Initial missing-feature tests produced 24 failed and 14 passed; integration tests initially produced
eight failed and 20 passed. No test_search_* test was run.
- A separate file-scoped smoke read immutable Git source objects for all 23 access-control changes,
validated their commit objects and source digests, and recorded nonzero input bytes for every change.
All 23 executions completed, zero errors; 16 changes checked operations and seven remained unchecked.
It materialised only changed source files in temporary directories, so it is not a full-repository
recall measurement. The maintainer-owned task 6.1 and release readiness remain unclaimed.
+105 -41
View File
@@ -2,9 +2,10 @@
from __future__ import annotations from __future__ import annotations
import re
from collections import Counter from collections import Counter
from collections.abc import Mapping from collections.abc import Mapping
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass, replace
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -14,8 +15,9 @@ from ..semantic.facts import load_facts
from ..semantic.ir import FileIR, FunctionIR, trailing_name from ..semantic.ir import FileIR, FunctionIR, trailing_name
from ..semantic.obligations.check import _on_caller_hop, _relevant, check_obligations from ..semantic.obligations.check import _on_caller_hop, _relevant, check_obligations
from ..semantic.obligations.dominance import OrderDominance from ..semantic.obligations.dominance import OrderDominance
from ..semantic.obligations.enforcement import witnesses
from ..semantic.obligations.facts import load_obligation_facts from ..semantic.obligations.facts import load_obligation_facts
from ..semantic.obligations.operations import Operation, find_operations, valid_witness from ..semantic.obligations.operations import Discharge, Operation, find_discharges, find_operations, valid_witness
from ..semantic.obligations.paths import compose_paths from ..semantic.obligations.paths import compose_paths
from ..semantic.obligations.policy import DEFAULT_POLICY_RELPATH, load_declared_policy from ..semantic.obligations.policy import DEFAULT_POLICY_RELPATH, load_declared_policy
from .producers import Context, _safe_path from .producers import Context, _safe_path
@@ -70,35 +72,30 @@ def prove_change(root: Path, change: Mapping[str, Any], ctx: Context | None = No
readable.append(path) readable.append(path)
except Exception as error: except Exception as error:
failed.add(path) failed.add(path)
reasons[str(getattr(error, "reason", None) or type(error).__name__)] += 1 reasons[type(error).__name__] += 1
degradations.append({"path": path, "reason": str(getattr(error, "reason", None) or type(error).__name__)}) degradations.append({"path": path, "reason": type(error).__name__})
if "entry_points" not in ctx.__dict__: for config in mapping.framework_config_paths(root):
# External manifest enforcement is supplied by delivery B; this delivery uses source entries.
mapped: list[mapping.EntryPointRecord] = []
for target in targets:
try: try:
mapped.extend(mapping.analyze_entry_points(root, [target], texts=ctx.texts, include_manifests=False)) ctx.read(config)
except Exception as error: except Exception as error:
failed.add(target.path) degradations.append({"path": config, "reason": type(error).__name__})
reasons[str(getattr(error, "reason", None) or type(error).__name__)] += 1 if "entry_points" not in ctx.__dict__:
degradations.append({"path": target.path, "reason": str(getattr(error, "reason", None) or type(error).__name__)}) ctx.__dict__["entry_points"] = mapping.analyze_entry_points(root, targets, texts=ctx.texts, include_manifests=False)
ctx.__dict__["entry_points"] = mapped entries = [
entries = [entry for entry in ctx.entry_points if entry.function_name and entry.defines_handler and entry.line is not None] entry
for entry in ctx.entry_points
if entry.function_name and entry.defines_handler and entry.line is not None and entry.kind == "route"
]
recognised = {entry.path for entry in entries} recognised = {entry.path for entry in entries}
for path in recognised: handlers_of: dict[str, dict[str, str]] = {}
evidence = " ".join(item for entry in entries if entry.path == path for item in entry.access_evidence) framework_files: set[tuple[str, str]] = set()
framework = ( for entry in entries:
"stapler" framework = mapping.entry_framework(entry)
if "Stapler" in evidence if framework is None:
else "jetty" framework = source.canonical_language(detect_language(root / entry.path))
if "Jetty" in evidence framework_files.add((entry.path, framework))
else "servlet" handlers_of.setdefault(entry.path, {})[str(entry.function_name)] = framework
if "Servlet" in evidence frameworks.update(framework for _, framework in framework_files)
else "jaxrs"
if "JAX-RS" in evidence
else source.canonical_language(detect_language(root / path))
)
frameworks[framework] += 1
def read_ir(path: str) -> FileIR | None: def read_ir(path: str) -> FileIR | None:
if path in failed: if path in failed:
@@ -107,6 +104,14 @@ def prove_change(root: Path, change: Mapping[str, Any], ctx: Context | None = No
return irs[path][0] return irs[path][0]
try: try:
ir, text = ctx.ir(path), ctx.read(path) ir, text = ctx.ir(path), ctx.read(path)
aliases = {e.line: e.function_name for e in entries if e.path == path and mapping.entry_framework(e) == "remix"}
ir = replace(
ir,
functions=tuple(
replace(fn, name=str(aliases[fn.start_line])) if fn.name == "<anon>" and fn.start_line in aliases else fn
for fn in ir.functions
),
)
if not ir.parse_ok: if not ir.parse_ok:
reasons["language_unsupported" if ir.reason == "language_unsupported" else "parse_failed"] += 1 reasons["language_unsupported" if ir.reason == "language_unsupported" else "parse_failed"] += 1
failed.add(path) failed.add(path)
@@ -117,16 +122,16 @@ def prove_change(root: Path, change: Mapping[str, Any], ctx: Context | None = No
return None return None
irs[path] = ir, text irs[path] = ir, text
ctx.spans.setdefault(path, source.ranges_from_ir(ir, text)) ctx.spans.setdefault(path, source.ranges_from_ir(ir, text))
found = find_operations(ir, facts.for_language(ir.language), text=text) found = find_operations(ir, facts.for_language(ir.language), text=text, handlers_of=handlers_of.get(path))
for fn in ir.functions: for fn in ir.functions:
if fn.name != "<module>": if fn.name != "<module>":
functions[path, fn.name] = fn functions[path, fn.name] = fn
operations[path, fn.name] = tuple(op for op in found if op.function == fn.name) operations[path, fn.name] = tuple(op for op in found if op.function == fn.name)
return ir return ir
except Exception as error: except Exception as error:
reasons[str(getattr(error, "reason", None) or type(error).__name__)] += 1 reasons[type(error).__name__] += 1
failed.add(path) failed.add(path)
degradations.append({"path": path, "reason": str(getattr(error, "reason", None) or type(error).__name__)}) degradations.append({"path": path, "reason": type(error).__name__})
return None return None
for file in change.get("files", ()): for file in change.get("files", ()):
@@ -171,12 +176,24 @@ def prove_change(root: Path, change: Mapping[str, Any], ctx: Context | None = No
if any(span.name in call_names for span in ctx.ranges(path)): if any(span.name in call_names for span in ctx.ranges(path)):
read_ir(path) read_ir(path)
except Exception as error: except Exception as error:
reasons[str(getattr(error, "reason", None) or type(error).__name__)] += 1 reasons[type(error).__name__] += 1
degradations.append({"path": path, "reason": str(getattr(error, "reason", None) or type(error).__name__)}) degradations.append({"path": path, "reason": type(error).__name__})
target_functions.update(key for key, ops in operations.items() if key[1] in call_names and ops) target_functions.update(key for key, ops in operations.items() if key[1] in call_names and ops)
paths, unreached = compose_paths( paths, unreached = compose_paths(
callers=callers, entries={f"{e.path}::{e.function_name}" for e in entries}, targets=sorted(target_functions), max_depth=max_depth callers=callers, entries={f"{e.path}::{e.function_name}" for e in entries}, targets=sorted(target_functions), max_depth=max_depth
) )
# Handler obligations fill gaps in the call inventory. Preserve the established call-site verdict
# when that entry already reaches a listed operation, including one in a directly called helper.
handler_ids = {fact.id for fact in facts.operations if fact.handlers}
for record in paths:
target_path, _, target_name = record.hops[-1][0].rpartition("::")
if any(op.fact_id not in handler_ids for op in operations.get((target_path, target_name), ())):
entry_path, _, entry_name = record.entry.rpartition("::")
if handlers_of.get(entry_path, {}).get(entry_name) != "remix":
handlers_of[entry_path].pop(entry_name, None)
for (path, name), ops in operations.items():
if name not in handlers_of.get(path, {}):
operations[path, name] = tuple(op for op in ops if op.fact_id not in handler_ids)
path_nodes = {node for record in paths for node, _ in record.hops} path_nodes = {node for record in paths for node, _ in record.hops}
for node in path_nodes: for node in path_nodes:
read_ir(node.rpartition("::")[0]) read_ir(node.rpartition("::")[0])
@@ -185,7 +202,35 @@ def prove_change(root: Path, change: Mapping[str, Any], ctx: Context | None = No
reasons["unreached" if path in recognised else "framework_unrecognised"] += 1 reasons["unreached" if path in recognised else "framework_unrecognised"] += 1
entry_index = {f"{e.path}::{e.function_name}": e for e in entries} entry_index = {f"{e.path}::{e.function_name}": e for e in entries}
policy = load_declared_policy(_safe_path(root, DEFAULT_POLICY_RELPATH.as_posix())) policy = load_declared_policy(_safe_path(root, DEFAULT_POLICY_RELPATH.as_posix()))
dominance = OrderDominance(ctx.texts) handler_ends: dict[Operation, int] = {}
for (path, name), ops in operations.items():
fn = functions[path, name]
returns = [
index
for index, line in enumerate(ctx.texts[path].splitlines(), 1)
if fn.start_line <= index <= fn.end_line and re.match(r"^\s*return\b", line)
]
for op in ops:
if op.fact_id in handler_ids:
handler_ends[op] = returns[-1] if returns else fn.end_line + 1
dominance = _HandlerDominance(ctx.texts, handler_ends)
extra_witnesses: list[Discharge] = []
for language in ("php", "python"):
try:
extra_witnesses.extend(witnesses(root=root, texts=ctx.texts, entries=entries, language=language))
except Exception as error:
degradations.append({"stage": "enforcement", "reason": type(error).__name__})
# Boolean permission predicates need an exit; they are not throwing guards.
for op in handler_ends:
ir, text = irs[op.path]
for witness in find_discharges(ir, facts.for_language(ir.language), flow.for_language(ir.language), text=text):
if (
witness.function == op.function
and witness.kind == "ownership_check"
and dominance.dominates(witness, op, None)
and handlers_of.get(op.path, {}).get(op.function) == "stapler"
):
extra_witnesses.append(replace(witness, kind="path_guard"))
findings: dict[str, dict[str, Any]] = {} findings: dict[str, dict[str, Any]] = {}
checked: set[Operation] = set() checked: set[Operation] = set()
undischarged: set[Operation] = set() undischarged: set[Operation] = set()
@@ -204,7 +249,8 @@ def prove_change(root: Path, change: Mapping[str, Any], ctx: Context | None = No
dominance=dominance, dominance=dominance,
store_shapes=(), store_shapes=(),
min_siblings=3, min_siblings=3,
extra_witnesses=(), extra_witnesses=extra_witnesses,
handlers_of=handlers_of,
assume_entries_protected=True, assume_entries_protected=True,
) )
degradations.extend(result.degradations) degradations.extend(result.degradations)
@@ -212,13 +258,15 @@ def prove_change(root: Path, change: Mapping[str, Any], ctx: Context | None = No
for op in operations.get((target_path, target_name), ()): for op in operations.get((target_path, target_name), ()):
checked.add(op) checked.add(op)
# Silence from a public premise is not evidence that a check dominated the operation. # Silence from a public premise is not evidence that a check dominated the operation.
if not any( dominated = {
w.kind in op.requires w.kind
for w in result.discharges
if w.kind in op.requires
and valid_witness(w) and valid_witness(w)
and dominance.dominates(w, op, record) and dominance.dominates(w, op, record)
and (_relevant(w, op) or _on_caller_hop(w, record, op, dominance)) and (_relevant(w, op) or _on_caller_hop(w, record, op, dominance))
for w in result.discharges }
): if (set(op.requires) - dominated) if op.fact_id in handler_ids else not dominated:
undischarged.add(op) undischarged.add(op)
for finding in result.findings: for finding in result.findings:
op = finding.operation op = finding.operation
@@ -232,7 +280,7 @@ def prove_change(root: Path, change: Mapping[str, Any], ctx: Context | None = No
if call.line == op.line and any(call.name == name or call.name.endswith("." + name) for name in op_fact.calls) if call.line == op.line and any(call.name == name or call.name.endswith("." + name) for name in op_fact.calls)
} }
) )
entry = asdict(entry_index[record.entry]) entry_payload = asdict(entry_index[record.entry])
hops = [list(hop) for hop in record.hops] hops = [list(hop) for hop in record.hops]
findings.setdefault( findings.setdefault(
identity, identity,
@@ -247,7 +295,7 @@ def prove_change(root: Path, change: Mapping[str, Any], ctx: Context | None = No
f"operation {op.fact_id} ({op.kind}) at {op.path}:{op.line}; missing {finding.missing}", f"operation {op.fact_id} ({op.kind}) at {op.path}:{op.line}; missing {finding.missing}",
], ],
id=identity, id=identity,
entry=entry, entry=entry_payload,
hops=hops, hops=hops,
operation=dict(id=op.fact_id, kind=op.kind, calls=calls, line=op.line), operation=dict(id=op.fact_id, kind=op.kind, calls=calls, line=op.line),
missing=finding.missing, missing=finding.missing,
@@ -265,3 +313,19 @@ def prove_change(root: Path, change: Mapping[str, Any], ctx: Context | None = No
dict(reasons), dict(reasons),
) )
return ProverResult(tuple(findings.values()), coverage, tuple(degradations)) return ProverResult(tuple(findings.values()), coverage, tuple(degradations))
class _HandlerDominance:
"""Judge a handler's internal guard over its body while retaining its declaration as the finding location.
Call operations retain ordinary order dominance. The checker default and OrderDominance are unchanged.
"""
def __init__(self, texts: Mapping[str, str], handler_ends: Mapping[Operation, int]) -> None:
self.order = OrderDominance(texts)
self.handler_ends = handler_ends
def dominates(self, witness: Discharge, operation: Operation, path: object | None) -> bool:
end = self.handler_ends.get(operation)
measured = replace(operation, line=end) if end is not None else operation
return self.order.dominates(witness, measured, path)
+302 -4
View File
@@ -100,10 +100,18 @@ def analyze_entry_points(
root: Path, targets: list[FileTarget], *, texts: Mapping[str, str] | None = None, include_manifests: bool = True root: Path, targets: list[FileTarget], *, texts: Mapping[str, str] | None = None, include_manifests: bool = True
) -> list[EntryPointRecord]: ) -> list[EntryPointRecord]:
records: list[EntryPointRecord] = [] records: list[EntryPointRecord] = []
source_texts = dict(texts) if texts is not None else {t.path: (root / t.path).read_text(errors="ignore") for t in targets}
if texts is None:
for config in framework_config_paths(root):
source_texts[config] = (root / config).read_text(errors="ignore")
for target in targets: for target in targets:
path = root / target.path text = source_texts[target.path]
text = texts[target.path] if texts is not None else path.read_text(errors="ignore")
records.extend(_language_entry_points(target, text)) records.extend(_language_entry_points(target, text))
if target.language == "php":
framework_entries = _php_framework_entries(target, text, source_texts)
names = {e.function_name for e in framework_entries}
records = [e for e in records if not (e.path == target.path and e.kind == "function" and e.function_name in names)]
records.extend(framework_entries)
records.extend(_tag_entry_points(target)) records.extend(_tag_entry_points(target))
if include_manifests: if include_manifests:
records.extend(_route_manifest_entry_points(root, targets)) records.extend(_route_manifest_entry_points(root, targets))
@@ -178,9 +186,9 @@ def _language_entry_points(target: FileTarget, text: str) -> list[EntryPointReco
if target.language == "java": if target.language == "java":
return _stapler_entries(target, text) + _jetty_entries(target, text) return _stapler_entries(target, text) + _jetty_entries(target, text)
if target.language == "python": if target.language == "python":
return _python_entry_points(target, text) return _python_entry_points(target, text) + _tornado_entries(target, text)
if target.language in {"javascript", "typescript"}: if target.language in {"javascript", "typescript"}:
return _js_entry_points(target, text) return _js_entry_points(target, text) + _remix_exports(target, text)
if target.language in {"c", "cpp"}: if target.language in {"c", "cpp"}:
return _c_entry_points(target, text) return _c_entry_points(target, text)
if target.language == "solidity": if target.language == "solidity":
@@ -190,6 +198,296 @@ def _language_entry_points(target: FileTarget, text: str) -> list[EntryPointReco
return [] return []
def framework_config_paths(root: Path) -> list[str]:
"""Small, declared configuration locations outside the product source census."""
candidates = [
root / "config" / name for name in ("routes.yaml", "routes.yml", "routes.xml", "packages/security.yaml", "packages/security.yml")
]
candidates.extend((root / "config/routes").glob("*.yaml"))
candidates.extend((root / "config/routes").glob("*.yml"))
return sorted(path.relative_to(root).as_posix() for path in candidates if path.is_file() and not path.is_symlink())
@dataclass(frozen=True)
class _PhpMethod:
owner: str
owner_header: str
owner_attributes: str
name: str
line: int
end_line: int
header: str
body: str
def _php_methods(text: str) -> list[_PhpMethod]:
"""CST class and method boundaries, including the method's own attributes and docblock."""
from .semantic.extra import grammar_for
grammar = grammar_for("php")
if grammar is None:
return []
from tree_sitter import Parser
data = text.encode()
parser = Parser(grammar)
tree = parser.parse(data)
if tree.root_node.has_error:
return []
result: list[_PhpMethod] = []
def value(node: Any) -> str:
return data[node.start_byte : node.end_byte].decode()
def walk(node: Any, owner: str = "", header: str = "", attributes: str = "", prior_comment: str = "") -> None:
if node.type == "class_declaration":
name, body = node.child_by_field_name("name"), node.child_by_field_name("body")
owner = value(name) if name is not None else ""
header = data[node.start_byte : body.start_byte if body else node.end_byte].decode()
attributes = header + prior_comment
if node.type == "method_declaration" and owner:
name, body = node.child_by_field_name("name"), node.child_by_field_name("body")
if name is not None and body is not None:
own_header = data[node.start_byte : body.start_byte].decode()
declaration = re.search(r"\bfunction\b", own_header)
line = data[: node.start_byte].count(b"\n") + 1 + own_header[: declaration.start() if declaration else 0].count("\n")
own_header = prior_comment + own_header
result.append(
_PhpMethod(
owner, header, attributes, value(name), line, data[: node.end_byte].count(b"\n") + 1, own_header, value(body)
)
)
return
previous_comment = ""
for child in node.named_children:
walk(child, owner, header, attributes, previous_comment)
previous_comment = value(child) if child.type == "comment" else ""
walk(tree.root_node)
return result
@dataclass(frozen=True)
class _PhpRoute:
source: str
owner: str
method: str
path: str
statement: str
def _php_routes(texts: Mapping[str, str]) -> list[_PhpRoute]:
"""Resolve literal Laravel array and Controller@method registrations without name-only joins."""
routes: list[_PhpRoute] = []
pattern = re.compile(r"\bRoute::(?:get|post|put|patch|delete|any|match)\s*\([^;]+;", re.S)
for source, text in texts.items():
if not source.endswith(".php"):
continue
# Comments cannot register a route. Preserve strings, which carry the route arguments.
clean = re.sub(r"/\*.*?\*/|//[^\n]*", "", text, flags=re.S)
for match in pattern.finditer(clean):
statement = match.group()
target = re.search(r"\[\s*([\w\\]+)::class\s*,\s*['\"](\w+)['\"]\s*\]", statement)
if target is None:
target = re.search(r"['\"]([\w\\]+)@(\w+)['\"]", statement)
path = re.search(r"\(\s*['\"]([^'\"]+)['\"]", statement)
if target:
routes.append(_PhpRoute(source, target[1].split("\\")[-1], target[2], path[1] if path else "", statement))
return routes
def _php_framework_entries(target: FileTarget, text: str, texts: Mapping[str, str]) -> list[EntryPointRecord]:
records: list[EntryPointRecord] = []
routes = _php_routes(texts)
for method in _php_methods(text):
if re.search(r"\b(private|protected)\b", method.header) or method.name.startswith("_"):
continue
route_attribute = re.search(r"(?:#\[|@)(?:[\w\\]+\\)?Route\s*\(\s*(?:path\s*[:=]\s*)?['\"]([^'\"]+)['\"]", method.header)
configured: list[tuple[str, str]] = []
if re.search(r"extends\s+(?:[\w\\]+\\)?AbstractController\b", method.owner_header):
for source, config in texts.items():
if "route" not in source or not source.endswith((".yaml", ".yml", ".xml", ".php")):
continue
if re.search(r"\b" + re.escape(method.owner) + r"::" + re.escape(method.name) + r"\b", config):
configured.append((source, method.name))
if route_attribute or configured:
route_name = route_attribute[1] if route_attribute else configured[0][1]
class_route = re.search(r"(?:#\[|@)(?:[\w\\]+\\)?Route\s*\(\s*(?:path\s*[:=]\s*)?['\"]([^'\"]+)['\"]", method.owner_attributes)
if class_route and route_attribute:
route_name = class_route[1].rstrip("/") + "/" + route_name.lstrip("/")
evidence = [f"Symfony Route {route_name}"] + [f"Symfony controller registration in {source}" for source, _ in configured]
records.append(
_entry(target, method.line, method.end_line, method.name, route_name, "route", "unknown", "http_request", evidence, [])
)
continue
component = re.search(r"extends\s+(?:Livewire\\)?Component\b", method.owner_header)
if component and ("Livewire\\" in text or "Livewire\\" in method.owner_header):
bindings = [
source
for source, body in texts.items()
if re.search(r"wire:[\w.:-]+\s*=\s*['\"]" + re.escape(method.name) + r"(?:\(|['\"])", body)
]
records.append(
_entry(
target,
method.line,
method.end_line,
method.name,
method.name,
"route",
"unknown",
"http_request",
[f"Livewire Component {method.owner}"] + [f"Livewire wire binding in {source}" for source in bindings],
[],
)
)
continue
for route in routes:
if (route.owner, route.method) != (method.owner, method.name):
continue
access: AccessLevel = "unknown" if "middleware" in route.statement else "public"
records.append(
_entry(
target,
method.line,
method.end_line,
method.name,
route.path or method.name,
"route",
access,
"http_request",
[f"Laravel Route registration in {route.source}: {route.path}"],
[],
access_declared=access == "public",
)
)
return records
def _tornado_entries(target: FileTarget, text: str) -> list[EntryPointRecord]:
try:
tree = ast.parse(text)
except SyntaxError:
return []
records: list[EntryPointRecord] = []
classes = [node for node in ast.walk(tree) if isinstance(node, ast.ClassDef)]
bases = {"RequestHandler", "WebSocketHandler"}
for _ in classes:
for node in classes:
if any(_safe_unparse(base).split(".")[-1] in bases for base in node.bases):
bases.add(node.name)
for node in classes:
direct = any(_safe_unparse(base).split(".")[-1] in bases for base in node.bases)
# The pool's terminal socket inherits through terminado and explicitly invokes Tornado's base method.
explicit_super = any(_safe_unparse(base) == "terminado.TermSocket" for base in node.bases) and any(
isinstance(call, ast.Call) and _safe_unparse(call.func).startswith("tornado.websocket.WebSocketHandler.")
for call in ast.walk(node)
)
if not direct and not explicit_super:
continue
for method in node.body:
if not isinstance(method, (ast.FunctionDef, ast.AsyncFunctionDef)) or method.name not in {
"get",
"post",
"put",
"delete",
"patch",
"head",
"options",
"open",
}:
continue
records.append(
_entry(
target,
method.lineno,
method.end_lineno,
method.name,
method.name,
"route",
"unknown",
"http_request",
[f"Tornado {node.name}.{method.name}", *[_safe_unparse(d) for d in method.decorator_list]],
[],
)
)
return records
def _remix_exports(target: FileTarget, text: str) -> list[EntryPointRecord]:
if "app/routes/" not in "/" + target.path:
return []
from .semantic.extra import grammar_for
grammar = grammar_for("tsx" if Path(target.path).suffix == ".tsx" else target.language)
if grammar is None:
return []
from tree_sitter import Parser
data = text.encode()
parser = Parser(grammar)
tree = parser.parse(data)
if tree.root_node.has_error:
return []
records: list[EntryPointRecord] = []
def walk(node: Any) -> None:
if node.type == "export_statement":
for declaration in node.named_children:
candidates = (
declaration.named_children if declaration.type in {"lexical_declaration", "variable_declaration"} else [declaration]
)
for candidate in candidates:
name_node = candidate.child_by_field_name("name")
if name_node is None:
continue
name = data[name_node.start_byte : name_node.end_byte].decode()
if name not in {"action", "loader"}:
continue
function = candidate.child_by_field_name("value") if candidate.type == "variable_declarator" else candidate
if function is None or function.type not in {"function_declaration", "arrow_function", "function_expression"}:
continue
records.append(
_entry(
target,
data[: function.start_byte].count(b"\n") + 1,
data[: function.end_byte].count(b"\n") + 1,
name,
name,
"route",
"unknown",
"http_request",
[f"Remix route export {name}"],
[],
)
)
return
for child in node.named_children:
walk(child)
walk(tree.root_node)
return records
def entry_framework(entry: object) -> str | None:
"""The recogniser id recorded in a handler entry's evidence."""
evidence = " ".join(getattr(entry, "access_evidence", ()))
for name, identifier in (
("Stapler", "stapler"),
("Jetty", "jetty"),
("Servlet", "servlet"),
("JAX-RS", "jaxrs"),
("Symfony", "symfony"),
("Livewire", "livewire"),
("Laravel", "laravel"),
("Remix", "remix"),
("Tornado", "tornado"),
):
if name in evidence:
return identifier
return None
def _java_methods(target: FileTarget, text: str) -> list[tuple[object, str, str]]: def _java_methods(target: FileTarget, text: str) -> list[tuple[object, str, str]]:
"""Use the CST to keep class declarations, parameters and method bodies separate.""" """Use the CST to keep class declarations, parameters and method bodies separate."""
from .semantic.extra import grammar_for from .semantic.extra import grammar_for
@@ -1,3 +1,5 @@
# Handler obligations: Jenkins form validation and list filling require permission checks.
# https://www.jenkins.io/doc/developer/security/form-validation/ (reference in the approved brief).
# Delivery A additionally checked actual calls in Git HEAD objects (2026-10-14): # Delivery A additionally checked actual calls in Git HEAD objects (2026-10-14):
# p4-plugin: CredentialsHelper.lookupCredentialsInItem, P4PasswordImpl and P4TicketImpl # p4-plugin: CredentialsHelper.lookupCredentialsInItem, P4PasswordImpl and P4TicketImpl
# call ConnectionFactory.testConnection, ConnectionFactory calls connect, ReviewNotifier # call ConnectionFactory.testConnection, ConnectionFactory calls connect, ReviewNotifier
@@ -79,3 +81,11 @@ id = "identity-from-context"
kind = "identity_constraint" kind = "identity_constraint"
identity_sources = ["SecurityContextHolder.getContext().getAuthentication()", "Jenkins.getAuthentication()", "Jenkins.getAuthentication2()", "getUserPrincipal()"] identity_sources = ["SecurityContextHolder.getContext().getAuthentication()", "Jenkins.getAuthentication()", "Jenkins.getAuthentication2()", "getUserPrincipal()"]
request_sources = ["getParameter(", "getHeader(", "getCookies(", "getQueryString("] request_sources = ["getParameter(", "getHeader(", "getCookies(", "getQueryString("]
[[operation]]
id = "stapler-handler"
kind = "privileged_action"
handlers = ["^do(?:Check.*|Fill.*Items|Test.*|Validate.*|Verify.*)$"]
framework = "stapler"
requires = ["path_guard"]
sensitivity = "high"
@@ -1,3 +1,9 @@
# Delivery B enforcement sources: shopperlabs__shopper Git HEAD packages/admin/routes/web.php
# uses chained middleware and groups. kimai__kimai at the pool revision uses @Route(path=...)
# and @Security in src/Controller/InvoiceController.php (class and action docblocks).
# Recognised contract names from the approved spec and the references below: Laravel auth, can:,
# role and permission middleware, constructor middleware/authorizeResource; Symfony IsGranted,
# Security and security.yaml access_control. No PHP operation or discharger row was changed.
# Closed authorization facts for Laravel, WordPress and Symfony. # Closed authorization facts for Laravel, WordPress and Symfony.
# Checked shopperlabs__shopper Git HEAD source objects in /home/mc/ousast-results/valid/train/hist/: # Checked shopperlabs__shopper Git HEAD source objects in /home/mc/ousast-results/valid/train/hist/:
# CollectionProducts.authorize, SetupGuide.can, AssetController.abort_unless, # CollectionProducts.authorize, SetupGuide.can, AssetController.abort_unless,
@@ -1,3 +1,6 @@
# Checked mage-ai__mage-ai Git HEAD and websocket fix 66440e4d3: no authenticated
# decorator or get_current_user definition found. authenticated is already an existing fact.
# Handler names follow the Tornado RequestHandler/WebSocketHandler contract in the approved spec.
# Obligated operations and dischargers for Python web code (authorization-obligations, Req 1). Closed data. # Obligated operations and dischargers for Python web code (authorization-obligations, Req 1). Closed data.
version = "1" version = "1"
language = "python" language = "python"
@@ -84,3 +87,11 @@ permissive_values = ["False"]
id = "validated-input" id = "validated-input"
kind = "validated_input" kind = "validated_input"
calls = ["validate", "schema.load", "parse_obj", "model_validate", "clean", "is_valid", "secure_filename", "safe_join"] calls = ["validate", "schema.load", "parse_obj", "model_validate", "clean", "is_valid", "secure_filename", "safe_join"]
[[operation]]
id = "tornado-handler"
kind = "privileged_action"
handlers = ["^(?:get|post|put|delete|patch|head|options|open)$"]
framework = "tornado"
requires = ["path_guard"]
sensitivity = "high"
@@ -1,3 +1,6 @@
# Checked triggerdotdev__trigger.dev Git HEAD: apps/webapp/app/services/session.server.ts
# defines requireUser and requireUserId; apiAuth.server.ts defines authenticateApiRequest;
# routes/resources.dashboard-agent.alerts.$channelId.unsubscribe.tsx defines and calls authorize.
# Obligated operations and dischargers for TypeScript web code (same surface as JavaScript) (authorization-obligations, Req 1). Closed data. # Obligated operations and dischargers for TypeScript web code (same surface as JavaScript) (authorization-obligations, Req 1). Closed data.
version = "1" version = "1"
language = "typescript" language = "typescript"
@@ -72,7 +75,7 @@ sensitivity = "medium"
[[discharger]] [[discharger]]
id = "route-guard" id = "route-guard"
kind = "path_guard" kind = "path_guard"
calls = ["authenticate", "requireAuth", "ensureAuthenticated", "isAuthenticated", "verifyToken", "checkAuth", "protect", "authorize", "requireUser", "getServerSession", "auth", "withAuth", "jwt.verify", "passport.authenticate"] calls = ["authenticate", "requireAuth", "ensureAuthenticated", "isAuthenticated", "verifyToken", "checkAuth", "protect", "authorize", "requireUser", "requireUserId", "authenticateApiRequest", "getServerSession", "auth", "withAuth", "jwt.verify", "passport.authenticate"]
decorators = ["UseGuards", "Authorized", "Roles"] decorators = ["UseGuards", "Authorized", "Roles"]
# `req.session` is the authenticated context itself, and it is listed because a destructuring spells the # `req.session` is the authenticated context itself, and it is listed because a destructuring spells the
@@ -112,3 +115,11 @@ permissive_values = ["false"]
id = "validated-input" id = "validated-input"
kind = "validated_input" kind = "validated_input"
calls = ["parse", "safeParse", "validate", "validateSync", "sanitize", "escape", "validationResult", "z.object", "assert"] calls = ["parse", "safeParse", "validate", "validateSync", "sanitize", "escape", "validationResult", "z.object", "assert"]
[[operation]]
id = "remix-handler"
kind = "privileged_action"
handlers = ["^action$"]
framework = "remix"
requires = ["path_guard", "ownership_check"]
sensitivity = "high"
@@ -66,6 +66,7 @@ def check_obligations(
min_siblings: int, min_siblings: int,
extra_witnesses: Sequence[Discharge] = (), extra_witnesses: Sequence[Discharge] = (),
assume_entries_protected: bool = False, assume_entries_protected: bool = False,
handlers_of: Mapping[str, Mapping[str, str]] | None = None,
) -> ObligationResult: ) -> ObligationResult:
operations: list[Operation] = [] operations: list[Operation] = []
witnesses: list[Discharge] = list(extra_witnesses) witnesses: list[Discharge] = list(extra_witnesses)
@@ -81,7 +82,7 @@ def check_obligations(
continue continue
scoped = facts.for_language(ir.language) scoped = facts.for_language(ir.language)
flow = flow_facts.for_language(ir.language) flow = flow_facts.for_language(ir.language)
operations.extend(find_operations(ir, scoped, text=text)) operations.extend(find_operations(ir, scoped, text=text, handlers_of=(handlers_of or {}).get(path)))
witnesses.extend(find_discharges(ir, scoped, flow, text=text, entries=entries)) witnesses.extend(find_discharges(ir, scoped, flow, text=text, entries=entries))
sets = sibling_sets(entries, operations, witnesses) sets = sibling_sets(entries, operations, witnesses)
anomalies, under = consistency_anomalies(sets, min_siblings=min_siblings) anomalies, under = consistency_anomalies(sets, min_siblings=min_siblings)
@@ -122,11 +123,15 @@ def check_obligations(
# (a) the operation's own obligation: discharged when any accepted discharger dominates. A declared public route # (a) the operation's own obligation: discharged when any accepted discharger dominates. A declared public route
# waives the path guard here too (Req 4.4), so the operation falls through to the next kind it accepts. # waives the path guard here too (Req 4.4), so the operation falls through to the next kind it accepts.
required = [kind for kind in operation.requires if not (kind == "path_guard" and declared_access == "public")] required = [kind for kind in operation.requires if not (kind == "path_guard" and declared_access == "public")]
if required and not (set(required) & dominated_kinds): handler_fact = next((f for f in facts.operations if f.id == operation.fact_id and f.handlers), None)
missing = next( missing_kinds = (
(kind for kind in required if (handler, kind) in anomaly_index), [kind for kind in required if kind not in dominated_kinds]
required[0], if handler_fact is not None and handlers_of is not None
else [next((kind for kind in required if (handler, kind) in anomaly_index), required[0])]
if required and not (set(required) & dominated_kinds)
else []
) )
for missing in missing_kinds:
finding = _finding( finding = _finding(
operation, operation,
missing, missing,
@@ -139,6 +144,7 @@ def check_obligations(
paths, paths,
facts, facts,
entry if assume_entries_protected else None, entry if assume_entries_protected else None,
handler_obligation=handler_fact is not None and handlers_of is not None,
) )
if finding is not None: if finding is not None:
findings.append(finding) findings.append(finding)
@@ -225,6 +231,8 @@ def _finding(
paths: Sequence[object], paths: Sequence[object],
facts: ObligationFacts, facts: ObligationFacts,
assumed_entry: object | None = None, assumed_entry: object | None = None,
*,
handler_obligation: bool = False,
) -> ObligationFinding | None: ) -> ObligationFinding | None:
wrong = next((w for w in same if w.kind == missing and not valid_witness(w)), None) wrong = next((w for w in same if w.kind == missing and not valid_witness(w)), None)
declared = policy.resources.get(operation.resource or "") if policy is not None else None declared = policy.resources.get(operation.resource or "") if policy is not None else None
@@ -247,14 +255,25 @@ def _finding(
label = "consistency_violation" label = "consistency_violation"
evidence = anomaly.siblings + anomaly.evidence[len(anomaly.siblings) :] evidence = anomaly.siblings + anomaly.evidence[len(anomaly.siblings) :]
elif ( elif (
paths and assumed_entry is not None and getattr(assumed_entry, "access_level", "public") != "public" and declared_access != "public" paths
and assumed_entry is not None
and (
(getattr(assumed_entry, "access_level", "public") != "public" and declared_access != "public")
or (handler_obligation and missing != "path_guard")
)
): ):
label = "unguarded_path" label = "unguarded_path"
evidence = ( evidence = (
f"entry {getattr(assumed_entry, 'kind', '')}: {getattr(assumed_entry, 'path', '')}::" f"entry {getattr(assumed_entry, 'kind', '')}: {getattr(assumed_entry, 'path', '')}::"
f"{getattr(assumed_entry, 'function_name', '')}", f"{getattr(assumed_entry, 'function_name', '')}",
*(str(item) for item in getattr(assumed_entry, "access_evidence", ())), *(str(item) for item in getattr(assumed_entry, "access_evidence", ())),
"no declared policy clause and no sibling norm; the prover assumes a recognised entry is protected", (
"no declared policy clause and no sibling norm; the handler fact requires "
+ missing
+ "; public access waives only path_guard"
if handler_obligation and (declared_access == "public" or getattr(assumed_entry, "access_level", "public") == "public")
else "no declared policy clause and no sibling norm; the prover assumes a recognised entry is protected"
),
) )
elif paths: elif paths:
return None # path-aware mode: without a clause or a sibling norm, nothing justifies the obligation (gating 3) return None # path-aware mode: without a clause or a sibling norm, nothing justifies the obligation (gating 3)
@@ -0,0 +1,104 @@
"""Framework enforcement outside a handler, resolved from cached source and route declarations."""
from __future__ import annotations
import ast
import re
from collections.abc import Mapping, Sequence
from pathlib import Path
import yaml
from ...mapping import EntryPointRecord, _php_methods, _php_routes, entry_framework
from .operations import Discharge
_MIDDLEWARE = re.compile(r"['\"](auth(?::[^'\"]+)?|can:[^'\"]+|role(?::[^'\"]+)?|permission(?::[^'\"]+)?)['\"]")
def witnesses(*, root: Path, texts: Mapping[str, str], entries: Sequence[EntryPointRecord], language: str) -> list[Discharge]:
"""One scoped path guard per enforcement source and covered handler; unrelated declarations discharge nothing.
Texts are supplied by the caller's cache. Root names the checkout; this function never rereads source files.
"""
del root
found: list[Discharge] = []
routes = _php_routes(texts) if language == "php" else []
security = _security_rules(texts) if language == "php" else []
methods_by_path = {path: _php_methods(text) for path, text in texts.items() if language == "php" and path.endswith(".php")}
for entry in entries:
if not entry.function_name or entry.line is None or not entry.defines_handler:
continue
framework = entry_framework(entry)
sources: list[tuple[str, str]] = []
if language == "python" and framework == "tornado":
tree = ast.parse(texts.get(entry.path, ""))
for py_method in ast.walk(tree):
if isinstance(py_method, (ast.FunctionDef, ast.AsyncFunctionDef)) and py_method.lineno == entry.line:
for decorator in py_method.decorator_list:
name = ast.unparse(decorator)
if name in {"authenticated", "tornado.web.authenticated"}:
sources.append(("decorator", f"Tornado {name} in {entry.path}"))
if language == "php" and framework in {"symfony", "laravel", "livewire"}:
methods = methods_by_path.get(entry.path, [])
method = next((m for m in methods if m.name == entry.function_name and m.line == entry.line), None)
if method is None:
continue
if framework == "symfony":
for header in (method.header, method.owner_attributes):
if re.search(r"(?:#\[|@)(?:[\w\\]+\\)?(?:IsGranted|Security)\s*\(", header):
sources.append(("decorator", f"Symfony IsGranted/Security in {entry.path}"))
# Symfony evaluates access_control in order: a public match cannot be skipped for a later role.
for source, path, roles in security:
if re.search(path, entry.name):
if roles and not any(role in {"PUBLIC_ACCESS", "IS_AUTHENTICATED_ANONYMOUSLY"} for role in roles):
sources.append(("router", f"Symfony access_control in {source}"))
break
if framework in {"laravel", "livewire"}:
for route in routes:
if (route.owner, route.method) == (method.owner, method.name):
middleware = re.search(r"->middleware\s*\((.*?)\)", route.statement, re.S)
if middleware and _MIDDLEWARE.search(middleware[1]):
sources.append(("router", f"Laravel route middleware in {route.source}"))
for constructor in methods:
if constructor.owner != method.owner or constructor.name != "__construct":
continue
for match in re.finditer(r"\$this->(?:middleware|authorizeResource)\s*\([^;]+;", constructor.body, re.S):
statement = match.group()
only = re.search(r"->only\s*\((.*?)\)", statement, re.S)
exclude = re.search(r"->except\s*\((.*?)\)", statement, re.S)
if only and not re.search(r"['\"]" + re.escape(method.name) + r"['\"]", only[1]):
continue
if exclude and re.search(r"['\"]" + re.escape(method.name) + r"['\"]", exclude[1]):
continue
if "authorizeResource" in statement or _MIDDLEWARE.search(statement):
sources.append(("router", f"Laravel constructor enforcement in {entry.path}"))
for scope, detail in dict.fromkeys(sources):
found.append(
Discharge(
entry.path, None, entry.function_name, "path_guard", f"{framework}-enforcement", "authenticated_context", scope, detail
)
)
return list(dict.fromkeys(found))
def _security_rules(texts: Mapping[str, str]) -> list[tuple[str, str, tuple[str, ...]]]:
result: list[tuple[str, str, tuple[str, ...]]] = []
for source, text in texts.items():
if not source.endswith(("config/packages/security.yaml", "config/packages/security.yml")):
continue
payload = yaml.safe_load(text)
if not isinstance(payload, dict) or not isinstance(payload.get("security"), dict):
continue
rules = payload["security"].get("access_control", [])
if not isinstance(rules, list):
continue
for rule in rules:
if not isinstance(rule, dict) or not isinstance(rule.get("path"), str):
continue
roles = rule.get("roles", ())
if isinstance(roles, str):
roles = (roles,)
if not isinstance(roles, (list, tuple)):
continue
result.append((source, rule["path"], tuple(str(role) for role in roles)))
return result
@@ -7,6 +7,7 @@ data bug and fails loud. Nothing detects an obligation that is not named here.
from __future__ import annotations from __future__ import annotations
import re
import tomllib import tomllib
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@@ -20,7 +21,9 @@ PROVENANCE_KINDS = ("authenticated_context", "request_input", "constant", "unkno
SENSITIVITIES = ("low", "medium", "high") SENSITIVITIES = ("low", "medium", "high")
DEFAULT_OBLIGATION_FACTS_DIR = Path(__file__).resolve().parents[2] / "ruleset" / "obligations" DEFAULT_OBLIGATION_FACTS_DIR = Path(__file__).resolve().parents[2] / "ruleset" / "obligations"
_OPERATION_FIELDS = frozenset({"id", "kind", "calls", "resource_arg", "requires", "sensitivity", "query_document_arg"}) _OPERATION_FIELDS = frozenset(
{"id", "kind", "calls", "resource_arg", "requires", "sensitivity", "query_document_arg", "handlers", "framework"}
)
_DISCHARGER_FIELDS = frozenset( _DISCHARGER_FIELDS = frozenset(
{"id", "kind", "calls", "decorators", "identity_sources", "constraint_params", "request_sources", "permissive_values"} {"id", "kind", "calls", "decorators", "identity_sources", "constraint_params", "request_sources", "permissive_values"}
) )
@@ -45,6 +48,8 @@ class OperationFact:
# calls were a selector or a predicate. The position is 1-based over the real arguments; the arbiter that # calls were a selector or a predicate. The position is 1-based over the real arguments; the arbiter that
# can see argument shapes decides it, and a language whose calls are unambiguous never sets this. # can see argument shapes decides it, and a language whose calls are unambiguous never sets this.
query_document_arg: int | None = None query_document_arg: int | None = None
handlers: tuple[str, ...] = ()
framework: str | None = None
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -109,6 +114,17 @@ def _load_obligation_facts_uncached(directory: Path | None = None) -> Obligation
document_arg = row.get("query_document_arg") document_arg = row.get("query_document_arg")
if document_arg is not None and (not isinstance(document_arg, int) or document_arg < 1): if document_arg is not None and (not isinstance(document_arg, int) or document_arg < 1):
raise ObligationFactsError(f"{path}: operation {row.get('id')!r} query_document_arg must be a positive integer") raise ObligationFactsError(f"{path}: operation {row.get('id')!r} query_document_arg must be a positive integer")
handlers = _strings(row.get("handlers"), "handlers", path)
framework = row.get("framework")
if framework is not None and (not isinstance(framework, str) or not framework):
raise ObligationFactsError("invalid operation framework")
if handlers and framework is None:
raise ObligationFactsError("handler operation needs a framework")
for pattern in handlers:
try:
re.compile(pattern)
except re.error as exc:
raise ObligationFactsError(type(exc).__name__) from exc
operations.append( operations.append(
OperationFact( OperationFact(
id=str(row["id"]), id=str(row["id"]),
@@ -119,6 +135,8 @@ def _load_obligation_facts_uncached(directory: Path | None = None) -> Obligation
requires=requires, requires=requires,
sensitivity=sensitivity, sensitivity=sensitivity,
query_document_arg=document_arg, query_document_arg=document_arg,
handlers=handlers,
framework=framework,
) )
) )
for row in _rows(payload.get("discharger"), path): for row in _rows(payload.get("discharger"), path):
@@ -10,7 +10,7 @@ the authenticated context (a constraint bound from the request or a constant dis
from __future__ import annotations from __future__ import annotations
import re import re
from collections.abc import Sequence from collections.abc import Mapping, Sequence
from dataclasses import dataclass from dataclasses import dataclass
from ..facts import SemanticFacts from ..facts import SemanticFacts
@@ -44,7 +44,9 @@ class Discharge:
detail: str = "" # identifier only: the decorator or call name that witnessed the discharge detail: str = "" # identifier only: the decorator or call name that witnessed the discharge
def find_operations(ir: FileIR, facts: ObligationFacts, *, text: str) -> tuple[Operation, ...]: def find_operations(
ir: FileIR, facts: ObligationFacts, *, text: str, handlers_of: Mapping[str, str] | None = None
) -> tuple[Operation, ...]:
"""Every obligated operation in the file's named functions, in source order.""" """Every obligated operation in the file's named functions, in source order."""
if not ir.parse_ok: if not ir.parse_ok:
return () return ()
@@ -54,6 +56,14 @@ def find_operations(ir: FileIR, facts: ObligationFacts, *, text: str) -> tuple[O
for function in ir.functions: for function in ir.functions:
if function.name == "<module>": if function.name == "<module>":
continue continue
for fact in facts.operations:
if (
fact.handlers
and not fact.calls
and (handlers_of or {}).get(function.name) == fact.framework
and any(re.search(pattern, function.name) for pattern in fact.handlers)
):
found.append(Operation(ir.path, function.start_line, function.name, fact.kind, fact.id, None, fact.requires))
for site in _operations(function, facts, lines, kind=None): for site in _operations(function, facts, lines, kind=None):
key = (function.name, site.call.line, site.fact.id) key = (function.name, site.call.line, site.fact.id)
if key in seen: if key in seen:
@@ -30,6 +30,19 @@ def compose_paths(
raise ValueError("invalid path bounds") raise ValueError("invalid path bounds")
found: dict[tuple[tuple[str, int | None], ...], PathRecord] = {} found: dict[tuple[tuple[str, int | None], ...], PathRecord] = {}
unreached: list[str] = [] unreached: list[str] = []
if not entries:
# Nothing can complete a chain; one ordinary change of a 3000-file tree spent eleven minutes walking
# callers towards entries that did not exist.
return [], [f"{path}::{function}" for path, function in dict.fromkeys(targets)]
# Each node's callers are looked up once per change: the walk revisits nodes across chains and targets.
known: dict[str, list[tuple[str, int]]] = {}
def callers_of_node(node: str) -> list[tuple[str, int]]:
if node not in known:
node_path, _, node_function = node.rpartition("::")
known[node] = list(dict.fromkeys(callers(node_path, node_function)))[:fan_out]
return known[node]
for path, function in dict.fromkeys(targets): for path, function in dict.fromkeys(targets):
target = f"{path}::{function}" target = f"{path}::{function}"
reached = False reached = False
@@ -43,9 +56,8 @@ def compose_paths(
continue continue
if len(hops) - 1 >= max_depth: if len(hops) - 1 >= max_depth:
continue continue
node_path, _, node_function = node.rpartition("::")
seen = {hop[0] for hop in hops} seen = {hop[0] for hop in hops}
for caller, line in list(dict.fromkeys(callers(node_path, node_function)))[:fan_out]: for caller, line in callers_of_node(node):
if caller not in seen: if caller not in seen:
pending.append(((caller, line), *hops)) pending.append(((caller, line), *hops))
if not reached: if not reached:
+109
View File
@@ -181,3 +181,112 @@ def test_nonpublic_unreached_function_in_partly_covered_repo(tmp_path):
assert result.coverage.frameworks_recognised == {"stapler": 1} assert result.coverage.frameworks_recognised == {"stapler": 1}
assert result.coverage.unchecked_by_reason["framework_unrecognised"] == 1 assert result.coverage.unchecked_by_reason["framework_unrecognised"] == 1
assert not result.findings assert not result.findings
@pytest.mark.parametrize("guard,expected", [("", 1), ("checkPermission(ADMINISTER);", 0)])
def test_handler_without_listed_call(tmp_path, guard, expected):
root, change = repository(
tmp_path,
f"""class X extends Descriptor<Thing> {{
public Object doCheckValue() {{
{guard}
return 1;
}}
}}""",
)
result = prove_change(root, change)
assert len(result.findings) == expected and result.coverage.operations_checked == 1
if expected:
assert result.findings[0]["line"] == 2
assert result.findings[0]["operation"]["id"] == "stapler-handler"
@pytest.mark.parametrize("middleware,expected", [("->middleware(['auth'])", 0), ("->middleware(['throttle:10'])", 1)])
def test_laravel_enforcement_in_prover(tmp_path, middleware, expected):
root, change = repository(
tmp_path,
"class Plain {}",
{
"Editor.php": "<?php class Editor extends Controller { public function store() { Product::save(); } }",
"routes/web.php": f"<?php Route::post('/items', [Editor::class, 'store']){middleware};",
},
)
change["files"] = [{"path": "Editor.php", "head_lines": [[1, 1]]}]
result = prove_change(root, change)
assert len(result.findings) == expected and result.coverage.operations_checked == 1
assert result.coverage.frameworks_recognised == {"laravel": 1}
@pytest.mark.parametrize("arrow", [False, True])
def test_remix_session_does_not_discharge_ownership(tmp_path, arrow):
declaration = "export const action = async () =>" if arrow else "export async function action()"
text = f"""{declaration} {{
const user = await requireUser(request);
return deactivateRecord(requestId);
}}"""
root, change = repository(tmp_path, "class Plain {}", {"app/routes/items.ts": text})
change["files"] = [{"path": "app/routes/items.ts", "head_lines": [[1, 4]]}]
result = prove_change(root, change)
assert result.coverage.operations_checked == 1 and result.coverage.operations_discharged == 0
assert len(result.findings) == 1 and result.findings[0]["missing"] == "ownership_check"
@pytest.mark.parametrize("decorator,expected", [("", 1), ("@tornado.web.authenticated", 0)])
def test_tornado_enforcement_in_prover(tmp_path, decorator, expected):
text = f"""import tornado.web
class Handler(tornado.web.RequestHandler):
{decorator}
def get(self):
return 1
"""
root, change = repository(tmp_path, "class Plain {}", {"app.py": text})
change["files"] = [{"path": "app.py", "head_lines": [[1, 5]]}]
result = prove_change(root, change)
assert len(result.findings) == expected and result.coverage.operations_checked == 1
def test_symfony_yaml_is_read_by_prover(tmp_path):
root, change = repository(
tmp_path,
"class Plain {}",
{
"Controller.php": (
"<?php class Editor extends AbstractController { #[Route('/admin')] public function store() { Product::save(); } }"
),
"config/packages/security.yaml": "security:\n access_control:\n - { path: ^/admin, roles: ROLE_ADMIN }\n",
},
)
change["files"] = [{"path": "Controller.php", "head_lines": [[1, 1]]}]
ctx = Context(root, change)
result = prove_change(root, change, ctx)
assert not result.findings and result.coverage.operations_discharged == 1
assert len(ctx.texts["config/packages/security.yaml"].encode()) > 0
@pytest.mark.parametrize("exits,expected", [(True, 0), (False, 1)])
def test_handler_permission_predicate_requires_exit(tmp_path, exits, expected):
root, change = repository(
tmp_path,
f"""class X extends Descriptor<Thing> {{
public Object doCheckValue() {{
boolean allowed = hasPermission(ADMINISTER);
{"if (!allowed) {" if exits else ""}
{"return null;" if exits else ""}
{"}" if exits else ""}
return 1;
}}
}}""",
)
result = prove_change(root, change)
assert len(result.findings) == expected and result.coverage.operations_discharged == 1 - expected
@pytest.mark.parametrize("public,expected_missing", [(False, ["path_guard", "ownership_check"]), (True, ["ownership_check"])])
def test_remix_missing_kinds_and_public_policy(tmp_path, public, expected_missing):
extra = {"app/routes/items.ts": "export function action() { return deactivateRecord(requestId); }"}
if public:
extra[".openultrasast/obligations.toml"] = 'version = 1\n[[route]]\npath = "action"\naccess = "public"\n'
root, change = repository(tmp_path, "class Plain {}", extra)
change["files"] = [{"path": "app/routes/items.ts", "head_lines": [[1, 1]]}]
result = prove_change(root, change)
assert [finding["missing"] for finding in result.findings] == expected_missing
+172
View File
@@ -67,3 +67,175 @@ def test_jetty_servlet_and_jaxrs(tmp_path, text, name):
def test_unrecognised_and_comments(tmp_path): def test_unrecognised_and_comments(tmp_path):
assert entries(tmp_path, "/* class X extends Descriptor { public void doFake() {} } */\nclass Plain { public void doCheck() {} }") == [] assert entries(tmp_path, "/* class X extends Descriptor { public void doFake() {} } */\nclass Plain { public void doCheck() {} }") == []
def framework_entries(tmp_path, files):
targets = []
for name, text in files.items():
path = tmp_path / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text)
targets.append(build_file_target(tmp_path, path))
return [
e for e in analyze_entry_points(tmp_path, targets, texts=files, include_manifests=False) if e.kind == "route" and e.function_name
]
@pytest.mark.parametrize("annotation", ['#[Route("/items")]', '/** @Route("/items") */'])
def test_symfony_actions(tmp_path, annotation):
rows = framework_entries(
tmp_path,
{
"Controller.php": f"""<?php
class Items extends AbstractController {{
{annotation}
public function show() {{ return 1; }}
private function hidden() {{}}
}}
class Plain {{ public function helper() {{}} }}
"""
},
)
assert [e.function_name for e in rows] == ["show"]
assert "Symfony" in " ".join(rows[0].access_evidence)
assert "/items" in " ".join(rows[0].access_evidence)
def test_livewire_public_methods_and_bindings(tmp_path):
rows = framework_entries(
tmp_path,
{
"Component.php": """<?php
use Livewire\\Component;
class Editor extends Component {
public function store() {}
public function _helper() {}
protected function hidden() {}
}
class Other { public function store() {} }
""",
"view.blade.php": '<button wire:click="store">Save</button>',
},
)
assert [(e.path, e.function_name) for e in rows] == [("Component.php", "store")]
assert "Livewire" in " ".join(rows[0].access_evidence)
@pytest.mark.parametrize("middleware,access", [("", "public"), ("->middleware(['auth', 'can:edit'])", "unknown")])
def test_laravel_cross_file_registration(tmp_path, middleware, access):
rows = framework_entries(
tmp_path,
{
"app/Http/Controllers/Editor.php": """<?php
class Editor extends Controller {
public function store() {}
public function helper() {}
}
class Other extends Controller { public function store() {} }
""",
"routes/web.php": f"<?php Route::post('/items', [Editor::class, 'store']){middleware};",
},
)
assert [(e.function_name, e.access_level) for e in rows] == [("store", access)]
assert rows[0].path == "app/Http/Controllers/Editor.php"
assert "Laravel" in " ".join(rows[0].access_evidence)
def test_symfony_route_config(tmp_path):
rows = framework_entries(
tmp_path,
{
"Controller.php": "<?php class Editor extends AbstractController { public function show() {} public function helper() {} }",
"config/routes.yaml": "items:\n path: /items\n controller: App\\Controller\\Editor::show\n",
},
)
assert [e.function_name for e in rows] == ["show"]
@pytest.mark.parametrize(
"declaration",
[
"export async function action() { return 1; }",
"export const action = async () => { return 1; }",
"export const action: ActionFunction = async () => { return 1; }",
],
)
def test_remix_exports(tmp_path, declaration):
rows = framework_entries(tmp_path, {"apps/web/app/routes/items.ts": declaration + "\nexport function loader() { return 1; }"})
assert [e.function_name for e in rows] == ["action", "loader"]
assert all("Remix" in " ".join(e.access_evidence) and e.access_level == "unknown" for e in rows)
assert framework_entries(tmp_path, {"app/helpers/items.ts": declaration}) == []
@pytest.mark.parametrize("base", ["tornado.web.RequestHandler", "tornado.websocket.WebSocketHandler", "RequestHandler"])
def test_tornado_methods(tmp_path, base):
rows = framework_entries(
tmp_path,
{
"app.py": f"""from tornado.web import RequestHandler
class Handler({base}):
def get(self):
return 1
def open(self):
pass
def helper(self):
pass
class Plain:
def get(self):
pass
"""
},
)
assert [e.function_name for e in rows] == ["get", "open"]
assert all("Tornado" in " ".join(e.access_evidence) for e in rows)
@pytest.mark.parametrize("prefix", ["", "\n" * 320])
def test_symfony_named_paths_and_class_prefix(tmp_path, prefix):
rows = framework_entries(
tmp_path,
{
"Controller.php": prefix
+ """<?php
/** @Route(path="/admin") */
class Editor extends AbstractController {
/** @Route(path="/items") */
public function show() {}
}
"""
},
)
assert [row.name for row in rows] == ["/admin/items"]
assert rows[0].line == 5 + prefix.count("\n")
def test_tornado_inherited_and_explicit_super_handler(tmp_path):
rows = framework_entries(
tmp_path,
{
"app.py": """import tornado.websocket
import terminado
class Base(tornado.websocket.WebSocketHandler):
pass
class Child(Base):
def open(self):
pass
class Terminal(terminado.TermSocket):
def open(self):
tornado.websocket.WebSocketHandler.open(self)
"""
},
)
assert [row.function_name for row in rows] == ["open", "open"]
def test_remix_tsx_with_jsx(tmp_path):
rows = framework_entries(
tmp_path,
{
"app/routes/items.tsx": """export const action = async () => { return 1; };
export default function Items() { return <div>Items</div>; }
"""
},
)
assert [row.function_name for row in rows] == ["action"]
+157
View File
@@ -0,0 +1,157 @@
"""Framework enforcement is scoped to the registered handler, never a nearby method."""
import pytest
from openultrasast.mapping import analyze_entry_points
from openultrasast.preprocess import build_file_target
from openultrasast.semantic.obligations.dominance import OrderDominance
from openultrasast.semantic.obligations.operations import Operation
def mapped(tmp_path, files):
targets = []
for name, text in files.items():
path = tmp_path / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text)
if path.suffix in {".php", ".py"}:
targets.append(build_file_target(tmp_path, path))
return [
e for e in analyze_entry_points(tmp_path, targets, texts=files, include_manifests=False) if e.kind == "route" and e.function_name
]
@pytest.mark.parametrize("guard", ["auth", "can:edit", "role:admin", "permission:edit"])
def test_laravel_route_middleware(tmp_path, guard):
from openultrasast.semantic.obligations.enforcement import witnesses
files = {
"Editor.php": "<?php class Editor extends Controller { public function store() {} public function other() {} }",
"routes/web.php": f"<?php Route::post('/items', [Editor::class, 'store'])->middleware(['{guard}']);",
}
rows = witnesses(root=tmp_path, texts=files, entries=mapped(tmp_path, files), language="php")
(witness,) = rows
assert (witness.path, witness.function, witness.line, witness.scope) == ("Editor.php", "store", None, "router")
assert "routes/web.php" in witness.detail
assert OrderDominance(files).dominates(
witness, Operation("Editor.php", 1, "store", "protected_write", "x", None, ("path_guard",)), None
)
@pytest.mark.parametrize("guard", ["$this->middleware('auth');", "$this->authorizeResource(Item::class, 'item');"])
def test_laravel_constructor(tmp_path, guard):
from openultrasast.semantic.obligations.enforcement import witnesses
files = {
"Editor.php": f"""<?php class Editor extends Controller {{
public function __construct() {{ {guard} }}
public function store() {{}}
}}
Route::post('/items', [Editor::class, 'store']);
"""
}
(witness,) = witnesses(root=tmp_path, texts=files, entries=mapped(tmp_path, files), language="php")
assert witness.scope == "router" and witness.function == "store"
assert OrderDominance(files).dominates(
witness, Operation("Editor.php", 3, "store", "protected_write", "x", None, ("path_guard",)), None
)
@pytest.mark.parametrize("guard", ['#[IsGranted("ROLE_ADMIN")]', "#[Security(\"is_granted('ROLE_ADMIN')\")]"])
def test_symfony_attributes(tmp_path, guard):
from openultrasast.semantic.obligations.enforcement import witnesses
files = {
"Editor.php": f"""<?php class Editor extends AbstractController {{
#[Route('/items')]
{guard}
public function store() {{}}
#[Route('/other')]
public function other() {{}}
}}"""
}
(witness,) = witnesses(root=tmp_path, texts=files, entries=mapped(tmp_path, files), language="php")
assert witness.scope == "decorator" and witness.function == "store"
assert OrderDominance(files).dominates(
witness, Operation("Editor.php", 4, "store", "protected_write", "x", None, ("path_guard",)), None
)
def test_symfony_security_yaml(tmp_path):
from openultrasast.semantic.obligations.enforcement import witnesses
files = {
"Editor.php": "<?php class Editor extends AbstractController { #[Route('/admin')] public function store() {} }",
"config/packages/security.yaml": "security:\n access_control:\n - { path: ^/admin, roles: ROLE_ADMIN }\n",
}
(witness,) = witnesses(root=tmp_path, texts=files, entries=mapped(tmp_path, files), language="php")
assert witness.scope == "router" and "security.yaml" in witness.detail
assert OrderDominance(files).dominates(
witness, Operation("Editor.php", 1, "store", "protected_write", "x", None, ("path_guard",)), None
)
@pytest.mark.parametrize("guard", ["authenticated", "tornado.web.authenticated"])
def test_tornado_decorator(tmp_path, guard):
from openultrasast.semantic.obligations.enforcement import witnesses
files = {
"app.py": f"""import tornado.web
class Handler(tornado.web.RequestHandler):
@{guard}
def get(self):
pass
def post(self):
pass
"""
}
(witness,) = witnesses(root=tmp_path, texts=files, entries=mapped(tmp_path, files), language="python")
assert witness.scope == "decorator" and witness.function == "get"
assert OrderDominance(files).dominates(witness, Operation("app.py", 4, "get", "protected_read", "x", None, ("path_guard",)), None)
@pytest.mark.parametrize("restriction", ["->only(['store'])", "->except(['other'])"])
def test_constructor_restrictions_do_not_guard_other_handlers(tmp_path, restriction):
from openultrasast.semantic.obligations.enforcement import witnesses
files = {
"Editor.php": f"""<?php class Editor extends Controller {{
public function __construct() {{ $this->middleware('auth'){restriction}; }}
public function store() {{}}
public function other() {{}}
}}
Route::post('/items', [Editor::class, 'store']);
Route::get('/other', [Editor::class, 'other']);
"""
}
rows = witnesses(root=tmp_path, texts=files, entries=mapped(tmp_path, files), language="php")
assert [row.function for row in rows] == ["store"]
def test_symfony_first_public_rule_does_not_become_a_guard(tmp_path):
from openultrasast.semantic.obligations.enforcement import witnesses
files = {
"Editor.php": "<?php class Editor extends AbstractController { #[Route('/admin')] public function store() {} }",
"config/packages/security.yaml": (
"security:\n access_control:\n - { path: ^/admin, roles: PUBLIC_ACCESS }\n - { path: ^/admin, roles: ROLE_ADMIN }\n"
),
}
assert witnesses(root=tmp_path, texts=files, entries=mapped(tmp_path, files), language="php") == []
def test_unrelated_guards_and_comments_are_not_witnesses(tmp_path):
from openultrasast.semantic.obligations.enforcement import witnesses
files = {
"Editor.php": """<?php class Editor extends Controller {
public function store() {}
}
// Route::post('/items', [Editor::class, 'store'])->middleware('auth');
Route::post('/items', [Editor::class, 'store'])->middleware('throttle:10');
""",
"app.py": "class Handler(RequestHandler):\n @not_authenticated\n def get(self):\n pass\n",
}
entries = mapped(tmp_path, files)
assert witnesses(root=tmp_path, texts=files, entries=entries, language="php") == []
assert witnesses(root=tmp_path, texts=files, entries=entries, language="python") == []
+27
View File
@@ -115,3 +115,30 @@ def test_delivery_a_java_operations_and_dischargers_resolve():
assert "path_guard" in operations[call].requires assert "path_guard" in operations[call].requires
assert dischargers["checkPermission"].kind == dischargers["checkAnyPermission"].kind == "path_guard" assert dischargers["checkPermission"].kind == dischargers["checkAnyPermission"].kind == "path_guard"
assert dischargers["hasPermission"].kind == "ownership_check" assert dischargers["hasPermission"].kind == "ownership_check"
def test_handler_rows_and_validation(tmp_path):
from openultrasast.semantic.obligations.facts import ObligationFactsError, load_obligation_facts
text = """version = "1"
language = "python"
[[operation]]
id = "handler"
kind = "privileged_action"
handlers = ["^open$"]
framework = "tornado"
requires = ["path_guard"]
sensitivity = "high"
"""
path = tmp_path / "python.toml"
path.write_text(text)
(row,) = load_obligation_facts(tmp_path).operations
assert row.handlers == ("^open$",) and row.framework == "tornado" and row.calls == ()
for invalid in [
text.replace('framework = "tornado"', ""),
text.replace('"^open$"', '"["'),
text.replace('framework = "tornado"', "framework = 12"),
]:
path.write_text(invalid)
with pytest.raises(ObligationFactsError):
load_obligation_facts(tmp_path)
+30
View File
@@ -111,3 +111,33 @@ def test_discharge_witness_covers_operation_only_when_kind_and_provenance_fit()
assert covering["constrained"] == ["identity_constraint"] and covering["constrained_too"] == ["identity_constraint"] assert covering["constrained"] == ["identity_constraint"] and covering["constrained_too"] == ["identity_constraint"]
assert covering["leaky"] == [] # request-bound identity does not cover assert covering["leaky"] == [] # request-bound identity does not cover
assert covering["guarded"] == [] # a path guard does not cover a protected read (only privileged actions) assert covering["guarded"] == [] # a path guard does not cover a protected read (only privileged actions)
def test_handler_named_operations_are_framework_scoped():
from openultrasast.semantic.obligations import load_obligation_facts
from openultrasast.semantic.obligations.operations import find_operations
text = "class Handler:\n def open(self):\n pass\n def helper(self):\n pass\n"
ir = parse_file("app.py", text, "python")
facts = load_obligation_facts().for_language("python")
assert find_operations(ir, facts, text=text) == ()
assert find_operations(ir, facts, text=text, handlers_of={"open": "other"}) == ()
(op,) = find_operations(ir, facts, text=text, handlers_of={"open": "tornado", "helper": "tornado"})
assert (op.function, op.line, op.requires) == ("open", 2, ("path_guard",))
def test_handler_rows_in_java_and_typescript():
from openultrasast.semantic.obligations import load_obligation_facts
from openultrasast.semantic.obligations.operations import find_operations
facts = load_obligation_facts()
cases = [
("java", "stapler", "class Editor { public void doFillNamesItems() {} }", "doFillNamesItems", ("path_guard",)),
("typescript", "remix", "export function action() { return 1; }", "action", ("path_guard", "ownership_check")),
]
for language, framework, text, name, requires in cases:
ir = parse_file(f"app.{language}", text, language)
assert ir.parse_ok
assert find_operations(ir, facts.for_language(language), text=text) == ()
(operation,) = find_operations(ir, facts.for_language(language), text=text, handlers_of={name: framework})
assert operation.line == 1 and operation.requires == requires and operation.function == name
+18
View File
@@ -63,3 +63,21 @@ def test_records_are_consumed_by_the_checker():
) )
(finding,) = result.findings (finding,) = result.findings
assert finding.operation.function == "entry" and finding.label == "unguarded_path" assert finding.operation.function == "entry" and finding.label == "unguarded_path"
def test_callers_are_looked_up_once_per_node_and_never_without_entries():
from openultrasast.semantic.obligations.paths import compose_paths
calls: list[str] = []
graph = {"a.py::leaf": [("a.py::mid", 3)], "a.py::mid": [("web.py::view", 9), ("a.py::other", 4)], "a.py::other": [("a.py::mid", 5)]}
def callers(path, function):
calls.append(f"{path}::{function}")
return graph.get(f"{path}::{function}", [])
paths, unreached = compose_paths(callers=callers, entries={"web.py::view"}, targets=[("a.py", "leaf"), ("a.py", "mid")], max_depth=3)
assert [p.hops[0][0] for p in paths] == ["web.py::view"] * len(paths) and len(paths) >= 2 and unreached == []
assert len(calls) == len(set(calls)) # once per node across both targets
calls.clear()
paths, unreached = compose_paths(callers=callers, entries=set(), targets=[("a.py", "leaf")])
assert paths == [] and unreached == ["a.py::leaf"] and calls == []