mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 11:49:28 +00:00
feat(authorization-obligations): operations and discharge witnesses in a parsed file
Task 2.1 (Req 3.1, 5.4). find_operations, find_discharges (decorators, guard and check calls, identity constraints with their provenance, non-permissive settings) and covers; chained call sites deduped; the resource token is the receiver head, the fact's resource argument, or a SQL table name. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
8324b243a0
commit
a8b9dae6b3
@@ -0,0 +1,248 @@
|
||||
"""Obligated operations and discharge witnesses in one parsed file (authorization-obligations, Req 3.1, 5.4).
|
||||
|
||||
An ``Operation`` is a call site matching an operation fact inside a named function. A ``Discharge`` is a witness that a
|
||||
discharger fact is present: a decorator on the handler, a guard or check call before the operation, an identity
|
||||
constraint keyword bound from the authenticated context, or a non-permissive setting. ``covers`` says whether one witness
|
||||
discharges one operation: same function, a kind the operation requires, and, for identity constraints, a value bound from
|
||||
the authenticated context (a constraint bound from the request or a constant discharges nothing).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ..facts import SemanticFacts
|
||||
from ..ir import FileIR, FunctionIR
|
||||
from ..variants import trailing_name
|
||||
from .facts import DischargerFact, ObligationFacts
|
||||
from .shapes import _call_matches, _constraint_provenance, _is_literal, _operations, _permissive
|
||||
|
||||
SCOPES = ("decorator", "router", "statement", "hop")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Operation:
|
||||
path: str
|
||||
line: int
|
||||
function: str
|
||||
kind: str
|
||||
fact_id: str
|
||||
resource: str | None # identifier token naming the resource (model or table), never text
|
||||
requires: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Discharge:
|
||||
path: str
|
||||
line: int | None
|
||||
function: str
|
||||
kind: str
|
||||
fact_id: str
|
||||
provenance: str
|
||||
scope: str # SCOPES
|
||||
detail: str = "" # identifier only: the decorator or call name that witnessed the discharge
|
||||
|
||||
|
||||
def find_operations(ir: FileIR, facts: ObligationFacts, *, text: str) -> tuple[Operation, ...]:
|
||||
"""Every obligated operation in the file's named functions, in source order."""
|
||||
if not ir.parse_ok:
|
||||
return ()
|
||||
lines = text.splitlines()
|
||||
found: list[Operation] = []
|
||||
seen: set[tuple[str, int, str]] = set()
|
||||
for function in ir.functions:
|
||||
if function.name == "<module>":
|
||||
continue
|
||||
for site in _operations(function, facts, lines, kind=None):
|
||||
key = (function.name, site.call.line, site.fact.id)
|
||||
if key in seen:
|
||||
continue # the walker records a chained `x.filter_by(...).first()` as two call sites on one line
|
||||
seen.add(key)
|
||||
found.append(
|
||||
Operation(
|
||||
path=ir.path,
|
||||
line=site.call.line,
|
||||
function=function.name,
|
||||
kind=site.fact.kind,
|
||||
fact_id=site.fact.id,
|
||||
resource=_resource_token(site.call.name, site.line_text, site.fact.resource_arg, site.call.arg_texts),
|
||||
requires=site.fact.requires,
|
||||
)
|
||||
)
|
||||
return tuple(found)
|
||||
|
||||
|
||||
def find_discharges(
|
||||
ir: FileIR,
|
||||
facts: ObligationFacts,
|
||||
flow_facts: SemanticFacts,
|
||||
*,
|
||||
text: str,
|
||||
entries: Sequence[object] = (),
|
||||
) -> tuple[Discharge, ...]:
|
||||
"""Every discharge witness in the file: decorators above each handler, guard and check calls, identity constraints."""
|
||||
if not ir.parse_ok:
|
||||
return ()
|
||||
lines = text.splitlines()
|
||||
evidence_by_function: dict[str, list[str]] = {}
|
||||
for entry in entries:
|
||||
name = getattr(entry, "function_name", None)
|
||||
if name:
|
||||
evidence_by_function.setdefault(str(name), []).extend(str(item) for item in getattr(entry, "access_evidence", []) or [])
|
||||
witnesses: list[Discharge] = []
|
||||
for function in ir.functions:
|
||||
if function.name == "<module>":
|
||||
continue
|
||||
decorators = _decorators_above(function, lines) + evidence_by_function.get(function.name, [])
|
||||
for fact in facts.dischargers:
|
||||
witnesses.extend(_decorator_witnesses(ir.path, function, fact, decorators))
|
||||
witnesses.extend(_call_witnesses(ir.path, function, fact))
|
||||
witnesses.extend(_constraint_witnesses(ir.path, function, fact, facts, flow_facts, lines))
|
||||
return tuple(_dedupe(witnesses))
|
||||
|
||||
|
||||
def covers(witness: Discharge, operation: Operation) -> bool:
|
||||
"""A witness discharges an operation when it sits in the same function, has a kind the operation requires, and, for an
|
||||
identity constraint, binds its value from the authenticated context. Ordering along the path is the Dominance protocol's
|
||||
job (task 2.4); this is the kind-and-provenance fit only."""
|
||||
if witness.path != operation.path or witness.function != operation.function:
|
||||
return False
|
||||
if witness.kind not in operation.requires:
|
||||
return False
|
||||
if witness.kind == "identity_constraint" and witness.provenance != "authenticated_context":
|
||||
return False
|
||||
return not (witness.kind == "non_permissive_value" and witness.line != operation.line)
|
||||
|
||||
|
||||
def _decorators_above(function: FunctionIR, lines: Sequence[str]) -> list[str]:
|
||||
start = function.start_line
|
||||
found: list[str] = []
|
||||
index = start - 2
|
||||
while index >= 0 and lines[index].strip().startswith("@"):
|
||||
found.append(lines[index].strip())
|
||||
index -= 1
|
||||
return found
|
||||
|
||||
|
||||
def _decorator_witnesses(path: str, function: FunctionIR, fact: DischargerFact, decorators: Sequence[str]) -> list[Discharge]:
|
||||
out: list[Discharge] = []
|
||||
for decorator in decorators:
|
||||
name = _decorator_name(decorator)
|
||||
if any(candidate == name or candidate in decorator for candidate in fact.decorators):
|
||||
out.append(
|
||||
Discharge(
|
||||
path=path,
|
||||
line=None,
|
||||
function=function.name,
|
||||
kind=fact.kind,
|
||||
fact_id=fact.id,
|
||||
provenance="authenticated_context" if fact.kind == "path_guard" else "unknown",
|
||||
scope="decorator",
|
||||
detail=name,
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _call_witnesses(path: str, function: FunctionIR, fact: DischargerFact) -> list[Discharge]:
|
||||
if fact.kind == "identity_constraint":
|
||||
return []
|
||||
out: list[Discharge] = []
|
||||
for call in function.calls:
|
||||
if any(_call_matches(call.name, name) for name in fact.calls):
|
||||
if fact.kind == "non_permissive_value":
|
||||
continue # settings are judged per operation line in _constraint_witnesses
|
||||
out.append(
|
||||
Discharge(
|
||||
path=path,
|
||||
line=call.line,
|
||||
function=function.name,
|
||||
kind=fact.kind,
|
||||
fact_id=fact.id,
|
||||
provenance="authenticated_context" if fact.kind == "path_guard" else "unknown",
|
||||
scope="statement",
|
||||
detail=trailing_name(call.name),
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _constraint_witnesses(
|
||||
path: str,
|
||||
function: FunctionIR,
|
||||
fact: DischargerFact,
|
||||
facts: ObligationFacts,
|
||||
flow_facts: SemanticFacts,
|
||||
lines: Sequence[str],
|
||||
) -> list[Discharge]:
|
||||
out: list[Discharge] = []
|
||||
if fact.kind == "identity_constraint":
|
||||
for site in _operations(function, facts, lines, kind=None):
|
||||
provenance = _constraint_provenance(site, fact, flow_facts)
|
||||
if provenance is not None:
|
||||
out.append(
|
||||
Discharge(
|
||||
path=path,
|
||||
line=site.call.line,
|
||||
function=function.name,
|
||||
kind="identity_constraint",
|
||||
fact_id=fact.id,
|
||||
provenance=provenance,
|
||||
scope="statement",
|
||||
detail=trailing_name(site.call.name),
|
||||
)
|
||||
)
|
||||
elif fact.kind == "non_permissive_value":
|
||||
for site in _operations(function, facts, lines, kind="security_setting"):
|
||||
if any(_call_matches(site.call.name, name) for name in fact.calls) and not _permissive(site, fact):
|
||||
out.append(
|
||||
Discharge(
|
||||
path=path,
|
||||
line=site.call.line,
|
||||
function=function.name,
|
||||
kind="non_permissive_value",
|
||||
fact_id=fact.id,
|
||||
provenance="constant",
|
||||
scope="statement",
|
||||
detail=trailing_name(site.call.name),
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _resource_token(callee: str, line_text: str, resource_arg: int | None, arg_texts: Sequence[str]) -> str | None:
|
||||
"""The identifier that names the resource: the receiver's first segment (`Book.query...` -> `book`), a table name in a
|
||||
SQL literal, or the argument the fact points at. Never more than one identifier."""
|
||||
if resource_arg is not None and resource_arg < len(arg_texts):
|
||||
match = re.match(r"[A-Za-z_][\w]*", arg_texts[resource_arg].strip())
|
||||
return match.group(0).lower() if match else None
|
||||
head = callee.split(".")[0].split("(")[0]
|
||||
if (
|
||||
head
|
||||
and head not in {"self", "cls", "db", "session", "cursor", "conn", "connection", "c", "cur", "request", "req", "this"}
|
||||
and not _is_literal(head)
|
||||
):
|
||||
return head.lower()
|
||||
table = re.search(r"\b(?:from|into|update|join)\s+([A-Za-z_][\w]*)", line_text, re.I)
|
||||
return table.group(1).lower() if table else None
|
||||
|
||||
|
||||
def _decorator_name(decorator: str) -> str:
|
||||
body = decorator.lstrip("@").strip()
|
||||
return trailing_name(body.split("(", 1)[0])
|
||||
|
||||
|
||||
def _dedupe(witnesses: Sequence[Discharge]) -> list[Discharge]:
|
||||
seen: set[tuple[object, ...]] = set()
|
||||
out: list[Discharge] = []
|
||||
for witness in witnesses:
|
||||
key = (witness.path, witness.line, witness.function, witness.kind, witness.provenance, witness.scope, witness.detail)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
out.append(witness)
|
||||
return out
|
||||
|
||||
|
||||
__all__ = ["SCOPES", "Discharge", "Operation", "covers", "find_discharges", "find_operations"]
|
||||
@@ -0,0 +1,92 @@
|
||||
"""authorization-obligations task 2.1: operations and discharge witnesses in a parsed file (offline, stdlib ast)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from openultrasast.mapping import EntryPointRecord
|
||||
from openultrasast.semantic.facts import load_facts
|
||||
from openultrasast.semantic.ir import parse_file
|
||||
|
||||
APP = (
|
||||
"from flask import request\n\n\n"
|
||||
"@app.route('/books/<title>')\n@login_required\ndef guarded(title):\n"
|
||||
" return Book.query.filter_by(book_title=title).first()\n\n\n"
|
||||
"@app.route('/books/mine/<title>')\ndef constrained(title):\n"
|
||||
" user_id = request.user.id\n"
|
||||
" return Book.query.filter_by(user_id=user_id, book_title=title).first()\n\n\n"
|
||||
"@app.route('/books/any/<title>')\ndef leaky(title):\n"
|
||||
" owner = request.args.get('owner')\n"
|
||||
" return Book.query.filter_by(user_id=owner, book_title=title).first()\n\n\n"
|
||||
"@app.route('/health')\ndef health():\n return 'ok'\n"
|
||||
)
|
||||
|
||||
|
||||
def _entry(function: str, line: int, end: int, access: str, evidence: list[str]) -> EntryPointRecord:
|
||||
return EntryPointRecord(
|
||||
path="app.py",
|
||||
line=line,
|
||||
end_line=end,
|
||||
function_name=function,
|
||||
name=function,
|
||||
kind="route",
|
||||
access_level=access, # type: ignore[arg-type]
|
||||
trust_boundary="http_request",
|
||||
access_evidence=evidence,
|
||||
conditions=[],
|
||||
provenance="test",
|
||||
rationale="",
|
||||
)
|
||||
|
||||
|
||||
ENTRIES = [
|
||||
_entry("guarded", 6, 7, "authenticated", ["@login_required"]),
|
||||
_entry("constrained", 11, 13, "public", []),
|
||||
_entry("leaky", 17, 19, "public", []),
|
||||
_entry("health", 23, 24, "public", []),
|
||||
]
|
||||
|
||||
|
||||
def test_find_operations_names_kind_fact_function_and_resource() -> None:
|
||||
from openultrasast.semantic.obligations import load_obligation_facts
|
||||
from openultrasast.semantic.obligations.operations import find_operations
|
||||
|
||||
ir = parse_file("app.py", APP, "python")
|
||||
ops = find_operations(ir, load_obligation_facts().for_language("python"), text=APP)
|
||||
assert [(op.function, op.kind, op.resource) for op in ops] == [
|
||||
("guarded", "protected_read", "book"),
|
||||
("constrained", "protected_read", "book"),
|
||||
("leaky", "protected_read", "book"),
|
||||
]
|
||||
assert all(op.path == "app.py" and op.fact_id == "orm-read" and op.line > 0 for op in ops)
|
||||
assert not [op for op in ops if op.function == "health"] # a constant return is no operation
|
||||
|
||||
|
||||
def test_find_discharges_reports_witness_kind_provenance_and_scope() -> None:
|
||||
from openultrasast.semantic.obligations import load_obligation_facts
|
||||
from openultrasast.semantic.obligations.operations import find_discharges
|
||||
|
||||
ir = parse_file("app.py", APP, "python")
|
||||
witnesses = find_discharges(
|
||||
ir, load_obligation_facts().for_language("python"), load_facts().for_language("python"), text=APP, entries=ENTRIES
|
||||
)
|
||||
by_function = {}
|
||||
for w in witnesses:
|
||||
by_function.setdefault(w.function, []).append((w.kind, w.provenance, w.scope))
|
||||
assert ("path_guard", "authenticated_context", "decorator") in by_function["guarded"]
|
||||
assert ("identity_constraint", "authenticated_context", "statement") in by_function["constrained"]
|
||||
assert ("identity_constraint", "request_input", "statement") in by_function["leaky"] # present, but discharges nothing
|
||||
assert "health" not in by_function
|
||||
assert all(w.path == "app.py" and w.fact_id for w in witnesses)
|
||||
|
||||
|
||||
def test_discharge_witness_covers_operation_only_when_kind_and_provenance_fit() -> None:
|
||||
from openultrasast.semantic.obligations import load_obligation_facts
|
||||
from openultrasast.semantic.obligations.operations import covers, find_discharges, find_operations
|
||||
|
||||
ir = parse_file("app.py", APP, "python")
|
||||
facts = load_obligation_facts().for_language("python")
|
||||
ops = {op.function: op for op in find_operations(ir, facts, text=APP)}
|
||||
witnesses = find_discharges(ir, facts, load_facts().for_language("python"), text=APP, entries=ENTRIES)
|
||||
covering = {op.function: [w.kind for w in witnesses if covers(w, op)] for op in ops.values()}
|
||||
assert covering["constrained"] == ["identity_constraint"]
|
||||
assert covering["leaky"] == [] # request-bound identity does not cover
|
||||
assert covering["guarded"] == [] # a path guard does not cover a protected read (only privileged actions)
|
||||
Reference in New Issue
Block a user