mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 11:49:28 +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]>
199 lines
8.0 KiB
Python
199 lines
8.0 KiB
Python
"""Ordered traces preserve argument origins across a bounded caller chain."""
|
|
|
|
import json
|
|
from types import SimpleNamespace
|
|
|
|
from openultrasast.semantic.ir import parse_python_ast
|
|
from openultrasast.semantic.obligations.dominance import OrderDominance
|
|
from openultrasast.semantic.obligations.facts import DischargerFact, ObligationFacts, OperationFact
|
|
from openultrasast.semantic.obligations.operations import Discharge, Operation
|
|
from openultrasast.semantic.obligations.paths import PathRecord
|
|
from openultrasast.semantic.obligations.trace import build_trace
|
|
|
|
|
|
def fixture():
|
|
text = """def entry(item):
|
|
guard()
|
|
middle(item)
|
|
def middle(value):
|
|
alias = value
|
|
final(alias)
|
|
def final(key):
|
|
owner = request.user
|
|
limit = 7
|
|
mystery = unrecognised()
|
|
read(key, owner, limit, mystery)
|
|
guard()
|
|
"""
|
|
ir = parse_python_ast("app.py", text)
|
|
facts = ObligationFacts(
|
|
"1",
|
|
(OperationFact("read", "protected_read", "python", ("read",), None, ("path_guard",), "high"),),
|
|
(
|
|
DischargerFact(
|
|
"identity", "identity_constraint", "python", identity_sources=("request.user",), request_sources=("request.args",)
|
|
),
|
|
),
|
|
)
|
|
return text, ir, facts
|
|
|
|
|
|
def trace():
|
|
text, ir, facts = fixture()
|
|
return build_trace(
|
|
path=PathRecord((("app.py::entry", 3), ("app.py::middle", 6), ("app.py::final", None)), "app.py::entry", True),
|
|
entry=SimpleNamespace(kind="route", trust_boundary="http_request", access_level="unknown"),
|
|
operation=Operation("app.py", 11, "final", "protected_read", "read", None, ("path_guard",)),
|
|
irs={"app.py": (ir, text)},
|
|
facts=facts,
|
|
witnesses=(
|
|
Discharge("app.py", 2, "entry", "path_guard", "guard", "authenticated_context", "statement"),
|
|
Discharge("app.py", 12, "final", "path_guard", "guard", "authenticated_context", "statement"),
|
|
),
|
|
dominance=OrderDominance({"app.py": text}),
|
|
dominating=("path_guard",),
|
|
missing=None,
|
|
)
|
|
|
|
|
|
def test_four_origins_and_three_hop_order():
|
|
result = trace()
|
|
assert result.arguments == (("key", "request_input"), ("owner", "authenticated_context"), ("limit", "constant"), ("mystery", "unknown"))
|
|
assert [hop.function for hop in result.hops] == ["entry", "middle", "final"]
|
|
assert result.hops[0].forwarded == ("item",)
|
|
assert result.hops[1].forwarded == ("alias",)
|
|
assert result.hops[0].guards_before_call[0].line == 2
|
|
assert result.guards_after[0].line == 12
|
|
|
|
|
|
def test_deterministic_text_and_canonical_json():
|
|
result = trace()
|
|
assert result.text() == trace().text()
|
|
assert [line.split(" ", 1)[0] for line in result.text().splitlines()] == [
|
|
"entry",
|
|
"hop",
|
|
"hop",
|
|
"hop",
|
|
"operation",
|
|
"guard_after",
|
|
"verdict",
|
|
]
|
|
record = result.record()
|
|
assert json.loads(json.dumps(record)) == record
|
|
assert list(record) == sorted(record)
|
|
assert record == trace().record()
|
|
assert not {"language", "framework", "repository", "size", "count"} & record.keys()
|
|
|
|
|
|
def test_recent_bind_sources_and_unknown_composites():
|
|
text, _, facts = fixture()
|
|
text = text.replace(
|
|
" read(key, owner, limit, mystery)",
|
|
""" key = request.args['id']
|
|
owner = external
|
|
limit = [mystery]
|
|
read(key, owner, limit, mystery)""",
|
|
)
|
|
ir = parse_python_ast("app.py", text)
|
|
result = build_trace(
|
|
path=PathRecord((("app.py::final", None),), "app.py::final", True),
|
|
entry=SimpleNamespace(kind="route", trust_boundary="internal", access_level="unknown"),
|
|
operation=Operation("app.py", 14, "final", "protected_read", "read", None, ("path_guard",)),
|
|
irs={"app.py": (ir, text)},
|
|
facts=facts,
|
|
witnesses=(),
|
|
dominance=OrderDominance({"app.py": text}),
|
|
dominating=(),
|
|
missing="path_guard",
|
|
)
|
|
assert result.arguments == (("key", "request_input"), ("owner", "unknown"), ("limit", "unknown"), ("mystery", "unknown"))
|
|
|
|
|
|
def test_call_text_fallback_balances_nested_arguments():
|
|
from dataclasses import replace
|
|
|
|
text, ir, facts = fixture()
|
|
text = text.replace("read(key, owner, limit, mystery)", "read(key, nested(owner, limit), 'a,b', mystery)")
|
|
parsed = parse_python_ast("app.py", text)
|
|
functions = tuple(replace(fn, calls=tuple(replace(call, arg_texts=()) for call in fn.calls)) for fn in parsed.functions)
|
|
ir = replace(ir, functions=functions)
|
|
result = build_trace(
|
|
path=PathRecord((("app.py::final", None),), "app.py::final", True),
|
|
entry=SimpleNamespace(kind="route", trust_boundary="network_request", access_level="unknown"),
|
|
operation=Operation("app.py", 11, "final", "protected_read", "read", None, ("path_guard",)),
|
|
irs={"app.py": (ir, text)},
|
|
facts=facts,
|
|
witnesses=(),
|
|
dominance=OrderDominance({"app.py": text}),
|
|
dominating=(),
|
|
missing="path_guard",
|
|
)
|
|
assert result.arguments == (
|
|
("key", "request_input"),
|
|
("nested(owner, limit)", "unknown"),
|
|
("'a,b'", "constant"),
|
|
("mystery", "unknown"),
|
|
)
|
|
|
|
|
|
def test_request_alias_and_member_access_across_methods():
|
|
text, _, facts = fixture()
|
|
text = (
|
|
text.replace("def middle(value):", "def middle(self, value):")
|
|
.replace("def final(key):", "def final(self, key):")
|
|
.replace("read(key, owner, limit, mystery)", "read(key.id, owner, limit, mystery)")
|
|
)
|
|
ir = parse_python_ast("app.py", text)
|
|
result = build_trace(
|
|
path=PathRecord((("app.py::entry", 3), ("app.py::middle", 6), ("app.py::final", None)), "app.py::entry", True),
|
|
entry=SimpleNamespace(kind="route", trust_boundary="http_request", access_level="unknown"),
|
|
operation=Operation("app.py", 11, "final", "protected_read", "read", None, ("path_guard",)),
|
|
irs={"app.py": (ir, text)},
|
|
facts=facts,
|
|
witnesses=(),
|
|
dominance=OrderDominance({"app.py": text}),
|
|
dominating=(),
|
|
missing="path_guard",
|
|
)
|
|
assert result.arguments[0] == ("key.id", "request_input")
|
|
|
|
|
|
def test_source_name_suffix_is_unknown():
|
|
text, _, facts = fixture()
|
|
text = text.replace("owner = request.user", "owner = request.users")
|
|
ir = parse_python_ast("app.py", text)
|
|
result = build_trace(
|
|
path=PathRecord((("app.py::final", None),), "app.py::final", True),
|
|
entry=SimpleNamespace(kind="route", trust_boundary="internal", access_level="unknown"),
|
|
operation=Operation("app.py", 11, "final", "protected_read", "read", None, ("path_guard",)),
|
|
irs={"app.py": (ir, text)},
|
|
facts=facts,
|
|
witnesses=(),
|
|
dominance=OrderDominance({"app.py": text}),
|
|
dominating=(),
|
|
missing="path_guard",
|
|
)
|
|
assert result.arguments[1] == ("owner", "unknown")
|
|
|
|
|
|
def test_guards_after_follow_return_order():
|
|
text, _, facts = fixture()
|
|
text = text.replace(" middle(item)", " middle(item)\n guard()").replace(" final(alias)", " final(alias)\n guard()")
|
|
ir = parse_python_ast("app.py", text)
|
|
result = build_trace(
|
|
path=PathRecord((("app.py::entry", 3), ("app.py::middle", 7), ("app.py::final", None)), "app.py::entry", True),
|
|
entry=SimpleNamespace(kind="route", trust_boundary="http_request", access_level="unknown"),
|
|
operation=Operation("app.py", 13, "final", "protected_read", "read", None, ("path_guard",)),
|
|
irs={"app.py": (ir, text)},
|
|
facts=facts,
|
|
witnesses=(
|
|
Discharge("app.py", 4, "entry", "path_guard", "entry-after", "authenticated_context", "statement"),
|
|
Discharge("app.py", 8, "middle", "path_guard", "middle-after", "authenticated_context", "statement"),
|
|
Discharge("app.py", 14, "final", "path_guard", "final-after", "authenticated_context", "statement"),
|
|
),
|
|
dominance=OrderDominance({"app.py": text}),
|
|
dominating=(),
|
|
missing="path_guard",
|
|
)
|
|
assert [guard.fact_id for guard in result.guards_after] == ["final-after", "middle-after", "entry-after"]
|