mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-11-13 09:48:18 +00:00
feat(detection-core): foundation contract package (trace, verdict, coverage, provenance)
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
"""Detection core: the shared security-trace contract package.
|
||||
|
||||
Owns contracts only -- no analysis logic. Arms implement the trace contract;
|
||||
runners and consumers adopt coverage, budget, provenance, and admission contracts.
|
||||
"""
|
||||
|
||||
from openultrasast.core.coverage import Coverage, InstrumentFailure, ResolutionOutcome, check_reads
|
||||
from openultrasast.core.provenance import ProvenanceMismatch, stamp, verify_stamp
|
||||
from openultrasast.core.trace import Guard, Hop, TraceRecord, Witness
|
||||
from openultrasast.core.verdict import BoundedSilence, Judgment, ProvenClaim, as_clean
|
||||
|
||||
__all__ = [
|
||||
"BoundedSilence",
|
||||
"Coverage",
|
||||
"Guard",
|
||||
"Hop",
|
||||
"InstrumentFailure",
|
||||
"Judgment",
|
||||
"ProvenClaim",
|
||||
"ProvenanceMismatch",
|
||||
"ResolutionOutcome",
|
||||
"TraceRecord",
|
||||
"Witness",
|
||||
"as_clean",
|
||||
"check_reads",
|
||||
"stamp",
|
||||
"verify_stamp",
|
||||
]
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Coverage accounting for the detection core (Req 3).
|
||||
|
||||
Every input is accounted for: entry recognition, exactly one resolution bucket per
|
||||
call, named reasons for unchecked operations. A zero-byte or unreadable input is an
|
||||
instrument failure -- a zero from an unread input is a failed run, never a clean one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
class InstrumentFailure(ValueError):
|
||||
"""The run's input was not read; any result from it is invalid."""
|
||||
|
||||
|
||||
class ResolutionOutcome(enum.Enum):
|
||||
"""Exactly one bucket per call (Req 3.1)."""
|
||||
|
||||
COMPLETE = "complete"
|
||||
INCOMPLETE = "incomplete"
|
||||
UNRESOLVED = "unresolved"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Coverage:
|
||||
"""Coverage of one analyzed change."""
|
||||
|
||||
entry_points: int
|
||||
frameworks_recognised: dict[str, int] = field(default_factory=dict)
|
||||
calls: int = 0
|
||||
complete: int = 0
|
||||
incomplete: int = 0
|
||||
unresolved: int = 0
|
||||
unchecked_reasons: dict[str, int] = field(default_factory=dict)
|
||||
|
||||
def validate(self) -> None:
|
||||
"""Refuse coverage where calls lack exactly one resolution bucket."""
|
||||
buckets = (self.complete, self.incomplete, self.unresolved)
|
||||
if min(buckets) < 0:
|
||||
raise ValueError(f"negative resolution bucket in {buckets}")
|
||||
if sum(buckets) != self.calls:
|
||||
raise ValueError(
|
||||
f"resolution buckets ({self.complete}+{self.incomplete}+{self.unresolved}) must equal the call count ({self.calls})"
|
||||
)
|
||||
|
||||
|
||||
def check_reads(reads: Mapping[str, int]) -> None:
|
||||
"""Fail the run when any tracked input was read as zero bytes (Req 3.2)."""
|
||||
zero = sorted(path for path, size in reads.items() if size <= 0)
|
||||
if zero:
|
||||
raise InstrumentFailure(f"zero-byte read of tracked input: {', '.join(zero)}")
|
||||
|
||||
|
||||
__all__ = ["Coverage", "InstrumentFailure", "ResolutionOutcome", "check_reads"]
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Provenance digest stamping for the detection core (Req 6).
|
||||
|
||||
Every analysis is stamped with the analyzer revision, fact-set, acquisition-record,
|
||||
configuration, population, and per-change input digests. A result whose digests
|
||||
cannot be re-derived from the named inputs is refused, never accepted.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ProvenanceMismatch(ValueError):
|
||||
"""A recorded digest does not match the re-derived input identity."""
|
||||
|
||||
|
||||
_DIGEST_FIELDS = (
|
||||
"analyzer_revision",
|
||||
"fact_set_digest",
|
||||
"acquisition_record_digest",
|
||||
"configuration_digest",
|
||||
"population_digest",
|
||||
"input_digest",
|
||||
)
|
||||
|
||||
|
||||
def _input_digest(change_id: str, population_digest: str) -> str:
|
||||
payload = json.dumps([population_digest, change_id], sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(payload.encode()).hexdigest()[:12]
|
||||
|
||||
|
||||
def stamp(
|
||||
*,
|
||||
analyzer_revision: str,
|
||||
fact_set_digest: str,
|
||||
acquisition_record_digest: str,
|
||||
configuration_digest: str,
|
||||
population_digest: str,
|
||||
change_id: str,
|
||||
) -> dict[str, str]:
|
||||
"""The provenance stamp of one analysis (Req 6.1)."""
|
||||
return {
|
||||
"analyzer_revision": analyzer_revision,
|
||||
"fact_set_digest": fact_set_digest,
|
||||
"acquisition_record_digest": acquisition_record_digest,
|
||||
"configuration_digest": configuration_digest,
|
||||
"population_digest": population_digest,
|
||||
"input_digest": _input_digest(change_id, population_digest),
|
||||
}
|
||||
|
||||
|
||||
def verify_stamp(recorded: Mapping[str, Any], rederived: Mapping[str, Any]) -> None:
|
||||
"""Refuse when any digest cannot be re-derived (Req 6.2)."""
|
||||
for field_name in _DIGEST_FIELDS:
|
||||
if recorded.get(field_name) != rederived.get(field_name):
|
||||
raise ProvenanceMismatch(f"{field_name}: recorded {recorded.get(field_name)!r} != rederived {rederived.get(field_name)!r}")
|
||||
|
||||
|
||||
__all__ = ["ProvenanceMismatch", "stamp", "verify_stamp"]
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Canonical trace schema for the detection core (Req 1).
|
||||
|
||||
One family-agnostic trace record per analyzed change, serialized deterministically:
|
||||
identical inputs produce byte-identical canonical output. The access-control
|
||||
prover's trace is the first implementation of this contract.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from openultrasast.core.verdict import Judgment
|
||||
|
||||
|
||||
def _canonical_bytes(payload: dict[str, Any]) -> bytes:
|
||||
return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Hop:
|
||||
"""One ordered hop with its call-target completeness."""
|
||||
|
||||
function: str
|
||||
call_line: int | None
|
||||
complete: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Witness:
|
||||
"""Evidence for or against a guard, with provenance and match relation."""
|
||||
|
||||
kind: str
|
||||
provenance: str
|
||||
relation: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Guard:
|
||||
"""A guard effect observed on a path (kind plus effect)."""
|
||||
|
||||
kind: str
|
||||
effect: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TraceRecord:
|
||||
"""The canonical security trace: entry, hops, operation, witnesses, guards, judgment, digests."""
|
||||
|
||||
entry: str
|
||||
hops: tuple[Hop, ...]
|
||||
operation_kind: str
|
||||
resource: str
|
||||
authority: str
|
||||
witnesses: tuple[Witness, ...] = ()
|
||||
guards: tuple[Guard, ...] = ()
|
||||
completeness_reasons: tuple[str, ...] = ()
|
||||
judgment: Judgment = Judgment.INDETERMINATE
|
||||
digests: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
def record(self) -> dict[str, Any]:
|
||||
"""JSON-ready dict with sorted keys; enums encode as their value strings."""
|
||||
payload: dict[str, Any] = asdict(self)
|
||||
payload["judgment"] = self.judgment.value
|
||||
record: dict[str, Any] = json.loads(json.dumps(payload, sort_keys=True))
|
||||
return record
|
||||
|
||||
def canonical(self) -> bytes:
|
||||
"""Deterministic byte serialization (Req 1.2)."""
|
||||
return _canonical_bytes(self.record())
|
||||
|
||||
|
||||
__all__ = ["Hop", "TraceRecord", "Witness"]
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Verdict vocabulary and verdict contract for the detection core (Req 2, 5, 9).
|
||||
|
||||
Every analyzed operation is judged as exactly one of unguarded, discharged, or
|
||||
indeterminate. A verdict is either a proven claim (witness attached, exact
|
||||
location) or a bounded silence (coverage reason attached). Indeterminate never
|
||||
converts to clean, and models may only judge residuals zero-shot over a trace.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
class Judgment(enum.Enum):
|
||||
"""The three-way judgment vocabulary; value strings are the wire format."""
|
||||
|
||||
UNGUARDED = "unguarded"
|
||||
DISCHARGED = "discharged"
|
||||
INDETERMINATE = "indeterminate"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProvenClaim:
|
||||
"""A defect claim carrying its witness and exact location (Req 5.1)."""
|
||||
|
||||
trace_id: str
|
||||
witness: str
|
||||
path: str
|
||||
line: int
|
||||
family: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BoundedSilence:
|
||||
"""An undecided site with its coverage reason; never a clean result (Req 5.2)."""
|
||||
|
||||
path: str
|
||||
reason: str
|
||||
coverage: dict[str, Any]
|
||||
|
||||
|
||||
def as_clean(judgment: Judgment) -> Judgment:
|
||||
"""Return a clean judgment, refusing to convert indeterminate (Req 2.1)."""
|
||||
if judgment is Judgment.INDETERMINATE:
|
||||
raise ValueError("indeterminate never converts to a clean judgment")
|
||||
return judgment
|
||||
|
||||
|
||||
__all__ = ["BoundedSilence", "Judgment", "ProvenClaim", "as_clean"]
|
||||
Reference in New Issue
Block a user