mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 03:49:26 +00:00
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"]
|