mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-11-13 09:48:18 +00:00
feat(access-control-prover): task 7.4 - judgment, trace and coverage contracts with abstract state
This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
"""access-control-prover task 7.4: judgment, trace and coverage contracts with abstract state (offline).
|
||||
|
||||
Three-way pair judgments, completeness and evidence relations as contracts, and their canonical
|
||||
serialization, per the design's "Abstract evidence domain" and "Witness matching and judgment"
|
||||
sections and Req 7.1, 7.2, 7.5, 7.6, 9.1, 9.2, 9.6. Legacy trace and coverage shapes stay byte-stable
|
||||
unless the caller opts into the abstract state.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from openultrasast.semantic.ir import parse_python_ast
|
||||
from openultrasast.semantic.obligations.dominance import OrderDominance
|
||||
from openultrasast.semantic.obligations.facts import ObligationFacts, OperationFact
|
||||
from openultrasast.semantic.obligations.operations import Operation
|
||||
from openultrasast.semantic.obligations.paths import PathRecord
|
||||
from openultrasast.semantic.obligations.trace import build_trace
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from openultrasast.semantic.obligations.trace import AbstractTraceState, Trace
|
||||
|
||||
_LEGACY_TRACE_KEYS = {
|
||||
"entry_kind",
|
||||
"boundary",
|
||||
"access",
|
||||
"parameters",
|
||||
"hops",
|
||||
"operation_kind",
|
||||
"fact_id",
|
||||
"operation_line",
|
||||
"arguments",
|
||||
"guards_after",
|
||||
"requires",
|
||||
"dominating",
|
||||
"missing",
|
||||
"operation_context",
|
||||
}
|
||||
|
||||
|
||||
def _abstract_state() -> AbstractTraceState:
|
||||
from openultrasast.semantic.obligations.abstract_domain import (
|
||||
AuthorityRef,
|
||||
Completeness,
|
||||
EvidenceRelation,
|
||||
PairJudgment,
|
||||
PathJudgment,
|
||||
ResourceRef,
|
||||
WitnessMatch,
|
||||
)
|
||||
from openultrasast.semantic.obligations.trace import AbstractTraceState
|
||||
|
||||
return AbstractTraceState(
|
||||
path_judgment=PathJudgment(
|
||||
judgment=PairJudgment.UNGUARDED,
|
||||
missing=("path_guard",),
|
||||
completeness=Completeness(
|
||||
False,
|
||||
("resolution:ambiguous_call", "matching:unknown_resource", "matching:unknown_authority"),
|
||||
),
|
||||
matches=(WitnessMatch(EvidenceRelation.MUST, EvidenceRelation.MAY, EvidenceRelation.NO, ("implication:admin_role",)),),
|
||||
),
|
||||
resource=ResourceRef(kind="exact", values=("doc:1",)),
|
||||
authority=AuthorityRef(values=("admin",)),
|
||||
resource_relation=EvidenceRelation.MAY,
|
||||
authority_relation=EvidenceRelation.UNKNOWN,
|
||||
)
|
||||
|
||||
|
||||
def _trace(abstract_state: AbstractTraceState | None = None) -> Trace:
|
||||
from openultrasast.semantic.obligations.trace import Trace
|
||||
|
||||
return Trace(
|
||||
entry_kind="route",
|
||||
boundary="http_request",
|
||||
access="unknown",
|
||||
parameters=("item",),
|
||||
hops=(),
|
||||
operation_kind="protected_read",
|
||||
fact_id="read",
|
||||
operation_line=2,
|
||||
arguments=(("item", "request_input"),),
|
||||
guards_after=(),
|
||||
requires=("path_guard",),
|
||||
dominating=(),
|
||||
missing=None,
|
||||
abstract_state=abstract_state,
|
||||
)
|
||||
|
||||
|
||||
def test_pair_judgment_is_exactly_the_three_way_vocabulary() -> None:
|
||||
# Req 4.1-4.3 vocabulary: a pair is unguarded, discharged or indeterminate (design, PairJudgment).
|
||||
from openultrasast.semantic.obligations.abstract_domain import PairJudgment
|
||||
|
||||
assert [judgment.value for judgment in PairJudgment] == ["unguarded", "discharged", "indeterminate"]
|
||||
|
||||
|
||||
def test_witness_match_and_path_judgment_carry_the_design_fields() -> None:
|
||||
from openultrasast.semantic.obligations.abstract_domain import (
|
||||
Completeness,
|
||||
EvidenceRelation,
|
||||
PairJudgment,
|
||||
PathJudgment,
|
||||
WitnessMatch,
|
||||
)
|
||||
|
||||
match = WitnessMatch(EvidenceRelation.MUST, EvidenceRelation.NO, EvidenceRelation.MAY, ("witness:guard",))
|
||||
judgment = PathJudgment(PairJudgment.DISCHARGED, (), Completeness(True), (match,))
|
||||
assert (judgment.judgment, judgment.missing, judgment.matches) == (PairJudgment.DISCHARGED, (), (match,))
|
||||
assert judgment.completeness == Completeness(True)
|
||||
assert hash(judgment) == hash(PathJudgment(PairJudgment.DISCHARGED, (), Completeness(True), (match,)))
|
||||
|
||||
|
||||
def test_count_pair_judgments_reports_each_way_separately() -> None:
|
||||
# Req 7.1, 7.6: unguarded, discharged and indeterminate pairs are counted separately; an
|
||||
# indeterminate pair is included in neither the unguarded nor the discharged count.
|
||||
from openultrasast.semantic.obligations.abstract_domain import PairJudgment, PairJudgmentCounts, count_pair_judgments
|
||||
|
||||
counts = count_pair_judgments([PairJudgment.UNGUARDED, PairJudgment.INDETERMINATE, PairJudgment.INDETERMINATE, PairJudgment.DISCHARGED])
|
||||
assert counts == PairJudgmentCounts(unguarded=1, discharged=1, indeterminate=2)
|
||||
assert counts.examined() == 4
|
||||
assert count_pair_judgments([]) == PairJudgmentCounts()
|
||||
|
||||
|
||||
def test_reason_counts_aggregates_every_reason_by_pair() -> None:
|
||||
# Req 7.2, 7.5: every incompleteness reason is preserved once per pair and aggregated in coverage.
|
||||
from openultrasast.semantic.obligations.abstract_domain import Completeness, reason_counts
|
||||
|
||||
counts = reason_counts(
|
||||
[
|
||||
Completeness(False, ("resolution:ambiguous_call", "matching:unknown_resource")),
|
||||
Completeness(False, ("resolution:ambiguous_call", "dominance:cfg_unavailable")),
|
||||
Completeness(True),
|
||||
]
|
||||
)
|
||||
assert counts == {"resolution:ambiguous_call": 2, "dominance:cfg_unavailable": 1, "matching:unknown_resource": 1}
|
||||
assert list(counts) == sorted(counts)
|
||||
|
||||
|
||||
def test_legacy_trace_omits_abstract_state_from_record_and_text() -> None:
|
||||
record = _trace().record()
|
||||
assert "abstract_state" not in record
|
||||
assert set(record) == _LEGACY_TRACE_KEYS
|
||||
assert json.loads(json.dumps(record)) == record
|
||||
text = _trace().text()
|
||||
assert [line.split(" ", 1)[0] for line in text.splitlines()] == ["entry", "operation", "verdict"]
|
||||
assert _trace().record() == record and _trace().text() == text
|
||||
|
||||
|
||||
def test_abstract_state_round_trips_all_fields_through_canonical_json() -> None:
|
||||
from openultrasast.semantic.obligations.trace import parse_abstract_state
|
||||
|
||||
state = _abstract_state()
|
||||
record = _trace(abstract_state=state).record()
|
||||
assert json.loads(json.dumps(record)) == record
|
||||
assert parse_abstract_state(record["abstract_state"]) == state
|
||||
assert _trace(abstract_state=state).record() == record
|
||||
|
||||
|
||||
def test_canonical_value_encoding_names_every_domain_value_stably() -> None:
|
||||
# EvidenceRelation and PairJudgment encode as their value strings; ResourceRef, AuthorityRef and
|
||||
# Completeness encode as sorted field dicts, so the canonical form is deterministic (Req 9.5).
|
||||
payload = _trace(abstract_state=_abstract_state()).record()["abstract_state"]
|
||||
assert list(payload) == sorted(payload)
|
||||
assert payload["path_judgment"]["judgment"] == "unguarded"
|
||||
assert payload["path_judgment"]["completeness"] == {
|
||||
"is_complete": False,
|
||||
"reasons": ["resolution:ambiguous_call", "matching:unknown_resource", "matching:unknown_authority"],
|
||||
}
|
||||
assert payload["path_judgment"]["matches"] == [
|
||||
{"authority_relation": "no", "evidence": ["implication:admin_role"], "relation": "must", "resource_relation": "may"}
|
||||
]
|
||||
assert payload["resource"] == {"kind": "exact", "values": ["doc:1"]}
|
||||
assert payload["authority"] == {"is_unknown": False, "values": ["admin"]}
|
||||
assert payload["resource_relation"] == "may"
|
||||
assert payload["authority_relation"] == "unknown"
|
||||
|
||||
|
||||
def test_abstract_text_appends_one_deterministic_abstract_component() -> None:
|
||||
legacy = _trace().text()
|
||||
extended = _trace(abstract_state=_abstract_state()).text()
|
||||
assert extended.startswith(legacy)
|
||||
lines = extended.splitlines()
|
||||
assert lines[-1].startswith("abstract ")
|
||||
assert json.loads(lines[-1][len("abstract ") :]) == _trace(abstract_state=_abstract_state()).record()["abstract_state"]
|
||||
assert extended == _trace(abstract_state=_abstract_state()).text()
|
||||
|
||||
|
||||
def test_every_incompleteness_reason_survives_the_round_trip() -> None:
|
||||
# Req 7.2's reason vocabulary, including unknown resource and unknown authority, is preserved in
|
||||
# the trace (Req 7.5, 9.6) rather than folded into a quieter summary.
|
||||
from dataclasses import replace
|
||||
|
||||
from openultrasast.semantic.obligations.abstract_domain import Completeness
|
||||
from openultrasast.semantic.obligations.trace import parse_abstract_state
|
||||
|
||||
reasons = (
|
||||
"recognition:framework_unrecognised",
|
||||
"resolution:unresolved_call",
|
||||
"resolution:ambiguous_call",
|
||||
"summary:exploration_cutoff",
|
||||
"dominance:cfg_unavailable",
|
||||
"matching:unknown_resource",
|
||||
"matching:unknown_authority",
|
||||
"judgment:analysis_exception",
|
||||
)
|
||||
state = _abstract_state()
|
||||
state = replace(state, path_judgment=replace(state.path_judgment, completeness=Completeness(False, reasons)))
|
||||
parsed = parse_abstract_state(_trace(abstract_state=state).record()["abstract_state"])
|
||||
assert parsed.path_judgment.completeness.reasons == reasons
|
||||
assert parsed.path_judgment.completeness.is_complete is False
|
||||
|
||||
|
||||
def test_parse_abstract_state_rejects_unknown_judgment_values() -> None:
|
||||
import pytest
|
||||
|
||||
from openultrasast.semantic.obligations.trace import parse_abstract_state
|
||||
|
||||
payload = _trace(abstract_state=_abstract_state()).record()["abstract_state"]
|
||||
payload["path_judgment"]["judgment"] = "probably_fine"
|
||||
with pytest.raises(ValueError):
|
||||
parse_abstract_state(payload)
|
||||
|
||||
|
||||
def test_build_trace_carries_abstract_state_as_an_opt_in_argument() -> None:
|
||||
text = "def entry(item):\n read(item)\n"
|
||||
ir = parse_python_ast("app.py", text)
|
||||
facts = ObligationFacts("1", (OperationFact("read", "protected_read", "python", ("read",), None, ("path_guard",), "high"),), ())
|
||||
result = build_trace(
|
||||
path=PathRecord((("app.py::entry", None),), "app.py::entry", True),
|
||||
entry=SimpleNamespace(kind="route", trust_boundary="http_request", access_level="unknown"),
|
||||
operation=Operation("app.py", 2, "entry", "protected_read", "read", None, ("path_guard",)),
|
||||
irs={"app.py": (ir, text)},
|
||||
facts=facts,
|
||||
witnesses=(),
|
||||
dominance=OrderDominance({"app.py": text}),
|
||||
dominating=(),
|
||||
missing="path_guard",
|
||||
abstract_state=_abstract_state(),
|
||||
)
|
||||
assert result.abstract_state == _abstract_state()
|
||||
assert result.record()["abstract_state"]["path_judgment"]["judgment"] == "unguarded"
|
||||
|
||||
|
||||
def test_coverage_counts_pair_judgments_without_changing_legacy_fields() -> None:
|
||||
# Req 7.1, 7.6: the coverage contract gains the three pair judgment counts additively; the
|
||||
# legacy constructor call and fields stay valid and the record stays pure JSON.
|
||||
from dataclasses import asdict
|
||||
|
||||
from openultrasast.hunt.prover import Coverage
|
||||
from openultrasast.semantic.obligations.abstract_domain import PairJudgmentCounts
|
||||
|
||||
coverage = Coverage({"python": 1}, {}, 1, 1, 0, 1, 1, {"parse_failed": 1})
|
||||
assert coverage.pair_judgments == PairJudgmentCounts()
|
||||
serialized = asdict(coverage)
|
||||
assert json.loads(json.dumps(serialized)) == serialized
|
||||
assert serialized["pair_judgments"] == {"unguarded": 0, "discharged": 0, "indeterminate": 0}
|
||||
Reference in New Issue
Block a user