mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-11-13 01:48:17 +00:00
feat(detection-core): adapt authorization traces
This commit is contained in:
@@ -302,7 +302,7 @@
|
||||
- _Depends: 8.2, 8.9_
|
||||
|
||||
- [ ] 9. Integrate adjacent producers and benchmark-owned orchestration
|
||||
- [ ] 9.1 (P) Translate the parked authorization trace conservatively into v2 evidence
|
||||
- [x] 9.1 (P) Translate the parked authorization trace conservatively into v2 evidence
|
||||
- Move conversion out of the family trace domain and keep authorization vocabulary outside shared core modules.
|
||||
- Map missing per-hop knowledge to incomplete relations and indeterminate outcomes without behavior lifting.
|
||||
- Preserve existing parked-arm regression behavior and avoid any admission rerun or reactivation.
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Conservative authorization adapters for detection-core migration boundaries."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
|
||||
from openultrasast.core import evidence, family, semantics
|
||||
from openultrasast.core import provenance as core_provenance
|
||||
from openultrasast.core.trace import Guard as LegacyGuard
|
||||
from openultrasast.core.trace import Hop as LegacyHop
|
||||
from openultrasast.core.trace import TraceRecord
|
||||
from openultrasast.core.trace import Witness as LegacyWitness
|
||||
from openultrasast.core.verdict import Judgment
|
||||
|
||||
from .abstract_domain import EvidenceRelation, PairJudgment
|
||||
from .trace import Trace
|
||||
|
||||
_MISSING_CONSTRAINT = "missing_constraint_relation"
|
||||
_AMBIGUOUS_CONSTRAINT = "ambiguous_constraint_relation"
|
||||
|
||||
|
||||
def _location(trace: Trace, scope: evidence.AnalyzedScope) -> semantics.SourceLocation:
|
||||
matches = tuple(region for region in scope.regions if region.start_line <= trace.operation_line <= region.end_line)
|
||||
if len(matches) != 1:
|
||||
raise ValueError("trace operation must resolve to exactly one analyzed source region")
|
||||
return semantics.SourceLocation(matches[0].path, trace.operation_line, 0, trace.operation_kind)
|
||||
|
||||
|
||||
def _product(stamp: core_provenance.ProvenanceStamp) -> semantics.EvidenceProvenance:
|
||||
return semantics.EvidenceProvenance(
|
||||
semantics.ProvenanceKind.PRODUCT_CODE,
|
||||
stamp.change_input_digest,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _constraint_state(trace: Trace) -> tuple[semantics.RelationStatus, str, str, evidence.NeutralOutcome]:
|
||||
state = trace.abstract_state
|
||||
if state is None or trace.hops:
|
||||
return semantics.RelationStatus.INCOMPLETE, "unknown", _MISSING_CONSTRAINT, evidence.NeutralOutcome.INDETERMINATE
|
||||
complete = state.path_judgment.completeness.is_complete
|
||||
known_values = state.resource.kind != "unknown" and not state.authority.is_unknown
|
||||
known_relations = state.resource_relation in (EvidenceRelation.MUST, EvidenceRelation.NO) and state.authority_relation in (
|
||||
EvidenceRelation.MUST,
|
||||
EvidenceRelation.NO,
|
||||
)
|
||||
if not (complete and known_values and known_relations):
|
||||
return semantics.RelationStatus.INCOMPLETE, "unknown", _MISSING_CONSTRAINT, evidence.NeutralOutcome.INDETERMINATE
|
||||
judgment = state.path_judgment.judgment
|
||||
if judgment is PairJudgment.UNGUARDED:
|
||||
return semantics.RelationStatus.COMPLETE, "unconstrained", "", evidence.NeutralOutcome.VIOLATION
|
||||
if judgment is PairJudgment.DISCHARGED:
|
||||
return semantics.RelationStatus.COMPLETE, "guarded", "", evidence.NeutralOutcome.SATISFIED
|
||||
return semantics.RelationStatus.UNRESOLVED, "unknown", _AMBIGUOUS_CONSTRAINT, evidence.NeutralOutcome.INDETERMINATE
|
||||
|
||||
|
||||
def _decision_rule(contract: family.FamilyContract, outcome: evidence.NeutralOutcome) -> family.DecisionRule:
|
||||
matches = tuple(rule for rule in contract.decisions if rule.outcome is outcome)
|
||||
if len(matches) != 1:
|
||||
raise ValueError("authorization contract must declare exactly one rule for each neutral outcome")
|
||||
return matches[0]
|
||||
|
||||
|
||||
def _relations(
|
||||
trace: Trace,
|
||||
location: semantics.SourceLocation,
|
||||
product: semantics.EvidenceProvenance,
|
||||
) -> tuple[tuple[semantics.SemanticRelation, ...], str, evidence.NeutralOutcome]:
|
||||
entry = semantics.SemanticNode("entry", "entry", location)
|
||||
operation = semantics.SemanticNode("operation", "operation", location)
|
||||
operation_relation = semantics.make_relation(
|
||||
kind=semantics.RelationKind.OPERATION_EFFECT,
|
||||
role="operation",
|
||||
source=entry,
|
||||
target=operation,
|
||||
location=location,
|
||||
status=semantics.RelationStatus.COMPLETE,
|
||||
conditions=(),
|
||||
reasons=(),
|
||||
provenance=product,
|
||||
)
|
||||
status, target_name, reason, outcome = _constraint_state(trace)
|
||||
constraint_relation = semantics.make_relation(
|
||||
kind=semantics.RelationKind.CONSTRAINT_EFFECT,
|
||||
role="constraint",
|
||||
source=operation,
|
||||
target=semantics.SemanticNode(target_name, "constraint", location),
|
||||
location=location,
|
||||
status=status,
|
||||
conditions=tuple(dict.fromkeys(trace.requires)),
|
||||
reasons=() if not reason else (reason,),
|
||||
provenance=product,
|
||||
)
|
||||
return (operation_relation, constraint_relation), reason, outcome
|
||||
|
||||
|
||||
def _packet(
|
||||
trace: Trace,
|
||||
contract: family.FamilyContract,
|
||||
ecosystem: str,
|
||||
scope: evidence.AnalyzedScope,
|
||||
stamp: core_provenance.ProvenanceStamp,
|
||||
) -> tuple[evidence.EvidencePacket, evidence.NeutralOutcome]:
|
||||
location = _location(trace, scope)
|
||||
product = _product(stamp)
|
||||
transitions, reason, outcome = _relations(trace, location, product)
|
||||
fields = (
|
||||
semantics.FieldValue("operation", semantics.FieldKind.TEXT, trace.operation_kind),
|
||||
semantics.FieldValue("boundary", semantics.FieldKind.TEXT, trace.boundary),
|
||||
)
|
||||
observation = semantics.make_observation(
|
||||
role="security_object",
|
||||
location=location,
|
||||
fields=fields,
|
||||
status=semantics.RelationStatus.COMPLETE,
|
||||
provenance=product,
|
||||
)
|
||||
completeness = () if not reason else (reason,)
|
||||
return (
|
||||
evidence.EvidencePacket(
|
||||
evidence.EVIDENCE_PACKET_SCHEMA,
|
||||
contract.family_id,
|
||||
family.family_contract_digest(contract),
|
||||
family.derive_security_object_id(contract.security_object, fields),
|
||||
ecosystem,
|
||||
scope,
|
||||
transitions,
|
||||
(observation,),
|
||||
fields,
|
||||
completeness,
|
||||
stamp,
|
||||
),
|
||||
outcome,
|
||||
)
|
||||
|
||||
|
||||
def to_evidence_record(
|
||||
trace: Trace,
|
||||
*,
|
||||
contract: family.FamilyContract,
|
||||
ecosystem: str,
|
||||
scope: evidence.AnalyzedScope,
|
||||
provenance: core_provenance.ProvenanceStamp,
|
||||
) -> evidence.EvidenceRecord:
|
||||
"""Map one parked authorization trace without promoting missing hop knowledge."""
|
||||
if type(trace) is not Trace:
|
||||
raise TypeError("authorization adapter requires an exact Trace")
|
||||
family.validate_contract(contract)
|
||||
packet, outcome = _packet(trace, contract, ecosystem, scope, provenance)
|
||||
completeness = packet.completeness_reasons
|
||||
rule = _decision_rule(contract, outcome)
|
||||
decision = evidence.Decision(
|
||||
rule.family_code,
|
||||
outcome,
|
||||
evidence.EvidenceBasis.DETERMINISTIC,
|
||||
scope,
|
||||
completeness,
|
||||
evidence.derive_packet_digest(packet),
|
||||
_deterministic_provenance(),
|
||||
)
|
||||
record = evidence.EvidenceRecord(packet, decision, "")
|
||||
family.validate_record(contract, record)
|
||||
return record
|
||||
|
||||
|
||||
def _deterministic_provenance() -> core_provenance.DecisionProvenance:
|
||||
return core_provenance.DecisionProvenance(None, None, None, None, None, None)
|
||||
|
||||
|
||||
def to_legacy_trace_record(
|
||||
trace: Trace,
|
||||
*,
|
||||
entry: str,
|
||||
digests: Mapping[str, str],
|
||||
complete: bool = False,
|
||||
) -> TraceRecord:
|
||||
"""Preserve the frozen v1 conversion while its writers remain parked."""
|
||||
state = trace.abstract_state
|
||||
if state is None:
|
||||
judgment = Judgment.INDETERMINATE
|
||||
reasons: tuple[str, ...] = ("abstract_state_unset",)
|
||||
witnesses: tuple[LegacyWitness, ...] = ()
|
||||
resource = authority = "unknown"
|
||||
else:
|
||||
judgment = Judgment(state.path_judgment.judgment.value)
|
||||
completeness = state.path_judgment.completeness
|
||||
reasons = tuple(completeness.reasons) or (("incomplete",) if not completeness.is_complete else ())
|
||||
witnesses = tuple(
|
||||
LegacyWitness(kind="witness", provenance=item, relation=match.relation.value)
|
||||
for match in state.path_judgment.matches
|
||||
for item in match.evidence
|
||||
)
|
||||
resource = ",".join(state.resource.values) or "unknown"
|
||||
authority = ",".join(state.authority.values) or "unknown"
|
||||
hops = tuple(LegacyHop(function=item.function, call_line=item.call_line, complete=complete) for item in trace.hops)
|
||||
hop_guards = tuple(guard for hop in trace.hops for guard in hop.guards_before_call)
|
||||
guards = tuple(LegacyGuard(kind=item.kind, effect=item.effect) for item in (*hop_guards, *trace.guards_after))
|
||||
return TraceRecord(
|
||||
entry=entry,
|
||||
hops=hops,
|
||||
operation_kind=trace.operation_kind,
|
||||
resource=resource,
|
||||
authority=authority,
|
||||
witnesses=witnesses,
|
||||
guards=guards,
|
||||
completeness_reasons=reasons,
|
||||
judgment=judgment,
|
||||
digests=dict(digests),
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["to_evidence_record", "to_legacy_trace_record"]
|
||||
@@ -10,12 +10,6 @@ from dataclasses import asdict, dataclass, replace
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from openultrasast.core.trace import Guard as CoreGuard
|
||||
from openultrasast.core.trace import Hop as CoreHop
|
||||
from openultrasast.core.trace import TraceRecord
|
||||
from openultrasast.core.trace import Witness as CoreWitness
|
||||
from openultrasast.core.verdict import Judgment as CoreJudgment
|
||||
|
||||
from ..cfg import ControlContext
|
||||
from ..ir import FileIR, FunctionIR, trailing_name
|
||||
from .abstract_domain import (
|
||||
@@ -153,47 +147,6 @@ class Trace:
|
||||
record: dict[str, Any] = json.loads(json.dumps(payload, sort_keys=True, default=_enum_json_value))
|
||||
return record
|
||||
|
||||
def to_core_record(self, *, entry: str, digests: Mapping[str, str], complete: bool = False) -> TraceRecord:
|
||||
"""Native conformance to the detection-core trace contract (detection-core task 8.1).
|
||||
|
||||
``complete`` defaults to False: a Trace carries no per-hop completeness, so the
|
||||
record never asserts resolved call targets it did not see. Guards are mapped from
|
||||
every hop's before-call guards plus the operation-level guards.
|
||||
"""
|
||||
state = self.abstract_state
|
||||
if state is None:
|
||||
judgment = CoreJudgment.INDETERMINATE
|
||||
reasons: tuple[str, ...] = ("abstract_state_unset",)
|
||||
witnesses: tuple[CoreWitness, ...] = ()
|
||||
resource = "unknown"
|
||||
authority = "unknown"
|
||||
else:
|
||||
judgment = CoreJudgment(state.path_judgment.judgment.value)
|
||||
completeness = state.path_judgment.completeness
|
||||
reasons = tuple(completeness.reasons) or (("incomplete",) if not completeness.is_complete else ())
|
||||
witnesses = tuple(
|
||||
CoreWitness(kind="witness", provenance=evidence, relation=match.relation.value)
|
||||
for match in state.path_judgment.matches
|
||||
for evidence in match.evidence
|
||||
)
|
||||
resource = ",".join(state.resource.values) or "unknown"
|
||||
authority = ",".join(state.authority.values) or "unknown"
|
||||
hops = tuple(CoreHop(function=hop.function, call_line=hop.call_line, complete=complete) for hop in self.hops)
|
||||
hop_guards = tuple(guard for hop in self.hops for guard in hop.guards_before_call)
|
||||
guards = tuple(CoreGuard(kind=guard.kind, effect=guard.effect) for guard in (*hop_guards, *self.guards_after))
|
||||
return TraceRecord(
|
||||
entry=entry,
|
||||
hops=hops,
|
||||
operation_kind=self.operation_kind,
|
||||
resource=resource,
|
||||
authority=authority,
|
||||
witnesses=witnesses,
|
||||
guards=guards,
|
||||
completeness_reasons=reasons,
|
||||
judgment=judgment,
|
||||
digests=dict(digests),
|
||||
)
|
||||
|
||||
def text(self) -> str:
|
||||
"""Emit entry, hops, operation, later guards, verdict and, when present, the abstract state."""
|
||||
record = self.record()
|
||||
|
||||
@@ -17,6 +17,7 @@ from openultrasast.semantic.obligations.abstract_domain import ( # noqa: E402
|
||||
ResourceRef,
|
||||
WitnessMatch,
|
||||
)
|
||||
from openultrasast.semantic.obligations.core_adapter import to_legacy_trace_record # noqa: E402
|
||||
from openultrasast.semantic.obligations.trace import AbstractTraceState, Guard, Hop, Trace # noqa: E402
|
||||
|
||||
|
||||
@@ -55,14 +56,14 @@ def _trace() -> Trace:
|
||||
|
||||
|
||||
def test_trace_natively_conforms_to_core_schema() -> None:
|
||||
record = _trace().to_core_record(entry="src/app.py::handler", digests={"input": "abc123"})
|
||||
record = to_legacy_trace_record(_trace(), entry="src/app.py::handler", digests={"input": "abc123"})
|
||||
assert isinstance(record, TraceRecord)
|
||||
assert record.entry == "src/app.py::handler"
|
||||
assert record.judgment is Judgment.UNGUARDED
|
||||
assert record.resource == "credentials"
|
||||
assert [g.kind for g in record.guards] == ["permission_check", "inner", "after"]
|
||||
assert record.hops[0].complete is False # conservative default
|
||||
assert record.canonical() == _trace().to_core_record(entry="src/app.py::handler", digests={"input": "abc123"}).canonical()
|
||||
assert record.canonical() == to_legacy_trace_record(_trace(), entry="src/app.py::handler", digests={"input": "abc123"}).canonical()
|
||||
|
||||
|
||||
def test_legacy_trace_without_abstract_state_is_indeterminate() -> None:
|
||||
@@ -82,7 +83,7 @@ def test_legacy_trace_without_abstract_state_is_indeterminate() -> None:
|
||||
dominating=trace.dominating,
|
||||
missing=trace.missing,
|
||||
)
|
||||
record = legacy.to_core_record(entry="e", digests={})
|
||||
record = to_legacy_trace_record(legacy, entry="e", digests={})
|
||||
assert record.judgment is Judgment.INDETERMINATE
|
||||
assert record.completeness_reasons
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ from openultrasast.semantic.obligations.abstract_domain import ( # noqa: E402
|
||||
ResourceRef,
|
||||
WitnessMatch,
|
||||
)
|
||||
from openultrasast.semantic.obligations.core_adapter import to_legacy_trace_record # noqa: E402
|
||||
from openultrasast.semantic.obligations.trace import AbstractTraceState, Trace # noqa: E402
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
@@ -67,7 +68,7 @@ def test_end_to_end_push_through_core_contracts() -> None:
|
||||
missing="identity_constraint",
|
||||
abstract_state=state,
|
||||
)
|
||||
record = trace.to_core_record(entry="src/app.py::handler", digests={"input": "abc123"})
|
||||
record = to_legacy_trace_record(trace, entry="src/app.py::handler", digests={"input": "abc123"})
|
||||
claim = ProvenClaim(trace_id="t1", witness="fact:f", path="src/app.py", line=30, family="access_control")
|
||||
verdict = consume_verdict(PushRequest(base="b", head="h", budget_class="dev-pool"), record, (claim,), ())
|
||||
assert verdict.claims[0].witness == "fact:f"
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Authorization traces cross into detection-core v2 only through the adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from dataclasses import replace
|
||||
|
||||
from test_core_evidence import _scope, _stamp
|
||||
from test_core_native import _trace
|
||||
from test_core_reference_families import _contract, _load
|
||||
|
||||
from openultrasast.core import evidence, family, semantics
|
||||
from openultrasast.semantic.obligations import trace as trace_module
|
||||
from openultrasast.semantic.obligations.abstract_domain import PairJudgment, ResourceRef
|
||||
from openultrasast.semantic.obligations.core_adapter import to_evidence_record
|
||||
|
||||
|
||||
def _adapt(trace: trace_module.Trace) -> evidence.EvidenceRecord:
|
||||
contract = _contract(_load("obligation_family.json"))
|
||||
stamp = replace(_stamp(), family_contract_digest=family.family_contract_digest(contract))
|
||||
return to_evidence_record(
|
||||
trace,
|
||||
contract=contract,
|
||||
ecosystem="reference-obligation",
|
||||
scope=_scope(),
|
||||
provenance=stamp,
|
||||
)
|
||||
|
||||
|
||||
def test_direct_complete_authorization_trace_maps_to_v2_violation() -> None:
|
||||
trace = replace(_trace(), hops=(), operation_line=3)
|
||||
|
||||
record = _adapt(trace)
|
||||
|
||||
assert record.decision.outcome is evidence.NeutralOutcome.VIOLATION
|
||||
assert record.decision.family_code == "constraint_exposed"
|
||||
assert tuple(item.role for item in record.packet.transitions) == ("operation", "constraint")
|
||||
assert all(item.status is semantics.RelationStatus.COMPLETE for item in record.packet.transitions)
|
||||
|
||||
|
||||
def test_missing_per_hop_completeness_stays_incomplete_and_indeterminate() -> None:
|
||||
trace = replace(_trace(), operation_line=3)
|
||||
|
||||
record = _adapt(trace)
|
||||
|
||||
constraint = next(item for item in record.packet.transitions if item.role == "constraint")
|
||||
assert constraint.status is semantics.RelationStatus.INCOMPLETE
|
||||
assert record.decision.outcome is evidence.NeutralOutcome.INDETERMINATE
|
||||
assert record.decision.family_code == "constraint_unknown"
|
||||
assert "missing_constraint_relation" in record.packet.completeness_reasons
|
||||
|
||||
|
||||
def test_complete_discharged_trace_maps_to_v2_satisfaction() -> None:
|
||||
trace = replace(_trace(), hops=(), operation_line=3)
|
||||
assert trace.abstract_state is not None
|
||||
state = replace(
|
||||
trace.abstract_state,
|
||||
path_judgment=replace(trace.abstract_state.path_judgment, judgment=PairJudgment.DISCHARGED, missing=()),
|
||||
)
|
||||
|
||||
record = _adapt(replace(trace, abstract_state=state))
|
||||
|
||||
assert record.decision.outcome is evidence.NeutralOutcome.SATISFIED
|
||||
assert record.decision.family_code == "constraint_enforced"
|
||||
|
||||
|
||||
def test_unknown_resource_degrades_complete_legacy_judgment_to_indeterminate() -> None:
|
||||
trace = replace(_trace(), hops=(), operation_line=3)
|
||||
assert trace.abstract_state is not None
|
||||
state = replace(trace.abstract_state, resource=ResourceRef(kind="unknown"))
|
||||
|
||||
record = _adapt(replace(trace, abstract_state=state))
|
||||
|
||||
assert record.decision.outcome is evidence.NeutralOutcome.INDETERMINATE
|
||||
assert record.packet.transitions[1].status is semantics.RelationStatus.INCOMPLETE
|
||||
|
||||
|
||||
def test_unset_abstract_state_never_lifts_legacy_trace_to_a_clean_result() -> None:
|
||||
record = _adapt(replace(_trace(), hops=(), operation_line=3, abstract_state=None))
|
||||
|
||||
assert record.decision.outcome is evidence.NeutralOutcome.INDETERMINATE
|
||||
assert record.packet.transitions[1].status is semantics.RelationStatus.INCOMPLETE
|
||||
|
||||
|
||||
def test_authorization_trace_domain_has_no_core_import_or_conversion_method() -> None:
|
||||
source = inspect.getsource(trace_module)
|
||||
|
||||
assert "openultrasast.core" not in source
|
||||
assert not hasattr(trace_module.Trace, "to_core_record")
|
||||
Reference in New Issue
Block a user