mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-11-14 10:18:17 +00:00
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:
co-authored by
Claude Fable 5.1
parent
7f1b820d5a
commit
17e07b36c4
@@ -66,6 +66,7 @@ def check_obligations(
|
||||
min_siblings: int,
|
||||
extra_witnesses: Sequence[Discharge] = (),
|
||||
assume_entries_protected: bool = False,
|
||||
handlers_of: Mapping[str, Mapping[str, str]] | None = None,
|
||||
) -> ObligationResult:
|
||||
operations: list[Operation] = []
|
||||
witnesses: list[Discharge] = list(extra_witnesses)
|
||||
@@ -81,7 +82,7 @@ def check_obligations(
|
||||
continue
|
||||
scoped = 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))
|
||||
sets = sibling_sets(entries, operations, witnesses)
|
||||
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
|
||||
# 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")]
|
||||
if required and not (set(required) & dominated_kinds):
|
||||
missing = next(
|
||||
(kind for kind in required if (handler, kind) in anomaly_index),
|
||||
required[0],
|
||||
)
|
||||
handler_fact = next((f for f in facts.operations if f.id == operation.fact_id and f.handlers), None)
|
||||
missing_kinds = (
|
||||
[kind for kind in required if kind not in dominated_kinds]
|
||||
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(
|
||||
operation,
|
||||
missing,
|
||||
@@ -139,6 +144,7 @@ def check_obligations(
|
||||
paths,
|
||||
facts,
|
||||
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:
|
||||
findings.append(finding)
|
||||
@@ -225,6 +231,8 @@ def _finding(
|
||||
paths: Sequence[object],
|
||||
facts: ObligationFacts,
|
||||
assumed_entry: object | None = None,
|
||||
*,
|
||||
handler_obligation: bool = False,
|
||||
) -> ObligationFinding | 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
|
||||
@@ -247,14 +255,25 @@ def _finding(
|
||||
label = "consistency_violation"
|
||||
evidence = anomaly.siblings + anomaly.evidence[len(anomaly.siblings) :]
|
||||
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"
|
||||
evidence = (
|
||||
f"entry {getattr(assumed_entry, 'kind', '')}: {getattr(assumed_entry, 'path', '')}::"
|
||||
f"{getattr(assumed_entry, 'function_name', '')}",
|
||||
*(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:
|
||||
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
|
||||
|
||||
import re
|
||||
import tomllib
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
@@ -20,7 +21,9 @@ PROVENANCE_KINDS = ("authenticated_context", "request_input", "constant", "unkno
|
||||
SENSITIVITIES = ("low", "medium", "high")
|
||||
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(
|
||||
{"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
|
||||
# can see argument shapes decides it, and a language whose calls are unambiguous never sets this.
|
||||
query_document_arg: int | None = None
|
||||
handlers: tuple[str, ...] = ()
|
||||
framework: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -109,6 +114,17 @@ def _load_obligation_facts_uncached(directory: Path | None = None) -> Obligation
|
||||
document_arg = row.get("query_document_arg")
|
||||
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")
|
||||
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(
|
||||
OperationFact(
|
||||
id=str(row["id"]),
|
||||
@@ -119,6 +135,8 @@ def _load_obligation_facts_uncached(directory: Path | None = None) -> Obligation
|
||||
requires=requires,
|
||||
sensitivity=sensitivity,
|
||||
query_document_arg=document_arg,
|
||||
handlers=handlers,
|
||||
framework=framework,
|
||||
)
|
||||
)
|
||||
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
|
||||
|
||||
import re
|
||||
from collections.abc import Sequence
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ..facts import SemanticFacts
|
||||
@@ -44,7 +44,9 @@ class 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."""
|
||||
if not ir.parse_ok:
|
||||
return ()
|
||||
@@ -54,6 +56,14 @@ def find_operations(ir: FileIR, facts: ObligationFacts, *, text: str) -> tuple[O
|
||||
for function in ir.functions:
|
||||
if function.name == "<module>":
|
||||
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):
|
||||
key = (function.name, site.call.line, site.fact.id)
|
||||
if key in seen:
|
||||
|
||||
@@ -30,6 +30,19 @@ def compose_paths(
|
||||
raise ValueError("invalid path bounds")
|
||||
found: dict[tuple[tuple[str, int | None], ...], PathRecord] = {}
|
||||
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):
|
||||
target = f"{path}::{function}"
|
||||
reached = False
|
||||
@@ -43,9 +56,8 @@ def compose_paths(
|
||||
continue
|
||||
if len(hops) - 1 >= max_depth:
|
||||
continue
|
||||
node_path, _, node_function = node.rpartition("::")
|
||||
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:
|
||||
pending.append(((caller, line), *hops))
|
||||
if not reached:
|
||||
|
||||
Reference in New Issue
Block a user