mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 03:49:26 +00:00
Codex delivery under spec access-control-prover, requirement 9. `semantic/obligations/trace.py` serialises each (entry, operation) pair the prover examined, discharged or unguarded: the entry with its boundary, access and parameters; each hop in order with its call line, the guards before the call and the values forwarded; the operation with the provenance of each argument traced through the function's binds; the guards after it; the verdict. One canonical text form and one JSON form, no language, framework, repository, size or count fields. The prover result, the arm record and each unguarded finding carry the traces. On the ec2-plugin fix: 32 traces, 4 unguarded. Gate: 3534 tests pass; the reserved-repository guard passes on the tracked export. Co-Authored-By: Claude Fable 5.1 <[email protected]>
230 lines
8.7 KiB
Python
230 lines
8.7 KiB
Python
"""Ordered security structure and bounded argument provenance for examined paths."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import json
|
|
import re
|
|
from collections.abc import Mapping, Sequence
|
|
from dataclasses import asdict, dataclass, replace
|
|
from typing import Any
|
|
|
|
from ..ir import FileIR, FunctionIR, trailing_name
|
|
from .dominance import Dominance
|
|
from .facts import ObligationFacts
|
|
from .operations import Discharge, Operation, valid_witness
|
|
from .paths import PathRecord
|
|
from .shapes import _call_matches
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Guard:
|
|
kind: str
|
|
scope: str
|
|
line: int | None
|
|
provenance: str
|
|
fact_id: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Hop:
|
|
function: str
|
|
call_line: int | None
|
|
guards_before_call: tuple[Guard, ...]
|
|
forwarded: tuple[str, ...]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Trace:
|
|
entry_kind: str
|
|
boundary: str
|
|
access: str
|
|
parameters: tuple[str, ...]
|
|
hops: tuple[Hop, ...]
|
|
operation_kind: str
|
|
fact_id: str
|
|
operation_line: int
|
|
arguments: tuple[tuple[str, str], ...]
|
|
guards_after: tuple[Guard, ...]
|
|
requires: tuple[str, ...]
|
|
dominating: tuple[str, ...]
|
|
missing: str | None
|
|
|
|
def record(self) -> dict[str, Any]:
|
|
"""Return JSON values with keys sorted recursively, retaining sequence order."""
|
|
result: dict[str, Any] = json.loads(json.dumps(asdict(self), sort_keys=True))
|
|
return result
|
|
|
|
def text(self) -> str:
|
|
"""Emit entry, hops, operation, later guards and verdict in that order."""
|
|
record = self.record()
|
|
components = [("entry", {key: record[key] for key in ("entry_kind", "boundary", "access", "parameters")})]
|
|
components.extend(("hop", hop) for hop in record["hops"])
|
|
components.append(("operation", {key: record[key] for key in ("operation_kind", "fact_id", "operation_line", "arguments")}))
|
|
components.extend(("guard_after", guard) for guard in record["guards_after"])
|
|
components.append(("verdict", {key: record[key] for key in ("requires", "dominating", "missing")}))
|
|
return "\n".join(f"{label} {json.dumps(value, sort_keys=True, separators=(',', ':'))}" for label, value in components)
|
|
|
|
|
|
def _arguments(function: FunctionIR, line: int, names: Sequence[str], text: str) -> tuple[str, ...]:
|
|
calls = [call for call in function.calls if call.line == line and any(_call_matches(call.name, name) for name in names)]
|
|
if calls and calls[0].arg_texts:
|
|
return calls[0].arg_texts
|
|
# Some grammars omit arguments. Read only the selected call, balancing nested delimiters and strings.
|
|
lines = text.splitlines()
|
|
if not 0 < line <= len(lines):
|
|
return ()
|
|
source = "\n".join(lines[line - 1 : function.end_line])
|
|
for name in names:
|
|
match = re.search(rf"(?<![\w$]){re.escape(trailing_name(name))}\s*\(", source)
|
|
if match is None:
|
|
continue
|
|
arguments: list[str] = []
|
|
start = match.end()
|
|
depth = 0
|
|
quote = ""
|
|
escaped = False
|
|
for index in range(start, len(source)):
|
|
char = source[index]
|
|
if quote:
|
|
if escaped:
|
|
escaped = False
|
|
elif char == "\\":
|
|
escaped = True
|
|
elif char == quote:
|
|
quote = ""
|
|
elif char in "\"'`":
|
|
quote = char
|
|
elif char in "([{":
|
|
depth += 1
|
|
elif char == ")" and depth == 0:
|
|
value = source[start:index].strip()
|
|
if value:
|
|
arguments.append(value)
|
|
return tuple(arguments)
|
|
elif char in ")]}":
|
|
depth -= 1
|
|
elif char == "," and depth == 0:
|
|
arguments.append(source[start:index].strip())
|
|
start = index + 1
|
|
return ()
|
|
|
|
|
|
def _origin(value: str, function: FunctionIR, before: int, parameters: Mapping[str, str], facts: ObligationFacts) -> str:
|
|
"""Follow the most recent preceding assignment, never guessing an unresolved origin."""
|
|
value = value.strip()
|
|
while True:
|
|
bind = next(
|
|
(
|
|
item
|
|
for item in sorted(function.binds, key=lambda item: (item.line, item.order), reverse=True)
|
|
if item.line < before and item.name == value
|
|
),
|
|
None,
|
|
)
|
|
if bind is None:
|
|
break
|
|
value, before = bind.value_text.strip(), bind.line
|
|
if value in {"true", "false", "null"}:
|
|
return "constant"
|
|
try:
|
|
ast.literal_eval(value)
|
|
except (ValueError, SyntaxError):
|
|
pass
|
|
else:
|
|
return "constant"
|
|
if any(_source_matches(value, source) for fact in facts.dischargers for source in fact.identity_sources):
|
|
return "authenticated_context"
|
|
if any(_source_matches(value, source) for fact in facts.dischargers for source in fact.request_sources):
|
|
return "request_input"
|
|
if value in parameters:
|
|
return parameters[value]
|
|
member = re.fullmatch(r"(\$?[A-Za-z_]\w*)(?:(?:\.|->)[A-Za-z_]\w*|\[[^\[\]]+\])+", value)
|
|
if member is not None:
|
|
return _origin(member.group(1), function, before, parameters, facts)
|
|
return "unknown"
|
|
|
|
|
|
def _source_matches(value: str, source: str) -> bool:
|
|
suffix = r"(?![\w$])" if source[-1:].isalnum() or source.endswith("_") else ""
|
|
return bool(source and re.search(r"(?<![\w$])" + re.escape(source) + suffix, value))
|
|
|
|
|
|
def build_trace(
|
|
*,
|
|
path: PathRecord,
|
|
entry: object,
|
|
operation: Operation,
|
|
irs: Mapping[str, tuple[FileIR, str]],
|
|
facts: ObligationFacts,
|
|
witnesses: Sequence[Discharge],
|
|
dominance: Dominance,
|
|
dominating: tuple[str, ...],
|
|
missing: str | None,
|
|
access: str | None = None,
|
|
) -> Trace:
|
|
"""Build one trace without changing the checker's verdict or coverage."""
|
|
hops: list[Hop] = []
|
|
after_by_hop: list[list[Guard]] = []
|
|
incoming: tuple[str, ...] = ()
|
|
parameters: tuple[str, ...] = ()
|
|
arguments: tuple[tuple[str, str], ...] = ()
|
|
boundary = str(getattr(entry, "trust_boundary", "unknown"))
|
|
for index, (node, call_line) in enumerate(path.hops):
|
|
file, _, name = node.rpartition("::")
|
|
ir, text = irs[file]
|
|
function = next(fn for fn in ir.functions if fn.name == name)
|
|
scoped = facts.for_language(ir.language)
|
|
if index == 0:
|
|
parameters = function.params
|
|
origins = {param: "request_input" if boundary in {"http_request", "network_request"} else "unknown" for param in parameters}
|
|
else:
|
|
origins = dict(zip(tuple(param for param in function.params if param not in {"self", "cls", "this"}), incoming, strict=False))
|
|
final = index == len(path.hops) - 1
|
|
line = operation.line if final else call_line
|
|
measured = operation if final else replace(operation, path=file, function=name, line=line or function.end_line)
|
|
before: list[Guard] = []
|
|
after: list[Guard] = []
|
|
after_by_hop.append(after)
|
|
local = sorted(
|
|
(w for w in witnesses if (w.path, w.function) == (file, name)),
|
|
key=lambda w: (w.line or 0, w.kind, w.scope, w.provenance, w.fact_id),
|
|
)
|
|
for witness in local:
|
|
guard = Guard(witness.kind, witness.scope, witness.line, witness.provenance, witness.fact_id)
|
|
if valid_witness(witness) and dominance.dominates(witness, measured, None):
|
|
before.append(guard)
|
|
elif witness.line is not None and line is not None and witness.line > line:
|
|
after.append(guard)
|
|
if final:
|
|
names = next(fact.calls for fact in scoped.operations if fact.id == operation.fact_id)
|
|
else:
|
|
names = (path.hops[index + 1][0].rpartition("::")[2],)
|
|
values = _arguments(function, line, names, text) if line is not None and names else ()
|
|
incoming = tuple(_origin(value, function, line or function.end_line + 1, origins, scoped) for value in values)
|
|
if final:
|
|
arguments = tuple(zip(values, incoming, strict=True))
|
|
hops.append(
|
|
Hop(
|
|
name,
|
|
call_line,
|
|
tuple(before),
|
|
tuple(value for value, origin in zip(values, incoming, strict=True) if not final and origin == "request_input"),
|
|
)
|
|
)
|
|
return Trace(
|
|
str(getattr(entry, "kind", "unknown")),
|
|
boundary,
|
|
access if access is not None else str(getattr(entry, "access_level", "unknown")),
|
|
parameters,
|
|
tuple(hops),
|
|
operation.kind,
|
|
operation.fact_id,
|
|
operation.line,
|
|
arguments,
|
|
tuple(guard for later in reversed(after_by_hop) for guard in later),
|
|
operation.requires,
|
|
tuple(sorted(dominating)),
|
|
missing,
|
|
)
|