mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-11-13 01:48:17 +00:00
311 lines
11 KiB
Python
311 lines
11 KiB
Python
"""Legacy stamps and strict v2 input/decision provenance contracts."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import re
|
|
from collections.abc import Mapping
|
|
from dataclasses import dataclass, fields
|
|
from enum import Enum
|
|
|
|
from openultrasast.core import canonical
|
|
from openultrasast.core.canonical import ContentRef, ContentResolver
|
|
|
|
NAMED_INPUTS_SCHEMA = "openultrasast.detection-core.named-inputs.v2"
|
|
PROVENANCE_POLICY_SCHEMA = "openultrasast.detection-core.provenance-policy.v2"
|
|
_DIGEST = re.compile(r"[0-9a-f]{64}")
|
|
|
|
|
|
class ProvenanceMismatch(ValueError):
|
|
"""A legacy recorded digest does not match its re-derived identity."""
|
|
|
|
|
|
_LEGACY_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]:
|
|
"""Preserve the v1 provenance stamp during migration."""
|
|
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, object], rederived: Mapping[str, object]) -> None:
|
|
"""Preserve v1 refusal when a legacy digest cannot be re-derived."""
|
|
for field_name in _LEGACY_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}")
|
|
|
|
|
|
class ProvenanceValidationError(ValueError):
|
|
"""A provenance identity cannot be reproduced from its named inputs."""
|
|
|
|
|
|
class ProvenanceInputRole(Enum):
|
|
ANALYZER = "analyzer"
|
|
FAMILY_CONTRACT = "family_contract"
|
|
FACT_SET = "fact_set"
|
|
ACQUISITION = "acquisition"
|
|
CONFIGURATION = "configuration"
|
|
POPULATION = "population"
|
|
CHANGE_INPUT = "change_input"
|
|
|
|
|
|
def _require_digest(value: object, field_name: str) -> None:
|
|
if not isinstance(value, str) or _DIGEST.fullmatch(value) is None:
|
|
raise canonical.DigestFormatError(f"{field_name} must be a full lowercase SHA-256 digest")
|
|
|
|
|
|
def _require_text(value: object, field_name: str) -> None:
|
|
if not isinstance(value, str) or not value:
|
|
raise ValueError(f"{field_name} must be nonempty text")
|
|
|
|
|
|
def _require_maximum(value: object) -> None:
|
|
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
|
raise ValueError("maximum_input_bytes must be a positive integer")
|
|
|
|
|
|
def _require_stores(stores: object) -> None:
|
|
if not isinstance(stores, tuple) or not stores:
|
|
raise ValueError("trusted_store_identities must be a nonempty tuple")
|
|
for store in stores:
|
|
_require_text(store, "trusted_store_identity")
|
|
if len(set(stores)) != len(stores):
|
|
raise ValueError("trusted_store_identities must be unique")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ProvenanceStamp:
|
|
analyzer_revision: str
|
|
family_contract_digest: str
|
|
fact_set_digest: str
|
|
acquisition_record_digest: str
|
|
configuration_digest: str
|
|
population_digest: str
|
|
change_input_digest: str
|
|
|
|
def __post_init__(self) -> None:
|
|
for item in fields(self):
|
|
_require_digest(getattr(self, item.name), item.name)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ProvenanceTrustPolicy:
|
|
trusted_store_identities: tuple[str, ...]
|
|
maximum_input_bytes: int
|
|
policy_digest: str
|
|
|
|
def __post_init__(self) -> None:
|
|
_require_stores(self.trusted_store_identities)
|
|
_require_maximum(self.maximum_input_bytes)
|
|
_require_digest(self.policy_digest, "policy_digest")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class NamedInput:
|
|
role: ProvenanceInputRole
|
|
identity: str
|
|
content: ContentRef
|
|
|
|
def __post_init__(self) -> None:
|
|
_require_digest(self.identity, "named input identity")
|
|
if not isinstance(self.content, ContentRef):
|
|
raise TypeError("named input content must be a ContentRef")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class NamedInputs:
|
|
inputs: tuple[NamedInput, ...]
|
|
manifest_digest: str
|
|
|
|
def __post_init__(self) -> None:
|
|
if not isinstance(self.inputs, tuple) or not all(isinstance(item, NamedInput) for item in self.inputs):
|
|
raise TypeError("named inputs must be a tuple of NamedInput values")
|
|
_require_digest(self.manifest_digest, "manifest_digest")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ModelCallIdentity:
|
|
provider: str
|
|
model: str
|
|
program_digest: str
|
|
prompt_digest: str
|
|
configuration_digest: str
|
|
adapter_revision: str
|
|
|
|
def __post_init__(self) -> None:
|
|
for field_name in ("provider", "model", "adapter_revision"):
|
|
_require_text(getattr(self, field_name), field_name)
|
|
for field_name in ("program_digest", "prompt_digest", "configuration_digest"):
|
|
_require_digest(getattr(self, field_name), field_name)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DecisionProvenance:
|
|
model_identity: ModelCallIdentity | None
|
|
model_result_digest: str | None
|
|
model_declaration_digest: str | None
|
|
proof_record_digest: str | None
|
|
proof_producer_digest: str | None
|
|
lineage_manifest_digest: str | None
|
|
|
|
def __post_init__(self) -> None:
|
|
if self.model_identity is not None and not isinstance(self.model_identity, ModelCallIdentity):
|
|
raise TypeError("model_identity must be a ModelCallIdentity")
|
|
for item in fields(self)[1:]:
|
|
value = getattr(self, item.name)
|
|
if value is not None:
|
|
_require_digest(value, item.name)
|
|
|
|
|
|
def _content_ref_value(reference: ContentRef) -> canonical.JsonValue:
|
|
return {
|
|
"artifact_id": reference.artifact_id,
|
|
"content_digest": reference.content_digest,
|
|
"media_type": reference.media_type,
|
|
"byte_length": reference.byte_length,
|
|
"retention_policy_digest": reference.retention_policy_digest,
|
|
"storage_receipt_digest": reference.storage_receipt_digest,
|
|
}
|
|
|
|
|
|
def _input_value(item: NamedInput) -> canonical.JsonValue:
|
|
if not isinstance(item.role, ProvenanceInputRole):
|
|
raise ProvenanceValidationError(f"unknown named input role: {item.role!r}")
|
|
return {"role": item.role.value, "identity": item.identity, "content": _content_ref_value(item.content)}
|
|
|
|
|
|
def derive_named_inputs_digest(inputs: tuple[NamedInput, ...]) -> str:
|
|
"""Derive an order-independent identity over every named input reference."""
|
|
ordered = tuple(sorted(inputs, key=lambda item: item.role.value))
|
|
payload: canonical.JsonValue = {
|
|
"schema_version": NAMED_INPUTS_SCHEMA,
|
|
"inputs": tuple(_input_value(item) for item in ordered),
|
|
}
|
|
return canonical.content_digest(payload)
|
|
|
|
|
|
def derive_trust_policy_digest(trusted_store_identities: tuple[str, ...], maximum_input_bytes: int) -> str:
|
|
"""Derive the trust and size policy identity."""
|
|
_require_stores(trusted_store_identities)
|
|
_require_maximum(maximum_input_bytes)
|
|
payload: canonical.JsonValue = {
|
|
"schema_version": PROVENANCE_POLICY_SCHEMA,
|
|
"trusted_store_identities": tuple(sorted(trusted_store_identities)),
|
|
"maximum_input_bytes": maximum_input_bytes,
|
|
}
|
|
return canonical.content_digest(payload)
|
|
|
|
|
|
_STAMP_FIELD = {
|
|
ProvenanceInputRole.ANALYZER: "analyzer_revision",
|
|
ProvenanceInputRole.FAMILY_CONTRACT: "family_contract_digest",
|
|
ProvenanceInputRole.FACT_SET: "fact_set_digest",
|
|
ProvenanceInputRole.ACQUISITION: "acquisition_record_digest",
|
|
ProvenanceInputRole.CONFIGURATION: "configuration_digest",
|
|
ProvenanceInputRole.POPULATION: "population_digest",
|
|
ProvenanceInputRole.CHANGE_INPUT: "change_input_digest",
|
|
}
|
|
|
|
|
|
def _index_inputs(inputs: tuple[NamedInput, ...]) -> dict[ProvenanceInputRole, NamedInput]:
|
|
indexed: dict[ProvenanceInputRole, NamedInput] = {}
|
|
for item in inputs:
|
|
if not isinstance(item.role, ProvenanceInputRole):
|
|
raise ProvenanceValidationError(f"unknown named input role: {item.role!r}")
|
|
if item.role in indexed:
|
|
raise ProvenanceValidationError(f"duplicate named input role: {item.role.value}")
|
|
indexed[item.role] = item
|
|
missing = set(ProvenanceInputRole) - set(indexed)
|
|
if missing:
|
|
names = ", ".join(sorted(role.value for role in missing))
|
|
raise ProvenanceValidationError(f"missing named input roles: {names}")
|
|
return indexed
|
|
|
|
|
|
def _validate_policy(trust: ProvenanceTrustPolicy) -> None:
|
|
expected = derive_trust_policy_digest(trust.trusted_store_identities, trust.maximum_input_bytes)
|
|
if trust.policy_digest != expected:
|
|
raise ProvenanceValidationError("provenance trust policy digest cannot be re-derived")
|
|
|
|
|
|
def _validate_manifest(named_inputs: NamedInputs) -> None:
|
|
expected = derive_named_inputs_digest(named_inputs.inputs)
|
|
if named_inputs.manifest_digest != expected:
|
|
raise ProvenanceValidationError("named input manifest digest cannot be re-derived")
|
|
|
|
|
|
def validate_provenance(
|
|
stamp: ProvenanceStamp,
|
|
named_inputs: NamedInputs,
|
|
resolver: ContentResolver,
|
|
trust: ProvenanceTrustPolicy,
|
|
) -> None:
|
|
"""Resolve every closed-role input and reproduce each immutable stamp identity."""
|
|
_validate_policy(trust)
|
|
indexed = _index_inputs(named_inputs.inputs)
|
|
_validate_manifest(named_inputs)
|
|
mismatches: list[str] = []
|
|
for role in ProvenanceInputRole:
|
|
item = indexed[role]
|
|
canonical.resolve_content(
|
|
item.content,
|
|
resolver,
|
|
trusted_store_identities=trust.trusted_store_identities,
|
|
maximum_bytes=trust.maximum_input_bytes,
|
|
)
|
|
if item.identity != item.content.content_digest:
|
|
raise ProvenanceValidationError(f"{role.value} named identity does not match resolved content")
|
|
field_name = _STAMP_FIELD[role]
|
|
if getattr(stamp, field_name) != item.identity:
|
|
mismatches.append(field_name)
|
|
if mismatches:
|
|
raise ProvenanceValidationError(f"stamp identities do not match named inputs: {', '.join(mismatches)}")
|
|
|
|
|
|
__all__ = [
|
|
"DecisionProvenance",
|
|
"ModelCallIdentity",
|
|
"NAMED_INPUTS_SCHEMA",
|
|
"NamedInput",
|
|
"NamedInputs",
|
|
"PROVENANCE_POLICY_SCHEMA",
|
|
"ProvenanceInputRole",
|
|
"ProvenanceMismatch",
|
|
"ProvenanceStamp",
|
|
"ProvenanceTrustPolicy",
|
|
"ProvenanceValidationError",
|
|
"derive_named_inputs_digest",
|
|
"derive_trust_policy_digest",
|
|
"stamp",
|
|
"validate_provenance",
|
|
"verify_stamp",
|
|
]
|