From d93df5e20d87211a2b4ef573492c56807601d739 Mon Sep 17 00:00:00 2001 From: stacknil Date: Fri, 18 Sep 2026 16:11:51 +0800 Subject: [PATCH 01/12] feat(security): add mock signer authority core --- signer/repo_sentinel_signer/__init__.py | 62 +++ signer/repo_sentinel_signer/model.py | 558 +++++++++++++++++++++ signer/repo_sentinel_signer/oidc.py | 41 ++ signer/repo_sentinel_signer/publisher.py | 115 +++++ signer/repo_sentinel_signer/registry.py | 106 ++++ signer/repo_sentinel_signer/service.py | 598 +++++++++++++++++++++++ signer/repo_sentinel_signer/store.py | 190 +++++++ 7 files changed, 1670 insertions(+) create mode 100644 signer/repo_sentinel_signer/__init__.py create mode 100644 signer/repo_sentinel_signer/model.py create mode 100644 signer/repo_sentinel_signer/oidc.py create mode 100644 signer/repo_sentinel_signer/publisher.py create mode 100644 signer/repo_sentinel_signer/registry.py create mode 100644 signer/repo_sentinel_signer/service.py create mode 100644 signer/repo_sentinel_signer/store.py diff --git a/signer/repo_sentinel_signer/__init__.py b/signer/repo_sentinel_signer/__init__.py new file mode 100644 index 0000000..c2f9dde --- /dev/null +++ b/signer/repo_sentinel_signer/__init__.py @@ -0,0 +1,62 @@ +"""Mock-only reference core for the Repo Sentinel signer v1 protocol.""" + +from .model import ( + GITHUB_OIDC_ISSUER, + SIGNER_AUDIENCE, + SIGNER_PROTOCOL, + EvaluationRecord, + EvaluationState, + FinalizationResult, + PublicationPayload, + PublicationReceipt, + PublicationSlot, + PublicationSlotState, + PullRequestSnapshot, + RegistryRecord, + SignerRefused, + TicketRequest, + TicketResponse, + VerifiedOidcClaims, +) +from .oidc import MockOidcVerifier, OidcVerifier +from .publisher import ( + MockPublisher, + MockPublishMode, + Publisher, + PublishDisposition, + PublishOutcome, +) +from .registry import InMemoryRegistry +from .service import MockPullRequestReader, PullRequestReader, SignerService +from .store import InMemoryEvaluationStore + +__all__ = [ + "GITHUB_OIDC_ISSUER", + "SIGNER_AUDIENCE", + "SIGNER_PROTOCOL", + "EvaluationRecord", + "EvaluationState", + "FinalizationResult", + "InMemoryEvaluationStore", + "InMemoryRegistry", + "MockOidcVerifier", + "MockPublishMode", + "MockPublisher", + "MockPullRequestReader", + "OidcVerifier", + "PublicationPayload", + "PublicationReceipt", + "PublicationSlot", + "PublicationSlotState", + "PublishDisposition", + "PublishOutcome", + "Publisher", + "PullRequestReader", + "PullRequestSnapshot", + "RegistryRecord", + "SignerRefused", + "SignerService", + "TicketRequest", + "TicketResponse", + "VerifiedOidcClaims", +] diff --git a/signer/repo_sentinel_signer/model.py b/signer/repo_sentinel_signer/model.py new file mode 100644 index 0000000..186b3ce --- /dev/null +++ b/signer/repo_sentinel_signer/model.py @@ -0,0 +1,558 @@ +"""Immutable values and strict boundary validation for signer v1.""" + +from __future__ import annotations + +import hashlib +import json +import re +from collections.abc import Mapping +from dataclasses import dataclass +from enum import Enum + +SIGNER_PROTOCOL = "repo-sentinel-signer-v1" +GITHUB_OIDC_ISSUER = "https://token.actions.githubusercontent.com" +SIGNER_AUDIENCE = "repo-sentinel-authoritative-signer-v1" + +_DIGEST = re.compile(r"[0-9a-f]{64}\Z") +_OID = re.compile(r"(?:[0-9a-f]{40}|[0-9a-f]{64})\Z") +_CANONICAL_DECIMAL = re.compile(r"(?:0|[1-9][0-9]*)\Z") +_PUBLICATION_DOMAIN = b"repo-sentinel-signer-publication-v1\0" + + +class SignerRefused(RuntimeError): + """Fail-closed signer refusal with a stable machine-readable code.""" + + def __init__(self, code: str) -> None: + self.code = code + super().__init__(code) + + +def require_string(value: object, field: str, *, maximum: int = 512) -> str: + if type(value) is not str or not value or len(value) > maximum: + raise SignerRefused(f"invalid_{field}") + if value != value.strip() or any(ord(character) < 0x20 for character in value): + raise SignerRefused(f"invalid_{field}") + return value + + +def require_digest(value: object, field: str) -> str: + if type(value) is not str or _DIGEST.fullmatch(value) is None: + raise SignerRefused(f"invalid_{field}") + return value + + +def require_oid(value: object, field: str) -> str: + if type(value) is not str or _OID.fullmatch(value) is None: + raise SignerRefused(f"invalid_{field}") + return value + + +def require_positive_int(value: object, field: str) -> int: + if type(value) is not int or value <= 0: + raise SignerRefused(f"invalid_{field}") + return value + + +def parse_canonical_decimal(value: object, field: str, *, positive: bool = True) -> int: + if type(value) is int: + parsed = value + elif type(value) is str and _CANONICAL_DECIMAL.fullmatch(value) is not None: + parsed = int(value) + else: + raise SignerRefused(f"invalid_{field}") + if parsed < 0 or (positive and parsed == 0): + raise SignerRefused(f"invalid_{field}") + return parsed + + +def _require_timestamp(value: object, field: str) -> int: + if type(value) is not int or value < 0: + raise SignerRefused(f"invalid_{field}") + return value + + +@dataclass(frozen=True, slots=True) +class VerifiedOidcClaims: + """Claims returned only after an OIDC verifier establishes authenticity.""" + + iss: str + aud: str + sub: str + jti: str + iat: int + nbf: int + exp: int + repository: str + repository_id: int + repository_owner: str + repository_owner_id: int + event_name: str + runner_environment: str + workflow_ref: str + workflow_sha: str + run_id: int + run_attempt: int + job_workflow_ref: str | None = None + job_workflow_sha: str | None = None + + def __post_init__(self) -> None: + for field in ( + "iss", + "aud", + "sub", + "jti", + "repository", + "repository_owner", + "event_name", + "runner_environment", + "workflow_ref", + ): + require_string(getattr(self, field), field) + require_oid(self.workflow_sha, "workflow_sha") + require_positive_int(self.repository_id, "repository_id") + require_positive_int(self.repository_owner_id, "repository_owner_id") + require_positive_int(self.run_id, "run_id") + require_positive_int(self.run_attempt, "run_attempt") + _require_timestamp(self.iat, "iat") + _require_timestamp(self.nbf, "nbf") + _require_timestamp(self.exp, "exp") + if self.nbf > self.exp: + raise SignerRefused("invalid_oidc_time_window") + paired = (self.job_workflow_ref is None, self.job_workflow_sha is None) + if paired[0] != paired[1]: + raise SignerRefused("invalid_job_workflow_identity") + if self.job_workflow_ref is not None: + require_string(self.job_workflow_ref, "job_workflow_ref") + require_oid(self.job_workflow_sha, "job_workflow_sha") + + @classmethod + def from_verified_mapping(cls, value: object) -> VerifiedOidcClaims: + required = { + "iss", + "aud", + "sub", + "jti", + "iat", + "nbf", + "exp", + "repository", + "repository_id", + "repository_owner", + "repository_owner_id", + "event_name", + "runner_environment", + "workflow_ref", + "workflow_sha", + "run_id", + "run_attempt", + } + optional = {"job_workflow_ref", "job_workflow_sha"} + if type(value) is not dict: + raise SignerRefused("invalid_oidc_claims") + keys = set(value) + if not required.issubset(keys) or not keys.issubset(required | optional): + raise SignerRefused("invalid_oidc_claims") + if bool(keys & optional) and not optional.issubset(keys): + raise SignerRefused("invalid_oidc_claims") + claims = dict(value) + return cls( + iss=require_string(claims["iss"], "iss"), + aud=require_string(claims["aud"], "aud"), + sub=require_string(claims["sub"], "sub"), + jti=require_string(claims["jti"], "jti"), + iat=_require_timestamp(claims["iat"], "iat"), + nbf=_require_timestamp(claims["nbf"], "nbf"), + exp=_require_timestamp(claims["exp"], "exp"), + repository=require_string(claims["repository"], "repository"), + repository_id=parse_canonical_decimal( + claims["repository_id"], "repository_id" + ), + repository_owner=require_string( + claims["repository_owner"], "repository_owner" + ), + repository_owner_id=parse_canonical_decimal( + claims["repository_owner_id"], "repository_owner_id" + ), + event_name=require_string(claims["event_name"], "event_name"), + runner_environment=require_string( + claims["runner_environment"], "runner_environment" + ), + workflow_ref=require_string(claims["workflow_ref"], "workflow_ref"), + workflow_sha=require_oid(claims["workflow_sha"], "workflow_sha"), + run_id=parse_canonical_decimal(claims["run_id"], "run_id"), + run_attempt=parse_canonical_decimal(claims["run_attempt"], "run_attempt"), + job_workflow_ref=( + require_string(claims["job_workflow_ref"], "job_workflow_ref") + if "job_workflow_ref" in claims + else None + ), + job_workflow_sha=( + require_oid(claims["job_workflow_sha"], "job_workflow_sha") + if "job_workflow_sha" in claims + else None + ), + ) + + def execution_tuple(self) -> tuple[object, ...]: + return ( + self.repository_id, + self.repository_owner_id, + self.workflow_ref, + self.workflow_sha, + self.job_workflow_ref, + self.job_workflow_sha, + self.run_id, + self.run_attempt, + ) + + +@dataclass(frozen=True, slots=True) +class PullRequestSnapshot: + repository_id: int + pull_number: int + state: str + head_oid: str + base_ref: str + + def __post_init__(self) -> None: + require_positive_int(self.repository_id, "repository_id") + require_positive_int(self.pull_number, "pull_number") + if type(self.state) is not str or self.state not in {"open", "closed"}: + raise SignerRefused("invalid_pull_request_state") + require_oid(self.head_oid, "head_oid") + require_string(self.base_ref, "base_ref") + + +@dataclass(frozen=True, slots=True) +class RegistryRecord: + record_id: str + revision: int + repository_id: int + repository_owner_id: int + repository: str + policy_selector: str + policy_epoch: str + policy_bundle_sha256: str + controller_protocol: str + controller_schema_version: int + scanner_distribution: str + scanner_version: str + scanner_artifact_sha256: str + workflow_ref: str + workflow_sha: str + status_context: str + publisher_identity: str + job_workflow_ref: str | None = None + job_workflow_sha: str | None = None + + def __post_init__(self) -> None: + for field in ( + "record_id", + "repository", + "policy_selector", + "policy_epoch", + "controller_protocol", + "scanner_distribution", + "scanner_version", + "workflow_ref", + "status_context", + "publisher_identity", + ): + require_string(getattr(self, field), field) + require_positive_int(self.revision, "revision") + require_positive_int(self.repository_id, "repository_id") + require_positive_int(self.repository_owner_id, "repository_owner_id") + require_positive_int( + self.controller_schema_version, "controller_schema_version" + ) + require_digest(self.policy_bundle_sha256, "policy_bundle_sha256") + require_digest(self.scanner_artifact_sha256, "scanner_artifact_sha256") + require_oid(self.workflow_sha, "workflow_sha") + if len(self.status_context) > 100: + raise SignerRefused("invalid_status_context") + owner, separator, name = self.repository.partition("/") + if not separator or not owner or not name or "/" in name: + raise SignerRefused("invalid_repository") + paired = (self.job_workflow_ref is None, self.job_workflow_sha is None) + if paired[0] != paired[1]: + raise SignerRefused("invalid_job_workflow_identity") + if self.job_workflow_ref is not None: + require_string(self.job_workflow_ref, "job_workflow_ref") + require_oid(self.job_workflow_sha, "job_workflow_sha") + + @property + def key(self) -> tuple[str, int]: + return self.record_id, self.revision + + +@dataclass(frozen=True, slots=True) +class TicketRequest: + pull_number: int + + def __post_init__(self) -> None: + require_positive_int(self.pull_number, "pull_number") + + +@dataclass(frozen=True, slots=True) +class TicketResponse: + evaluation_id: str + repository_id: int + pull_number: int + head_oid: str + policy_selector: str + policy_epoch: str + expected_policy_bundle_sha256: str + controller_protocol: str + controller_schema_version: int + scanner_distribution: str + scanner_version: str + scanner_artifact_sha256: str + expires_at: int + + def __post_init__(self) -> None: + require_string(self.evaluation_id, "evaluation_id") + require_positive_int(self.repository_id, "repository_id") + require_positive_int(self.pull_number, "pull_number") + require_oid(self.head_oid, "head_oid") + for field in ( + "policy_selector", + "policy_epoch", + "controller_protocol", + "scanner_distribution", + "scanner_version", + ): + require_string(getattr(self, field), field) + require_digest( + self.expected_policy_bundle_sha256, + "expected_policy_bundle_sha256", + ) + require_positive_int( + self.controller_schema_version, + "controller_schema_version", + ) + require_digest(self.scanner_artifact_sha256, "scanner_artifact_sha256") + _require_timestamp(self.expires_at, "expires_at") + + +class EvaluationState(str, Enum): + ISSUED = "ISSUED" + RETRYABLE = "RETRYABLE" + UNKNOWN = "UNKNOWN" + PUBLISHED = "PUBLISHED" + + +@dataclass(frozen=True, slots=True) +class EvaluationRecord: + evaluation_id: str + registry_record_id: str + registry_revision: int + repository_id: int + repository_owner_id: int + pull_number: int + head_oid: str + policy_selector: str + policy_epoch: str + expected_policy_bundle_sha256: str + controller_protocol: str + controller_schema_version: int + scanner_distribution: str + scanner_version: str + scanner_artifact_sha256: str + workflow_ref: str + workflow_sha: str + job_workflow_ref: str | None + job_workflow_sha: str | None + run_id: int + run_attempt: int + admission_jti: str + issued_at: int + expires_at: int + finalization_state: EvaluationState = EvaluationState.ISSUED + + def __post_init__(self) -> None: + for field in ( + "evaluation_id", + "registry_record_id", + "policy_selector", + "policy_epoch", + "controller_protocol", + "scanner_distribution", + "scanner_version", + "workflow_ref", + "admission_jti", + ): + require_string(getattr(self, field), field) + for field in ( + "registry_revision", + "repository_id", + "repository_owner_id", + "pull_number", + "controller_schema_version", + "run_id", + "run_attempt", + ): + require_positive_int(getattr(self, field), field) + require_oid(self.head_oid, "head_oid") + require_digest( + self.expected_policy_bundle_sha256, + "expected_policy_bundle_sha256", + ) + require_digest(self.scanner_artifact_sha256, "scanner_artifact_sha256") + require_oid(self.workflow_sha, "workflow_sha") + paired = (self.job_workflow_ref is None, self.job_workflow_sha is None) + if paired[0] != paired[1]: + raise SignerRefused("invalid_job_workflow_identity") + if self.job_workflow_ref is not None: + require_string(self.job_workflow_ref, "job_workflow_ref") + require_oid(self.job_workflow_sha, "job_workflow_sha") + _require_timestamp(self.issued_at, "issued_at") + _require_timestamp(self.expires_at, "expires_at") + if self.expires_at <= self.issued_at: + raise SignerRefused("invalid_evaluation_window") + if type(self.finalization_state) is not EvaluationState: + raise SignerRefused("invalid_evaluation_state") + + @property + def registry_key(self) -> tuple[str, int]: + return self.registry_record_id, self.registry_revision + + @property + def execution_tuple(self) -> tuple[object, ...]: + return ( + self.repository_id, + self.repository_owner_id, + self.workflow_ref, + self.workflow_sha, + self.job_workflow_ref, + self.job_workflow_sha, + self.run_id, + self.run_attempt, + ) + + +@dataclass(frozen=True, slots=True) +class PublicationPayload: + repository_id: int + head_oid: str + context: str + state: str + description: str + target_url: None = None + + def __post_init__(self) -> None: + require_positive_int(self.repository_id, "repository_id") + require_oid(self.head_oid, "head_oid") + require_string(self.context, "status_context", maximum=100) + if type(self.state) is not str or self.state not in {"success", "failure"}: + raise SignerRefused("invalid_publication_state") + require_string(self.description, "description", maximum=140) + if self.target_url is not None: + raise SignerRefused("invalid_target_url") + + def canonical_digest(self) -> str: + encoded = json.dumps( + { + "context": self.context, + "description": self.description, + "head_oid": self.head_oid, + "repository_id": self.repository_id, + "state": self.state, + "target_url": self.target_url, + }, + ensure_ascii=True, + sort_keys=True, + separators=(",", ":"), + ).encode("ascii") + return hashlib.sha256( + _PUBLICATION_DOMAIN + len(encoded).to_bytes(8, "big") + encoded + ).hexdigest() + + +@dataclass(frozen=True, slots=True) +class PublicationReceipt: + provider_record_id: str + payload_sha256: str + + def __post_init__(self) -> None: + require_string(self.provider_record_id, "provider_record_id") + require_digest(self.payload_sha256, "payload_sha256") + + +class PublicationSlotState(str, Enum): + RESERVED = "RESERVED" + UNKNOWN = "UNKNOWN" + PUBLISHED = "PUBLISHED" + + +@dataclass(frozen=True, slots=True) +class PublicationSlot: + repository_id: int + head_oid: str + policy_epoch: str + payload: PublicationPayload + payload_sha256: str + state: PublicationSlotState + receipt: PublicationReceipt | None = None + + def __post_init__(self) -> None: + require_positive_int(self.repository_id, "repository_id") + require_oid(self.head_oid, "head_oid") + require_string(self.policy_epoch, "policy_epoch") + if type(self.payload) is not PublicationPayload: + raise SignerRefused("invalid_publication_payload") + if ( + self.payload.repository_id != self.repository_id + or self.payload.head_oid != self.head_oid + ): + raise SignerRefused("publication_slot_binding_mismatch") + require_digest(self.payload_sha256, "payload_sha256") + if self.payload_sha256 != self.payload.canonical_digest(): + raise SignerRefused("publication_slot_digest_mismatch") + if type(self.state) is not PublicationSlotState: + raise SignerRefused("invalid_publication_slot_state") + if self.state is PublicationSlotState.PUBLISHED: + if type(self.receipt) is not PublicationReceipt: + raise SignerRefused("invalid_publication_receipt") + if self.receipt.payload_sha256 != self.payload_sha256: + raise SignerRefused("publication_receipt_mismatch") + elif self.receipt is not None: + raise SignerRefused("unexpected_publication_receipt") + + @property + def key(self) -> tuple[int, str, str]: + return self.repository_id, self.head_oid, self.policy_epoch + + +@dataclass(frozen=True, slots=True) +class FinalizationResult: + evaluation_id: str + evaluation_state: EvaluationState + slot_state: PublicationSlotState | None + receipt: PublicationReceipt | None + retryable: bool + + def __post_init__(self) -> None: + require_string(self.evaluation_id, "evaluation_id") + if type(self.evaluation_state) is not EvaluationState: + raise SignerRefused("invalid_evaluation_state") + if ( + self.slot_state is not None + and type(self.slot_state) is not PublicationSlotState + ): + raise SignerRefused("invalid_publication_slot_state") + if self.receipt is not None and type(self.receipt) is not PublicationReceipt: + raise SignerRefused("invalid_publication_receipt") + if type(self.retryable) is not bool: + raise SignerRefused("invalid_retryable") + + +def exact_mapping(value: object, keys: set[str], code: str) -> dict[str, object]: + if type(value) is not dict or set(value) != keys: + raise SignerRefused(code) + return dict(value) + + +def copy_claim_mapping(value: Mapping[str, object]) -> dict[str, object]: + if type(value) is not dict: + raise SignerRefused("invalid_oidc_claims") + return dict(value) diff --git a/signer/repo_sentinel_signer/oidc.py b/signer/repo_sentinel_signer/oidc.py new file mode 100644 index 0000000..2e58855 --- /dev/null +++ b/signer/repo_sentinel_signer/oidc.py @@ -0,0 +1,41 @@ +"""OIDC verification abstraction and deterministic mock implementation.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Protocol + +from .model import ( + SignerRefused, + VerifiedOidcClaims, + copy_claim_mapping, + require_string, +) + + +class OidcVerifier(Protocol): + def verify(self, raw_token: str, expected_audience: str) -> VerifiedOidcClaims: ... + + +class MockOidcVerifier: + """Return pre-registered claims; this class performs no cryptography.""" + + def __init__(self, tokens: Mapping[str, Mapping[str, object]]) -> None: + self._tokens: dict[str, dict[str, object]] = {} + for token, claims in tokens.items(): + key = require_string(token, "raw_token") + if key in self._tokens: + raise SignerRefused("duplicate_mock_token") + self._tokens[key] = copy_claim_mapping(claims) + + def verify(self, raw_token: str, expected_audience: str) -> VerifiedOidcClaims: + token = require_string(raw_token, "raw_token") + audience = require_string(expected_audience, "expected_audience") + try: + raw_claims = self._tokens[token] + except KeyError: + raise SignerRefused("oidc_verification_failed") from None + claims = VerifiedOidcClaims.from_verified_mapping(raw_claims) + if claims.aud != audience: + raise SignerRefused("oidc_audience_mismatch") + return claims diff --git a/signer/repo_sentinel_signer/publisher.py b/signer/repo_sentinel_signer/publisher.py new file mode 100644 index 0000000..6e8f678 --- /dev/null +++ b/signer/repo_sentinel_signer/publisher.py @@ -0,0 +1,115 @@ +"""Publication abstraction and deterministic mock Commit Status publisher.""" + +from __future__ import annotations + +import threading +from dataclasses import dataclass +from enum import Enum +from typing import Protocol + +from .model import PublicationPayload, PublicationReceipt, SignerRefused, require_string + + +class PublishDisposition(str, Enum): + PUBLISHED = "PUBLISHED" + DEFINITE_FAILURE = "DEFINITE_FAILURE" + UNKNOWN = "UNKNOWN" + + +@dataclass(frozen=True, slots=True) +class PublishOutcome: + disposition: PublishDisposition + receipt: PublicationReceipt | None = None + + def __post_init__(self) -> None: + if type(self.disposition) is not PublishDisposition: + raise SignerRefused("invalid_publish_disposition") + if self.disposition is PublishDisposition.PUBLISHED: + if type(self.receipt) is not PublicationReceipt: + raise SignerRefused("invalid_publication_receipt") + elif self.receipt is not None: + raise SignerRefused("unexpected_publication_receipt") + + +class Publisher(Protocol): + @property + def identity(self) -> str: ... + + def publish(self, payload: PublicationPayload) -> PublishOutcome: ... + + def lookup(self, payload: PublicationPayload) -> PublicationReceipt | None: ... + + +class MockPublishMode(str, Enum): + PUBLISHED = "PUBLISHED" + DEFINITE_FAILURE = "DEFINITE_FAILURE" + UNKNOWN_BEFORE_WRITE = "UNKNOWN_BEFORE_WRITE" + UNKNOWN_AFTER_WRITE = "UNKNOWN_AFTER_WRITE" + + +class MockPublisher: + """Exercise definite, uncertain-before, and uncertain-after publication.""" + + def __init__( + self, + identity: str, + outcomes: list[MockPublishMode] | None = None, + ) -> None: + self._identity = require_string(identity, "publisher_identity") + self._outcomes = list(outcomes or []) + if not all(type(outcome) is MockPublishMode for outcome in self._outcomes): + raise SignerRefused("invalid_mock_publish_mode") + self._lock = threading.Lock() + self._calls: list[PublicationPayload] = [] + self._visible: dict[tuple[int, str, str], PublicationReceipt] = {} + self._counter = 0 + + @property + def identity(self) -> str: + return self._identity + + @property + def calls(self) -> tuple[PublicationPayload, ...]: + with self._lock: + return tuple(self._calls) + + def _receipt(self, payload: PublicationPayload) -> PublicationReceipt: + key = payload.repository_id, payload.head_oid, payload.context + digest = payload.canonical_digest() + existing = self._visible.get(key) + if existing is not None: + if existing.payload_sha256 != digest: + raise SignerRefused("mock_publisher_payload_conflict") + return existing + self._counter += 1 + receipt = PublicationReceipt(f"mock-status-{self._counter}", digest) + self._visible[key] = receipt + return receipt + + def publish(self, payload: PublicationPayload) -> PublishOutcome: + if type(payload) is not PublicationPayload: + raise SignerRefused("invalid_publication_payload") + with self._lock: + self._calls.append(payload) + mode = ( + self._outcomes.pop(0) if self._outcomes else MockPublishMode.PUBLISHED + ) + if mode is MockPublishMode.PUBLISHED: + return PublishOutcome( + PublishDisposition.PUBLISHED, self._receipt(payload) + ) + if mode is MockPublishMode.DEFINITE_FAILURE: + return PublishOutcome(PublishDisposition.DEFINITE_FAILURE) + if mode is MockPublishMode.UNKNOWN_AFTER_WRITE: + self._receipt(payload) + return PublishOutcome(PublishDisposition.UNKNOWN) + + def lookup(self, payload: PublicationPayload) -> PublicationReceipt | None: + if type(payload) is not PublicationPayload: + raise SignerRefused("invalid_publication_payload") + with self._lock: + key = payload.repository_id, payload.head_oid, payload.context + receipt = self._visible.get(key) + if receipt is None or receipt.payload_sha256 != payload.canonical_digest(): + return None + return receipt diff --git a/signer/repo_sentinel_signer/registry.py b/signer/repo_sentinel_signer/registry.py new file mode 100644 index 0000000..fc9b68a --- /dev/null +++ b/signer/repo_sentinel_signer/registry.py @@ -0,0 +1,106 @@ +"""Immutable registry records and explicit activation pointers.""" + +from __future__ import annotations + +import threading + +from .model import RegistryRecord, SignerRefused, require_positive_int, require_string + +RegistryKey = tuple[str, int] + + +class InMemoryRegistry: + """Thread-safe reference registry with no implicit latest-record lookup.""" + + def __init__(self, *, lock: threading.RLock | None = None) -> None: + self._lock = lock or threading.RLock() + self._records: dict[RegistryKey, RegistryRecord] = {} + self._epoch_digests: dict[tuple[int, str], str] = {} + self._active: dict[tuple[int, str], object] = {} + self._revoked: set[RegistryKey] = set() + + @property + def lock(self) -> threading.RLock: + return self._lock + + def add(self, record: RegistryRecord) -> RegistryRecord: + if type(record) is not RegistryRecord: + raise SignerRefused("invalid_registry_record") + with self._lock: + existing = self._records.get(record.key) + if existing is not None: + if existing != record: + raise SignerRefused("registry_record_mutation") + return existing + for candidate in self._records.values(): + if ( + candidate.record_id == record.record_id + and candidate.repository_id != record.repository_id + ): + raise SignerRefused("registry_record_identity_conflict") + epoch_key = record.repository_id, record.policy_epoch + approved_digest = self._epoch_digests.get(epoch_key) + if ( + approved_digest is not None + and approved_digest != record.policy_bundle_sha256 + ): + raise SignerRefused("registry_epoch_digest_conflict") + self._records[record.key] = record + self._epoch_digests.setdefault(epoch_key, record.policy_bundle_sha256) + return record + + def get(self, key: RegistryKey) -> RegistryRecord: + with self._lock: + if type(key) is not tuple or len(key) != 2: + raise SignerRefused("invalid_registry_key") + record_id, revision = key + require_string(record_id, "record_id") + require_positive_int(revision, "revision") + try: + return self._records[key] + except KeyError: + raise SignerRefused("registry_record_not_found") from None + + def activate( + self, repository_id: int, authority_slot: str, key: RegistryKey + ) -> RegistryRecord: + require_positive_int(repository_id, "repository_id") + slot = require_string(authority_slot, "authority_slot") + with self._lock: + record = self.get(key) + if record.repository_id != repository_id: + raise SignerRefused("registry_repository_mismatch") + if key in self._revoked: + raise SignerRefused("registry_record_revoked") + self._active[(repository_id, slot)] = key + return record + + def resolve_active(self, repository_id: int, authority_slot: str) -> RegistryRecord: + require_positive_int(repository_id, "repository_id") + slot = require_string(authority_slot, "authority_slot") + with self._lock: + pointer = self._active.get((repository_id, slot)) + if ( + type(pointer) is not tuple + or len(pointer) != 2 + or type(pointer[0]) is not str + or type(pointer[1]) is not int + ): + raise SignerRefused("registry_activation_ambiguous") + record = self.get(pointer) + if record.key in self._revoked: + raise SignerRefused("registry_record_revoked") + return record + + def revoke(self, key: RegistryKey) -> None: + with self._lock: + self.get(key) + self._revoked.add(key) + + def is_revoked(self, key: RegistryKey) -> bool: + with self._lock: + self.get(key) + return key in self._revoked + + def resolve_latest(self, *_args: object, **_kwargs: object) -> RegistryRecord: + raise SignerRefused("unsupported_registry_lookup") diff --git a/signer/repo_sentinel_signer/service.py b/signer/repo_sentinel_signer/service.py new file mode 100644 index 0000000..de540f1 --- /dev/null +++ b/signer/repo_sentinel_signer/service.py @@ -0,0 +1,598 @@ +"""Transport-neutral signer admission, finalization, and publication state machine.""" + +from __future__ import annotations + +import hashlib +import json +import threading +from collections.abc import Callable, Mapping +from typing import Protocol + +from .model import ( + GITHUB_OIDC_ISSUER, + SIGNER_AUDIENCE, + EvaluationRecord, + EvaluationState, + FinalizationResult, + PublicationPayload, + PublicationSlotState, + PullRequestSnapshot, + RegistryRecord, + SignerRefused, + TicketRequest, + TicketResponse, + VerifiedOidcClaims, + exact_mapping, + require_digest, + require_oid, + require_positive_int, + require_string, +) +from .publisher import PublishDisposition, Publisher, PublishOutcome +from .registry import InMemoryRegistry +from .store import InMemoryEvaluationStore + +Clock = Callable[[], int] +EvaluationIdGenerator = Callable[[], str] + +_CONTROLLER_KEYS = frozenset( + { + "controller_outcome", + "controller_protocol", + "controller_schema_version", + "fixed_refusal_code", + "head_oid", + "policy_bundle_sha256", + "policy_epoch", + "policy_selector", + "repository_id", + "worker_result", + "worker_semantic_sha256", + } +) +_WORKER_KEYS = frozenset( + { + "coverage_policy_sha256", + "files_policy_excluded", + "files_scanned", + "files_scanner_skipped", + "files_total", + "head_oid", + "policy_bundle_sha256", + "policy_epoch", + "policy_schema_version", + "protected_manifest_sha256", + "refusal_code", + "report_sha256", + "report_size", + "repository_id", + "scanner_artifact_sha256", + "scanner_distribution", + "scanner_version", + "semantic_sha256", + "suppression_manifest_sha256", + "verdict", + } +) +_SEMANTIC_DOMAIN = b"repo-sentinel-commit-authority-result-v1\0" +_POLICY_REFUSALS = frozenset( + { + "coverage_policy_mismatch", + "policy_bundle_mirror_mismatch", + "protected_control_mismatch", + "suppression_manifest_mismatch", + } +) +_CONTROLLER_REFUSALS = frozenset( + { + "acquired_head_mismatch", + "acquisition_refused", + "cleanup_failed", + "environment_mismatch", + "git_identity_mismatch", + "invalid_head_oid", + "invalid_request", + "launch_not_isolated", + "policy_bundle_mismatch", + "repository_identity_mismatch", + "runtime_mismatch", + "unsafe_control_root", + "unsafe_root_layout", + "unexpected_failure", + "unknown_policy_epoch", + "worker_infrastructure_refusal", + "worker_result_invalid", + } +) +_SEMANTIC_VERDICTS = frozenset({"PASS", "SCANNER_FINDING", "POLICY_ADMISSION_FAILURE"}) + + +def _publication_fields(verdict: str) -> tuple[str, str]: + if verdict == "PASS": + return "success", "Repo Sentinel authority: PASS" + if verdict == "SCANNER_FINDING": + return "failure", "Repo Sentinel authority: SCANNER_FINDING" + if verdict == "POLICY_ADMISSION_FAILURE": + return "failure", "Repo Sentinel authority: POLICY_ADMISSION_FAILURE" + raise SignerRefused("controller_result_invalid") + + +class PullRequestReader(Protocol): + def read( + self, repository_id: int, pull_number: int + ) -> PullRequestSnapshot | None: ... + + +class MockPullRequestReader: + """Mutable test double whose returned snapshots remain immutable values.""" + + def __init__(self, snapshots: list[PullRequestSnapshot] | None = None) -> None: + self._lock = threading.Lock() + self._snapshots: dict[tuple[int, int], PullRequestSnapshot] = {} + for snapshot in snapshots or []: + self.set(snapshot) + + def set(self, snapshot: PullRequestSnapshot) -> None: + if type(snapshot) is not PullRequestSnapshot: + raise SignerRefused("invalid_pull_request_snapshot") + with self._lock: + self._snapshots[(snapshot.repository_id, snapshot.pull_number)] = snapshot + + def remove(self, repository_id: int, pull_number: int) -> None: + with self._lock: + self._snapshots.pop((repository_id, pull_number), None) + + def read(self, repository_id: int, pull_number: int) -> PullRequestSnapshot | None: + require_positive_int(repository_id, "repository_id") + require_positive_int(pull_number, "pull_number") + with self._lock: + return self._snapshots.get((repository_id, pull_number)) + + +def _valid_count(value: object) -> bool: + return type(value) is int and value >= 0 + + +def _worker_semantic_digest(payload: Mapping[str, object]) -> str: + semantic = { + key: value for key, value in payload.items() if key != "semantic_sha256" + } + encoded = json.dumps( + semantic, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return hashlib.sha256( + _SEMANTIC_DOMAIN + len(encoded).to_bytes(8, "big") + encoded + ).hexdigest() + + +class SignerService: + """Authorize evaluations and publish one immutable verdict per R/H/v slot.""" + + def __init__( + self, + *, + registry: InMemoryRegistry, + store: InMemoryEvaluationStore, + pull_requests: PullRequestReader, + publisher: Publisher, + clock: Clock, + evaluation_id_generator: EvaluationIdGenerator, + authority_slot: str = "authoritative", + evaluation_ttl_seconds: int = 900, + allowed_clock_skew_seconds: int = 30, + ) -> None: + if registry.lock is not store.lock: + raise SignerRefused("state_lock_mismatch") + if type(evaluation_ttl_seconds) is not int or evaluation_ttl_seconds <= 0: + raise SignerRefused("invalid_evaluation_ttl") + if ( + type(allowed_clock_skew_seconds) is not int + or allowed_clock_skew_seconds < 0 + ): + raise SignerRefused("invalid_clock_skew") + self._registry = registry + self._store = store + self._pull_requests = pull_requests + self._publisher = publisher + self._clock = clock + self._evaluation_id_generator = evaluation_id_generator + self._authority_slot = require_string(authority_slot, "authority_slot") + self._evaluation_ttl_seconds = evaluation_ttl_seconds + self._allowed_clock_skew_seconds = allowed_clock_skew_seconds + + def _now(self) -> int: + value = self._clock() + if type(value) is not int or value < 0: + raise SignerRefused("invalid_clock") + return value + + def _authorize_oidc( + self, + claims: VerifiedOidcClaims, + record: RegistryRecord, + now: int, + ) -> None: + if type(claims) is not VerifiedOidcClaims: + raise SignerRefused("unverified_oidc_claims") + if claims.iss != GITHUB_OIDC_ISSUER: + raise SignerRefused("oidc_issuer_mismatch") + if claims.aud != SIGNER_AUDIENCE: + raise SignerRefused("oidc_audience_mismatch") + if claims.nbf > now: + raise SignerRefused("oidc_not_yet_valid") + if now > claims.exp: + raise SignerRefused("oidc_expired") + if claims.iat > now + self._allowed_clock_skew_seconds: + raise SignerRefused("oidc_issued_in_future") + if claims.repository_id != record.repository_id: + raise SignerRefused("oidc_repository_mismatch") + if claims.repository_owner_id != record.repository_owner_id: + raise SignerRefused("oidc_owner_mismatch") + if claims.repository != record.repository: + raise SignerRefused("oidc_repository_mismatch") + if claims.repository_owner != record.repository.partition("/")[0]: + raise SignerRefused("oidc_owner_mismatch") + if claims.event_name != "pull_request_target": + raise SignerRefused("oidc_event_mismatch") + if claims.runner_environment != "github-hosted": + raise SignerRefused("oidc_runner_mismatch") + if ( + claims.workflow_ref != record.workflow_ref + or claims.workflow_sha != record.workflow_sha + ): + raise SignerRefused("oidc_workflow_mismatch") + if ( + claims.job_workflow_ref != record.job_workflow_ref + or claims.job_workflow_sha != record.job_workflow_sha + ): + raise SignerRefused("oidc_reusable_workflow_mismatch") + + def _read_open_pull( + self, repository_id: int, pull_number: int + ) -> PullRequestSnapshot: + snapshot = self._pull_requests.read(repository_id, pull_number) + if type(snapshot) is not PullRequestSnapshot: + raise SignerRefused("pull_request_unavailable") + if ( + snapshot.repository_id != repository_id + or snapshot.pull_number != pull_number + ): + raise SignerRefused("pull_request_identity_mismatch") + if snapshot.state != "open": + raise SignerRefused("pull_request_closed") + return snapshot + + def issue_evaluation( + self, + verified_oidc: VerifiedOidcClaims, + request: TicketRequest, + ) -> TicketResponse: + if type(request) is not TicketRequest: + raise SignerRefused("invalid_ticket_request") + now = self._now() + with self._store.transaction(): + if type(verified_oidc) is not VerifiedOidcClaims: + raise SignerRefused("unverified_oidc_claims") + record = self._registry.resolve_active( + verified_oidc.repository_id, self._authority_slot + ) + self._authorize_oidc(verified_oidc, record, now) + snapshot = self._read_open_pull(record.repository_id, request.pull_number) + idempotency_key = ( + record.repository_id, + verified_oidc.run_id, + verified_oidc.run_attempt, + request.pull_number, + snapshot.head_oid, + record.record_id, + record.revision, + ) + evaluation = EvaluationRecord( + evaluation_id=require_string( + self._evaluation_id_generator(), "evaluation_id" + ), + registry_record_id=record.record_id, + registry_revision=record.revision, + repository_id=record.repository_id, + repository_owner_id=record.repository_owner_id, + pull_number=request.pull_number, + head_oid=snapshot.head_oid, + policy_selector=record.policy_selector, + policy_epoch=record.policy_epoch, + expected_policy_bundle_sha256=record.policy_bundle_sha256, + controller_protocol=record.controller_protocol, + controller_schema_version=record.controller_schema_version, + scanner_distribution=record.scanner_distribution, + scanner_version=record.scanner_version, + scanner_artifact_sha256=record.scanner_artifact_sha256, + workflow_ref=record.workflow_ref, + workflow_sha=record.workflow_sha, + job_workflow_ref=record.job_workflow_ref, + job_workflow_sha=record.job_workflow_sha, + run_id=verified_oidc.run_id, + run_attempt=verified_oidc.run_attempt, + admission_jti=verified_oidc.jti, + issued_at=now, + expires_at=now + self._evaluation_ttl_seconds, + ) + stored, _created = self._store.issue(evaluation, idempotency_key) + return TicketResponse( + evaluation_id=stored.evaluation_id, + repository_id=stored.repository_id, + pull_number=stored.pull_number, + head_oid=stored.head_oid, + policy_selector=stored.policy_selector, + policy_epoch=stored.policy_epoch, + expected_policy_bundle_sha256=stored.expected_policy_bundle_sha256, + controller_protocol=stored.controller_protocol, + controller_schema_version=stored.controller_schema_version, + scanner_distribution=stored.scanner_distribution, + scanner_version=stored.scanner_version, + scanner_artifact_sha256=stored.scanner_artifact_sha256, + expires_at=stored.expires_at, + ) + + def _validate_worker( + self, + value: object, + evaluation: EvaluationRecord, + ) -> tuple[str, dict[str, object]]: + worker = exact_mapping(value, _WORKER_KEYS, "controller_result_invalid") + verdict = worker["verdict"] + if type(verdict) is not str or verdict not in _SEMANTIC_VERDICTS: + raise SignerRefused("controller_result_invalid") + if type(worker["policy_schema_version"]) is not int: + raise SignerRefused("controller_result_invalid") + if worker["policy_schema_version"] != 1: + raise SignerRefused("controller_result_invalid") + if type(worker["repository_id"]) is not int: + raise SignerRefused("controller_result_invalid") + if worker["repository_id"] != evaluation.repository_id: + raise SignerRefused("controller_result_binding_mismatch") + for key, expected in ( + ("head_oid", evaluation.head_oid), + ("policy_epoch", evaluation.policy_epoch), + ("policy_bundle_sha256", evaluation.expected_policy_bundle_sha256), + ("scanner_distribution", evaluation.scanner_distribution), + ("scanner_version", evaluation.scanner_version), + ("scanner_artifact_sha256", evaluation.scanner_artifact_sha256), + ): + actual = worker[key] + if type(actual) is not str or actual != expected: + raise SignerRefused("controller_result_binding_mismatch") + for key in ( + "policy_bundle_sha256", + "protected_manifest_sha256", + "suppression_manifest_sha256", + "coverage_policy_sha256", + "scanner_artifact_sha256", + "semantic_sha256", + ): + require_digest(worker[key], "controller_result") + report_digest = worker["report_sha256"] + if report_digest is not None: + require_digest(report_digest, "controller_result") + counts = [ + worker["files_total"], + worker["files_scanned"], + worker["files_policy_excluded"], + worker["files_scanner_skipped"], + worker["report_size"], + ] + if not all(_valid_count(count) for count in counts): + raise SignerRefused("controller_result_invalid") + refusal = worker["refusal_code"] + if verdict == "POLICY_ADMISSION_FAILURE": + if type(refusal) is not str or refusal not in _POLICY_REFUSALS: + raise SignerRefused("controller_result_invalid") + if ( + any(counts[1:4]) + or report_digest is not None + or worker["report_size"] != 0 + ): + raise SignerRefused("controller_result_invalid") + else: + if refusal is not None: + raise SignerRefused("controller_result_invalid") + if report_digest is None or worker["report_size"] == 0: + raise SignerRefused("controller_result_invalid") + if worker["files_total"] != sum(counts[1:4]): + raise SignerRefused("controller_result_invalid") + semantic = worker["semantic_sha256"] + if semantic != _worker_semantic_digest(worker): + raise SignerRefused("controller_result_invalid") + return verdict, worker + + def _parse_controller_result( + self, + value: object, + evaluation: EvaluationRecord, + record: RegistryRecord, + ) -> tuple[str, str | None]: + result = exact_mapping(value, _CONTROLLER_KEYS, "controller_result_invalid") + outcome = result["controller_outcome"] + if type(outcome) is not str or outcome not in { + "AUTHORITY_RESULT", + "INFRASTRUCTURE_REFUSAL", + }: + raise SignerRefused("controller_result_invalid") + if ( + type(result["controller_protocol"]) is not str + or result["controller_protocol"] != record.controller_protocol + or type(result["controller_schema_version"]) is not int + or result["controller_schema_version"] != record.controller_schema_version + or type(result["repository_id"]) is not int + or result["repository_id"] != evaluation.repository_id + ): + raise SignerRefused("controller_result_binding_mismatch") + for key, expected in ( + ("head_oid", evaluation.head_oid), + ("policy_selector", evaluation.policy_selector), + ("policy_epoch", evaluation.policy_epoch), + ("policy_bundle_sha256", evaluation.expected_policy_bundle_sha256), + ): + actual = result[key] + if type(actual) is not str or actual != expected: + raise SignerRefused("controller_result_binding_mismatch") + require_oid(result["head_oid"], "controller_result") + require_digest(result["policy_bundle_sha256"], "controller_result") + if outcome == "INFRASTRUCTURE_REFUSAL": + refusal = result["fixed_refusal_code"] + if type(refusal) is not str or refusal not in _CONTROLLER_REFUSALS: + raise SignerRefused("controller_result_invalid") + if ( + result["worker_result"] is not None + or result["worker_semantic_sha256"] is not None + ): + raise SignerRefused("controller_result_invalid") + return outcome, None + if result["fixed_refusal_code"] is not None: + raise SignerRefused("controller_result_invalid") + verdict, worker = self._validate_worker(result["worker_result"], evaluation) + worker_semantic = result["worker_semantic_sha256"] + require_digest(worker_semantic, "controller_result") + if worker_semantic != worker["semantic_sha256"]: + raise SignerRefused("controller_result_binding_mismatch") + return outcome, verdict + + def _payload( + self, + evaluation: EvaluationRecord, + record: RegistryRecord, + verdict: str, + ) -> PublicationPayload: + state, description = _publication_fields(verdict) + return PublicationPayload( + repository_id=evaluation.repository_id, + head_oid=evaluation.head_oid, + context=record.status_context, + state=state, + description=description, + target_url=None, + ) + + def finalize_evaluation( + self, + verified_oidc: VerifiedOidcClaims, + evaluation_id: str, + controller_result: object, + ) -> FinalizationResult: + now = self._now() + with self._store.transaction(): + evaluation = self._store.get(evaluation_id) + record = self._registry.get(evaluation.registry_key) + if self._registry.is_revoked(record.key): + raise SignerRefused("registry_record_revoked") + self._authorize_oidc(verified_oidc, record, now) + if verified_oidc.execution_tuple() != evaluation.execution_tuple: + raise SignerRefused("finalization_execution_mismatch") + if verified_oidc.jti == evaluation.admission_jti: + raise SignerRefused("fresh_oidc_required") + if now > evaluation.expires_at: + raise SignerRefused("evaluation_expired") + snapshot = self._read_open_pull( + evaluation.repository_id, evaluation.pull_number + ) + if snapshot.head_oid != evaluation.head_oid: + raise SignerRefused("pull_request_head_changed") + outcome, verdict = self._parse_controller_result( + controller_result, evaluation, record + ) + self._store.consume_finalization_jti(evaluation_id, verified_oidc.jti) + if outcome == "INFRASTRUCTURE_REFUSAL": + if evaluation.finalization_state is EvaluationState.PUBLISHED: + raise SignerRefused("evaluation_already_published") + retry_state = ( + EvaluationState.UNKNOWN + if evaluation.finalization_state is EvaluationState.UNKNOWN + else EvaluationState.RETRYABLE + ) + updated = self._store.set_state(evaluation_id, retry_state) + return FinalizationResult( + updated.evaluation_id, + updated.finalization_state, + None, + None, + True, + ) + assert verdict is not None + publisher_identity = self._publisher.identity + if ( + type(publisher_identity) is not str + or publisher_identity != record.publisher_identity + ): + raise SignerRefused("publisher_identity_mismatch") + payload = self._payload(evaluation, record, verdict) + slot = self._store.reserve_slot(payload, evaluation.policy_epoch) + if slot.state is PublicationSlotState.PUBLISHED: + updated = self._store.set_state( + evaluation_id, EvaluationState.PUBLISHED + ) + return FinalizationResult( + updated.evaluation_id, + updated.finalization_state, + slot.state, + slot.receipt, + False, + ) + if slot.state is PublicationSlotState.UNKNOWN: + receipt = self._publisher.lookup(slot.payload) + if receipt is not None: + slot = self._store.set_slot_state( + slot.key, PublicationSlotState.PUBLISHED, receipt + ) + updated = self._store.set_state( + evaluation_id, EvaluationState.PUBLISHED + ) + return FinalizationResult( + updated.evaluation_id, + updated.finalization_state, + slot.state, + slot.receipt, + False, + ) + published = self._publisher.publish(slot.payload) + if type(published) is not PublishOutcome: + raise SignerRefused("publisher_result_invalid") + if published.disposition is PublishDisposition.PUBLISHED: + slot = self._store.set_slot_state( + slot.key, PublicationSlotState.PUBLISHED, published.receipt + ) + updated = self._store.set_state( + evaluation_id, EvaluationState.PUBLISHED + ) + return FinalizationResult( + updated.evaluation_id, + updated.finalization_state, + slot.state, + slot.receipt, + False, + ) + if published.disposition is PublishDisposition.UNKNOWN: + slot = self._store.set_slot_state( + slot.key, PublicationSlotState.UNKNOWN + ) + updated = self._store.set_state(evaluation_id, EvaluationState.UNKNOWN) + return FinalizationResult( + updated.evaluation_id, + updated.finalization_state, + slot.state, + None, + True, + ) + retry_state = ( + EvaluationState.UNKNOWN + if evaluation.finalization_state is EvaluationState.UNKNOWN + else EvaluationState.RETRYABLE + ) + updated = self._store.set_state(evaluation_id, retry_state) + return FinalizationResult( + updated.evaluation_id, + updated.finalization_state, + slot.state, + None, + True, + ) diff --git a/signer/repo_sentinel_signer/store.py b/signer/repo_sentinel_signer/store.py new file mode 100644 index 0000000..e0c044e --- /dev/null +++ b/signer/repo_sentinel_signer/store.py @@ -0,0 +1,190 @@ +"""In-memory evaluation and publication state with one transaction lock.""" + +from __future__ import annotations + +import threading +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import replace + +from .model import ( + EvaluationRecord, + EvaluationState, + PublicationPayload, + PublicationReceipt, + PublicationSlot, + PublicationSlotState, + SignerRefused, + require_string, +) + +IdempotencyKey = tuple[int, int, int, int, str, str, int] +SlotKey = tuple[int, str, str] + + +class InMemoryEvaluationStore: + """Reference state engine; all compound operations use one shared lock.""" + + def __init__(self, *, lock: threading.RLock | None = None) -> None: + self._lock = lock or threading.RLock() + self._evaluations: dict[str, EvaluationRecord] = {} + self._idempotency: dict[IdempotencyKey, str] = {} + self._admission_jtis: dict[str, IdempotencyKey] = {} + self._finalization_jtis: dict[str, str] = {} + self._slots: dict[SlotKey, PublicationSlot] = {} + + @property + def lock(self) -> threading.RLock: + return self._lock + + @contextmanager + def transaction(self) -> Iterator[None]: + with self._lock: + yield + + def issue( + self, evaluation: EvaluationRecord, idempotency_key: IdempotencyKey + ) -> tuple[EvaluationRecord, bool]: + if type(evaluation) is not EvaluationRecord: + raise SignerRefused("invalid_evaluation_record") + if type(idempotency_key) is not tuple or len(idempotency_key) != 7: + raise SignerRefused("invalid_idempotency_key") + with self._lock: + existing_id = self._idempotency.get(idempotency_key) + if existing_id is not None: + previous_key = self._admission_jtis.get(evaluation.admission_jti) + if previous_key is not None and previous_key != idempotency_key: + raise SignerRefused("admission_jti_reused") + self._admission_jtis[evaluation.admission_jti] = idempotency_key + return self._evaluations[existing_id], False + previous_key = self._admission_jtis.get(evaluation.admission_jti) + if previous_key is not None and previous_key != idempotency_key: + raise SignerRefused("admission_jti_reused") + if evaluation.evaluation_id in self._evaluations: + raise SignerRefused("evaluation_id_collision") + self._evaluations[evaluation.evaluation_id] = evaluation + self._idempotency[idempotency_key] = evaluation.evaluation_id + self._admission_jtis[evaluation.admission_jti] = idempotency_key + return evaluation, True + + def get(self, evaluation_id: str) -> EvaluationRecord: + key = require_string(evaluation_id, "evaluation_id") + with self._lock: + try: + return self._evaluations[key] + except KeyError: + raise SignerRefused("evaluation_not_found") from None + + def set_state(self, evaluation_id: str, state: EvaluationState) -> EvaluationRecord: + if type(state) is not EvaluationState: + raise SignerRefused("invalid_evaluation_state") + with self._lock: + current = self.get(evaluation_id) + allowed = { + EvaluationState.ISSUED: { + EvaluationState.ISSUED, + EvaluationState.RETRYABLE, + EvaluationState.UNKNOWN, + EvaluationState.PUBLISHED, + }, + EvaluationState.RETRYABLE: { + EvaluationState.RETRYABLE, + EvaluationState.UNKNOWN, + EvaluationState.PUBLISHED, + }, + EvaluationState.UNKNOWN: { + EvaluationState.UNKNOWN, + EvaluationState.PUBLISHED, + }, + EvaluationState.PUBLISHED: {EvaluationState.PUBLISHED}, + } + if state not in allowed[current.finalization_state]: + raise SignerRefused("evaluation_state_regression") + updated = replace(current, finalization_state=state) + self._evaluations[evaluation_id] = updated + return updated + + def consume_finalization_jti(self, evaluation_id: str, jti: str) -> None: + token_id = require_string(jti, "jti") + with self._lock: + self.get(evaluation_id) + if token_id in self._admission_jtis: + raise SignerRefused("fresh_oidc_required") + if token_id in self._finalization_jtis: + raise SignerRefused("finalization_jti_reused") + self._finalization_jtis[token_id] = evaluation_id + + def reserve_slot( + self, payload: PublicationPayload, policy_epoch: str + ) -> PublicationSlot: + if type(payload) is not PublicationPayload: + raise SignerRefused("invalid_publication_payload") + epoch = require_string(policy_epoch, "policy_epoch") + key = payload.repository_id, payload.head_oid, epoch + digest = payload.canonical_digest() + with self._lock: + existing = self._slots.get(key) + if existing is not None: + if existing.payload_sha256 != digest or existing.payload != payload: + raise SignerRefused("publication_slot_conflict") + return existing + slot = PublicationSlot( + repository_id=payload.repository_id, + head_oid=payload.head_oid, + policy_epoch=epoch, + payload=payload, + payload_sha256=digest, + state=PublicationSlotState.RESERVED, + ) + self._slots[key] = slot + return slot + + def get_slot(self, key: SlotKey) -> PublicationSlot: + with self._lock: + try: + return self._slots[key] + except KeyError: + raise SignerRefused("publication_slot_not_found") from None + + def set_slot_state( + self, + key: SlotKey, + state: PublicationSlotState, + receipt: PublicationReceipt | None = None, + ) -> PublicationSlot: + if type(state) is not PublicationSlotState: + raise SignerRefused("invalid_publication_slot_state") + with self._lock: + current = self.get_slot(key) + allowed = { + PublicationSlotState.RESERVED: { + PublicationSlotState.RESERVED, + PublicationSlotState.UNKNOWN, + PublicationSlotState.PUBLISHED, + }, + PublicationSlotState.UNKNOWN: { + PublicationSlotState.UNKNOWN, + PublicationSlotState.PUBLISHED, + }, + PublicationSlotState.PUBLISHED: {PublicationSlotState.PUBLISHED}, + } + if state not in allowed[current.state]: + raise SignerRefused("publication_slot_state_regression") + if state is PublicationSlotState.PUBLISHED: + if type(receipt) is not PublicationReceipt: + raise SignerRefused("invalid_publication_receipt") + if receipt.payload_sha256 != current.payload_sha256: + raise SignerRefused("publication_receipt_mismatch") + elif receipt is not None: + raise SignerRefused("unexpected_publication_receipt") + updated = replace(current, state=state, receipt=receipt) + self._slots[key] = updated + return updated + + def evaluation_count(self) -> int: + with self._lock: + return len(self._evaluations) + + def slot_count(self) -> int: + with self._lock: + return len(self._slots) From ee219f0639c9ff381667acb64426027dd442dcc9 Mon Sep 17 00:00:00 2001 From: stacknil Date: Fri, 18 Sep 2026 16:12:06 +0800 Subject: [PATCH 02/12] test(security): verify signer authority invariants --- tests/signer_test_support.py | 300 +++++++++++++ tests/test_repo_sentinel_signer_oidc.py | 221 ++++++++++ .../test_repo_sentinel_signer_publication.py | 409 ++++++++++++++++++ tests/test_repo_sentinel_signer_registry.py | 188 ++++++++ tests/test_repo_sentinel_signer_service.py | 372 ++++++++++++++++ 5 files changed, 1490 insertions(+) create mode 100644 tests/signer_test_support.py create mode 100644 tests/test_repo_sentinel_signer_oidc.py create mode 100644 tests/test_repo_sentinel_signer_publication.py create mode 100644 tests/test_repo_sentinel_signer_registry.py create mode 100644 tests/test_repo_sentinel_signer_service.py diff --git a/tests/signer_test_support.py b/tests/signer_test_support.py new file mode 100644 index 0000000..5d99a0c --- /dev/null +++ b/tests/signer_test_support.py @@ -0,0 +1,300 @@ +"""Synthetic fixtures for signer v1 contract tests.""" + +from __future__ import annotations + +import hashlib +import itertools +import json +import sys +import threading +from dataclasses import replace +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SIGNER = ROOT / "signer" +sys.path.insert(0, str(SIGNER)) + +from repo_sentinel_signer import ( # noqa: E402 + GITHUB_OIDC_ISSUER, + SIGNER_AUDIENCE, + InMemoryEvaluationStore, + InMemoryRegistry, + MockPublisher, + MockPublishMode, + MockPullRequestReader, + PullRequestSnapshot, + RegistryRecord, + SignerService, + TicketRequest, + VerifiedOidcClaims, +) + +NOW = 2_000_000_000 +REPOSITORY_ID = 1_130_304_545 +OWNER_ID = 219_124_580 +REPOSITORY = "stacknil/sec-writeups-public" +OWNER = "stacknil" +PULL_NUMBER = 22 + + +def digest(label: str) -> str: + return hashlib.sha256(label.encode("ascii")).hexdigest() + + +def oid(label: str) -> str: + return digest(label)[:40] + + +def registry_record( + *, + record_id: str = "synthetic-record", + revision: int = 1, + policy_epoch: str = "synthetic-policy-v1", + policy_digest: str | None = None, + workflow_sha: str | None = None, + publisher_identity: str | None = None, + reusable: bool = False, +) -> RegistryRecord: + return RegistryRecord( + record_id=record_id, + revision=revision, + repository_id=REPOSITORY_ID, + repository_owner_id=OWNER_ID, + repository=REPOSITORY, + policy_selector="v2", + policy_epoch=policy_epoch, + policy_bundle_sha256=policy_digest or digest(f"policy-{policy_epoch}"), + controller_protocol="external-policy-root-v1", + controller_schema_version=1, + scanner_distribution="repo-sentinel-lite", + scanner_version="0.8.1", + scanner_artifact_sha256=digest("synthetic-scanner-wheel"), + workflow_ref="stacknil/sec-writeups-public/.github/workflows/authority.yml@main", + workflow_sha=workflow_sha or oid("trusted-workflow"), + status_context="Repo Sentinel / authoritative gate", + publisher_identity=( + publisher_identity or f"mock-publisher-{digest('publisher-identity')[:12]}" + ), + job_workflow_ref=( + "stacknil/trusted-workflows/.github/workflows/signer.yml@v1" + if reusable + else None + ), + job_workflow_sha=oid("trusted-reusable-workflow") if reusable else None, + ) + + +def raw_claims( + record: RegistryRecord, + *, + jti: str = "synthetic-jti", + run_id: int = 7001, + run_attempt: int = 1, + now: int = NOW, +) -> dict[str, object]: + claims: dict[str, object] = { + "iss": GITHUB_OIDC_ISSUER, + "aud": SIGNER_AUDIENCE, + "sub": "repo:stacknil/sec-writeups-public:event_name:pull_request_target", + "jti": jti, + "iat": now - 5, + "nbf": now - 5, + "exp": now + 300, + "repository": record.repository, + "repository_id": str(record.repository_id), + "repository_owner": OWNER, + "repository_owner_id": str(record.repository_owner_id), + "event_name": "pull_request_target", + "runner_environment": "github-hosted", + "workflow_ref": record.workflow_ref, + "workflow_sha": record.workflow_sha, + "run_id": str(run_id), + "run_attempt": str(run_attempt), + } + if record.job_workflow_ref is not None: + claims["job_workflow_ref"] = record.job_workflow_ref + claims["job_workflow_sha"] = record.job_workflow_sha + return claims + + +def verified_claims( + record: RegistryRecord, + *, + jti: str = "synthetic-jti", + run_id: int = 7001, + run_attempt: int = 1, + now: int = NOW, + **changes: object, +) -> VerifiedOidcClaims: + claims = raw_claims( + record, + jti=jti, + run_id=run_id, + run_attempt=run_attempt, + now=now, + ) + claims.update(changes) + return VerifiedOidcClaims.from_verified_mapping(claims) + + +def worker_semantic_digest(payload: dict[str, object]) -> str: + semantic = { + key: value for key, value in payload.items() if key != "semantic_sha256" + } + encoded = json.dumps( + semantic, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + domain = b"repo-sentinel-commit-authority-result-v1\0" + return hashlib.sha256( + domain + len(encoded).to_bytes(8, "big") + encoded + ).hexdigest() + + +def controller_result( + ticket: object, + record: RegistryRecord, + verdict: str = "PASS", + *, + infrastructure_refusal: bool = False, +) -> dict[str, object]: + if infrastructure_refusal: + return { + "controller_outcome": "INFRASTRUCTURE_REFUSAL", + "controller_protocol": record.controller_protocol, + "controller_schema_version": record.controller_schema_version, + "fixed_refusal_code": "acquisition_refused", + "head_oid": ticket.head_oid, + "policy_bundle_sha256": ticket.expected_policy_bundle_sha256, + "policy_epoch": ticket.policy_epoch, + "policy_selector": ticket.policy_selector, + "repository_id": ticket.repository_id, + "worker_result": None, + "worker_semantic_sha256": None, + } + policy_failure = verdict == "POLICY_ADMISSION_FAILURE" + worker: dict[str, object] = { + "coverage_policy_sha256": digest("coverage-policy"), + "files_policy_excluded": 0 if policy_failure else 2, + "files_scanned": 0 if policy_failure else 7, + "files_scanner_skipped": 0, + "files_total": 9, + "head_oid": ticket.head_oid, + "policy_bundle_sha256": ticket.expected_policy_bundle_sha256, + "policy_epoch": ticket.policy_epoch, + "policy_schema_version": 1, + "protected_manifest_sha256": digest("protected-manifest"), + "refusal_code": "protected_control_mismatch" if policy_failure else None, + "report_sha256": None if policy_failure else digest("scanner-report"), + "report_size": 0 if policy_failure else 128, + "repository_id": ticket.repository_id, + "scanner_artifact_sha256": ticket.scanner_artifact_sha256, + "scanner_distribution": ticket.scanner_distribution, + "scanner_version": ticket.scanner_version, + "semantic_sha256": None, + "suppression_manifest_sha256": digest("suppression-manifest"), + "verdict": verdict, + } + worker["semantic_sha256"] = worker_semantic_digest(worker) + return { + "controller_outcome": "AUTHORITY_RESULT", + "controller_protocol": record.controller_protocol, + "controller_schema_version": record.controller_schema_version, + "fixed_refusal_code": None, + "head_oid": ticket.head_oid, + "policy_bundle_sha256": ticket.expected_policy_bundle_sha256, + "policy_epoch": ticket.policy_epoch, + "policy_selector": ticket.policy_selector, + "repository_id": ticket.repository_id, + "worker_result": worker, + "worker_semantic_sha256": worker["semantic_sha256"], + } + + +class MutableClock: + def __init__(self, now: int = NOW) -> None: + self.now = now + + def __call__(self) -> int: + return self.now + + +class Harness: + def __init__( + self, + *, + record: RegistryRecord | None = None, + outcomes: list[MockPublishMode] | None = None, + head_oid: str | None = None, + ) -> None: + self.lock = threading.RLock() + self.record = record or registry_record() + self.registry = InMemoryRegistry(lock=self.lock) + self.registry.add(self.record) + self.registry.activate( + self.record.repository_id, "authoritative", self.record.key + ) + self.store = InMemoryEvaluationStore(lock=self.lock) + self.head_oid = head_oid or oid("pull-head") + self.reader = MockPullRequestReader( + [ + PullRequestSnapshot( + self.record.repository_id, + PULL_NUMBER, + "open", + self.head_oid, + "main", + ) + ] + ) + self.publisher = MockPublisher(self.record.publisher_identity, outcomes) + self.clock = MutableClock() + self._ids = itertools.count(1) + self.service = SignerService( + registry=self.registry, + store=self.store, + pull_requests=self.reader, + publisher=self.publisher, + clock=self.clock, + evaluation_id_generator=lambda: f"evaluation-{next(self._ids)}", + ) + + def issue( + self, + *, + jti: str = "admission-jti", + pull_number: int = PULL_NUMBER, + run_id: int = 7001, + run_attempt: int = 1, + ): + claims = verified_claims( + self.record, + jti=jti, + run_id=run_id, + run_attempt=run_attempt, + now=self.clock.now, + ) + return self.service.issue_evaluation(claims, TicketRequest(pull_number)) + + def final_claims( + self, + jti: str, + *, + run_id: int = 7001, + run_attempt: int = 1, + **changes: object, + ) -> VerifiedOidcClaims: + return verified_claims( + self.record, + jti=jti, + run_id=run_id, + run_attempt=run_attempt, + now=self.clock.now, + **changes, + ) + + +def mutate_claims(claims: VerifiedOidcClaims, **changes: object) -> VerifiedOidcClaims: + return replace(claims, **changes) diff --git a/tests/test_repo_sentinel_signer_oidc.py b/tests/test_repo_sentinel_signer_oidc.py new file mode 100644 index 0000000..9a43c25 --- /dev/null +++ b/tests/test_repo_sentinel_signer_oidc.py @@ -0,0 +1,221 @@ +"""OIDC claim and execution-identity tests for signer v1.""" + +from __future__ import annotations + +import unittest + +from tests.signer_test_support import ( + NOW, + OWNER_ID, + REPOSITORY_ID, + Harness, + raw_claims, + registry_record, + verified_claims, +) + +from repo_sentinel_signer import ( # noqa: E402 + SIGNER_AUDIENCE, + MockOidcVerifier, + SignerRefused, + TicketRequest, + VerifiedOidcClaims, +) + + +class ClaimModelTests(unittest.TestCase): + def test_canonical_decimal_claims_are_parsed(self) -> None: + record = registry_record() + claims = VerifiedOidcClaims.from_verified_mapping(raw_claims(record)) + + self.assertEqual(claims.repository_id, REPOSITORY_ID) + self.assertEqual(claims.repository_owner_id, OWNER_ID) + self.assertEqual(claims.run_id, 7001) + self.assertEqual(claims.run_attempt, 1) + + def test_malformed_numeric_claims_fail_closed(self) -> None: + record = registry_record() + invalid = ( + True, + 1.0, + -1, + 0, + "0", + "-1", + "+1", + " 1", + "1 ", + "01", + "1.0", + "one", + "", + ) + for field in ( + "repository_id", + "repository_owner_id", + "run_id", + "run_attempt", + ): + for value in invalid: + with self.subTest(field=field, value=value): + claims = raw_claims(record) + claims[field] = value + with self.assertRaises(SignerRefused): + VerifiedOidcClaims.from_verified_mapping(claims) + + def test_timestamp_claims_require_exact_nonnegative_integers(self) -> None: + record = registry_record() + for field in ("iat", "nbf", "exp"): + for value in (True, 1.0, "1", -1): + with self.subTest(field=field, value=value): + claims = raw_claims(record) + claims[field] = value + with self.assertRaises(SignerRefused): + VerifiedOidcClaims.from_verified_mapping(claims) + + def test_claim_shape_rejects_missing_extra_and_unpaired_reusable_fields( + self, + ) -> None: + record = registry_record(reusable=True) + cases = [] + missing = raw_claims(record) + missing.pop("jti") + cases.append(missing) + extra = raw_claims(record) + extra["actor"] = "someone" + cases.append(extra) + unpaired = raw_claims(record) + unpaired.pop("job_workflow_sha") + cases.append(unpaired) + + for candidate in cases: + with self.subTest(keys=sorted(candidate)): + with self.assertRaisesRegex(SignerRefused, "invalid_oidc_claims"): + VerifiedOidcClaims.from_verified_mapping(candidate) + + def test_empty_jti_and_noncanonical_workflow_sha_are_rejected(self) -> None: + record = registry_record() + for field, value in (("jti", ""), ("workflow_sha", "A" * 40)): + claims = raw_claims(record) + claims[field] = value + with self.subTest(field=field), self.assertRaises(SignerRefused): + VerifiedOidcClaims.from_verified_mapping(claims) + + +class MockVerifierTests(unittest.TestCase): + def test_mock_verifier_returns_only_validated_claim_model(self) -> None: + record = registry_record() + verifier = MockOidcVerifier({"opaque-token": raw_claims(record)}) + + claims = verifier.verify("opaque-token", SIGNER_AUDIENCE) + + self.assertIs(type(claims), VerifiedOidcClaims) + + def test_mock_verifier_rejects_unknown_token_and_wrong_audience(self) -> None: + record = registry_record() + verifier = MockOidcVerifier({"opaque-token": raw_claims(record)}) + + with self.assertRaisesRegex(SignerRefused, "oidc_verification_failed"): + verifier.verify("other-token", SIGNER_AUDIENCE) + with self.assertRaisesRegex(SignerRefused, "oidc_audience_mismatch"): + verifier.verify("opaque-token", "wrong-audience") + + def test_no_production_looking_verifier_is_exported(self) -> None: + import repo_sentinel_signer as signer + + self.assertFalse(hasattr(signer, "GitHubOidcVerifier")) + self.assertFalse(hasattr(signer, "ProductionOidcVerifier")) + + +class AdmissionAuthorizationTests(unittest.TestCase): + def test_service_rejects_unverified_mapping(self) -> None: + harness = Harness() + + with self.assertRaisesRegex(SignerRefused, "unverified_oidc_claims"): + harness.service.issue_evaluation( # type: ignore[arg-type] + raw_claims(harness.record), TicketRequest(22) + ) + + def test_execution_identity_mismatches_fail_closed(self) -> None: + harness = Harness() + valid = raw_claims(harness.record, now=harness.clock.now) + cases = { + "issuer": ("iss", "https://issuer.example"), + "audience": ("aud", "wrong-audience"), + "repository-id": ("repository_id", str(REPOSITORY_ID + 1)), + "owner-id": ("repository_owner_id", str(OWNER_ID + 1)), + "repository": ("repository", "stacknil/other"), + "owner": ("repository_owner", "other-owner"), + "event": ("event_name", "pull_request"), + "runner": ("runner_environment", "self-hosted"), + "workflow-ref": ( + "workflow_ref", + "stacknil/other/.github/workflows/x.yml@main", + ), + "workflow-sha": ("workflow_sha", "f" * 40), + } + for name, (field, value) in cases.items(): + candidate = dict(valid) + candidate[field] = value + claims = VerifiedOidcClaims.from_verified_mapping(candidate) + with self.subTest(name=name), self.assertRaises(SignerRefused): + harness.service.issue_evaluation(claims, TicketRequest(22)) + + def test_reusable_workflow_identity_is_exactly_bound(self) -> None: + harness = Harness(record=registry_record(reusable=True)) + cases = ( + {"job_workflow_ref": "stacknil/other/.github/workflows/x.yml@v1"}, + {"job_workflow_sha": "f" * 40}, + ) + for changes in cases: + claims = verified_claims(harness.record, **changes) + with ( + self.subTest(changes=changes), + self.assertRaisesRegex( + SignerRefused, "oidc_reusable_workflow_mismatch" + ), + ): + harness.service.issue_evaluation(claims, TicketRequest(22)) + + def test_unconfigured_reusable_identity_is_not_silently_accepted(self) -> None: + harness = Harness() + claims = raw_claims(harness.record) + claims["job_workflow_ref"] = "stacknil/shared/.github/workflows/x.yml@v1" + claims["job_workflow_sha"] = "f" * 40 + + with self.assertRaisesRegex(SignerRefused, "oidc_reusable_workflow_mismatch"): + harness.service.issue_evaluation( + VerifiedOidcClaims.from_verified_mapping(claims), TicketRequest(22) + ) + + def test_time_window_uses_injected_clock_and_explicit_skew(self) -> None: + harness = Harness() + cases = ( + ({"nbf": NOW + 1}, "oidc_not_yet_valid"), + ({"exp": NOW - 1}, "oidc_expired"), + ({"iat": NOW + 31}, "oidc_issued_in_future"), + ) + for changes, code in cases: + claims = verified_claims(harness.record, **changes) + with ( + self.subTest(changes=changes), + self.assertRaisesRegex(SignerRefused, code), + ): + harness.service.issue_evaluation(claims, TicketRequest(22)) + + def test_token_lifetime_is_not_forced_to_an_arbitrary_duration(self) -> None: + harness = Harness() + claims = verified_claims( + harness.record, + iat=NOW - 10_000, + nbf=NOW - 10_000, + exp=NOW + 1, + ) + + ticket = harness.service.issue_evaluation(claims, TicketRequest(22)) + + self.assertEqual(ticket.head_oid, harness.head_oid) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_repo_sentinel_signer_publication.py b/tests/test_repo_sentinel_signer_publication.py new file mode 100644 index 0000000..94f34ba --- /dev/null +++ b/tests/test_repo_sentinel_signer_publication.py @@ -0,0 +1,409 @@ +"""Publication-slot, uncertainty, and concurrency tests for signer v1.""" + +from __future__ import annotations + +import inspect +import threading +import unittest + +from tests.signer_test_support import ( + PULL_NUMBER, + REPOSITORY_ID, + Harness, + controller_result, +) + +from repo_sentinel_signer import ( # noqa: E402 + EvaluationState, + MockPublishMode, + PublicationSlotState, + PullRequestSnapshot, + SignerRefused, +) + + +class VerdictMappingTests(unittest.TestCase): + def test_semantic_verdicts_map_to_only_success_or_failure(self) -> None: + expected = { + "PASS": ( + "success", + "Repo Sentinel authority: PASS", + ), + "SCANNER_FINDING": ( + "failure", + "Repo Sentinel authority: SCANNER_FINDING", + ), + "POLICY_ADMISSION_FAILURE": ( + "failure", + "Repo Sentinel authority: POLICY_ADMISSION_FAILURE", + ), + } + for verdict, (state, description) in expected.items(): + harness = Harness() + ticket = harness.issue() + result = harness.service.finalize_evaluation( + harness.final_claims(f"final-{verdict}"), + ticket.evaluation_id, + controller_result(ticket, harness.record, verdict), + ) + with self.subTest(verdict=verdict): + self.assertEqual(result.slot_state, PublicationSlotState.PUBLISHED) + self.assertEqual(len(harness.publisher.calls), 1) + payload = harness.publisher.calls[0] + self.assertEqual(payload.state, state) + self.assertEqual(payload.description, description) + self.assertNotIn(payload.state, {"pending", "error"}) + self.assertIsNone(payload.target_url) + + def test_publication_fields_are_not_finalize_parameters(self) -> None: + parameters = inspect.signature(Harness().service.finalize_evaluation).parameters + + self.assertEqual( + set(parameters), + {"verified_oidc", "evaluation_id", "controller_result"}, + ) + + def test_registry_supplies_context_and_publisher_identity(self) -> None: + harness = Harness() + ticket = harness.issue() + harness.service.finalize_evaluation( + harness.final_claims("final-context"), + ticket.evaluation_id, + controller_result(ticket, harness.record), + ) + + payload = harness.publisher.calls[0] + self.assertEqual(payload.context, harness.record.status_context) + self.assertEqual(harness.publisher.identity, harness.record.publisher_identity) + + def test_mismatched_publisher_identity_fails_before_publisher_call(self) -> None: + harness = Harness() + ticket = harness.issue() + harness.publisher._identity = "different-mock-publisher" # noqa: SLF001 + + with self.assertRaisesRegex(SignerRefused, "publisher_identity_mismatch"): + harness.service.finalize_evaluation( + harness.final_claims("wrong-publisher"), + ticket.evaluation_id, + controller_result(ticket, harness.record), + ) + self.assertEqual(harness.publisher.calls, ()) + + +class UnknownPublicationTests(unittest.TestCase): + def test_unknown_before_write_retries_only_identical_payload(self) -> None: + harness = Harness( + outcomes=[ + MockPublishMode.UNKNOWN_BEFORE_WRITE, + MockPublishMode.PUBLISHED, + ] + ) + ticket = harness.issue() + result = harness.service.finalize_evaluation( + harness.final_claims("unknown-before-one"), + ticket.evaluation_id, + controller_result(ticket, harness.record), + ) + self.assertEqual(result.slot_state, PublicationSlotState.UNKNOWN) + + result = harness.service.finalize_evaluation( + harness.final_claims("unknown-before-two"), + ticket.evaluation_id, + controller_result(ticket, harness.record), + ) + + self.assertEqual(result.slot_state, PublicationSlotState.PUBLISHED) + self.assertEqual(len(harness.publisher.calls), 2) + self.assertEqual( + harness.publisher.calls[0].canonical_digest(), + harness.publisher.calls[1].canonical_digest(), + ) + + def test_unknown_after_write_reconciles_without_duplicate_call(self) -> None: + harness = Harness(outcomes=[MockPublishMode.UNKNOWN_AFTER_WRITE]) + ticket = harness.issue() + first = harness.service.finalize_evaluation( + harness.final_claims("unknown-after-one"), + ticket.evaluation_id, + controller_result(ticket, harness.record), + ) + self.assertEqual(first.slot_state, PublicationSlotState.UNKNOWN) + + second = harness.service.finalize_evaluation( + harness.final_claims("unknown-after-two"), + ticket.evaluation_id, + controller_result(ticket, harness.record), + ) + + self.assertEqual(second.slot_state, PublicationSlotState.PUBLISHED) + self.assertEqual(len(harness.publisher.calls), 1) + self.assertIsNotNone(second.receipt) + + def test_definite_failure_remains_reserved_and_exactly_retryable(self) -> None: + harness = Harness( + outcomes=[MockPublishMode.DEFINITE_FAILURE, MockPublishMode.PUBLISHED] + ) + ticket = harness.issue() + first = harness.service.finalize_evaluation( + harness.final_claims("definite-one"), + ticket.evaluation_id, + controller_result(ticket, harness.record), + ) + self.assertEqual(first.slot_state, PublicationSlotState.RESERVED) + self.assertTrue(first.retryable) + + second = harness.service.finalize_evaluation( + harness.final_claims("definite-two"), + ticket.evaluation_id, + controller_result(ticket, harness.record), + ) + + self.assertEqual(second.slot_state, PublicationSlotState.PUBLISHED) + self.assertEqual(len(harness.publisher.calls), 2) + self.assertEqual(harness.publisher.calls[0], harness.publisher.calls[1]) + + def test_opposite_payload_after_unknown_is_rejected_before_publisher(self) -> None: + for first, second in ( + ("PASS", "SCANNER_FINDING"), + ("SCANNER_FINDING", "PASS"), + ): + harness = Harness(outcomes=[MockPublishMode.UNKNOWN_BEFORE_WRITE]) + ticket = harness.issue() + harness.service.finalize_evaluation( + harness.final_claims(f"unknown-{first}"), + ticket.evaluation_id, + controller_result(ticket, harness.record, first), + ) + with ( + self.subTest(first=first), + self.assertRaisesRegex(SignerRefused, "publication_slot_conflict"), + ): + harness.service.finalize_evaluation( + harness.final_claims(f"opposite-{second}"), + ticket.evaluation_id, + controller_result(ticket, harness.record, second), + ) + self.assertEqual(len(harness.publisher.calls), 1) + + def test_unknown_is_not_downgraded_by_later_infrastructure_refusal(self) -> None: + harness = Harness(outcomes=[MockPublishMode.UNKNOWN_BEFORE_WRITE]) + ticket = harness.issue() + first = harness.service.finalize_evaluation( + harness.final_claims("unknown-before-infra"), + ticket.evaluation_id, + controller_result(ticket, harness.record), + ) + + second = harness.service.finalize_evaluation( + harness.final_claims("infra-after-unknown"), + ticket.evaluation_id, + controller_result( + ticket, + harness.record, + infrastructure_refusal=True, + ), + ) + + self.assertEqual(first.evaluation_state, EvaluationState.UNKNOWN) + self.assertEqual(second.evaluation_state, EvaluationState.UNKNOWN) + self.assertTrue(second.retryable) + self.assertEqual(len(harness.publisher.calls), 1) + + def test_unknown_is_not_downgraded_by_definite_publish_failure(self) -> None: + harness = Harness( + outcomes=[ + MockPublishMode.UNKNOWN_BEFORE_WRITE, + MockPublishMode.DEFINITE_FAILURE, + ] + ) + ticket = harness.issue() + harness.service.finalize_evaluation( + harness.final_claims("unknown-before-definite"), + ticket.evaluation_id, + controller_result(ticket, harness.record), + ) + + result = harness.service.finalize_evaluation( + harness.final_claims("definite-after-unknown"), + ticket.evaluation_id, + controller_result(ticket, harness.record), + ) + + self.assertEqual(result.evaluation_state, EvaluationState.UNKNOWN) + self.assertEqual(result.slot_state, PublicationSlotState.UNKNOWN) + self.assertTrue(result.retryable) + self.assertEqual(len(harness.publisher.calls), 2) + + def test_published_evaluation_rejects_later_infrastructure_refusal(self) -> None: + harness = Harness() + ticket = harness.issue() + harness.service.finalize_evaluation( + harness.final_claims("publish-before-infra"), + ticket.evaluation_id, + controller_result(ticket, harness.record), + ) + + with self.assertRaisesRegex(SignerRefused, "evaluation_already_published"): + harness.service.finalize_evaluation( + harness.final_claims("infra-after-publish"), + ticket.evaluation_id, + controller_result( + ticket, + harness.record, + infrastructure_refusal=True, + ), + ) + + self.assertEqual( + harness.store.get(ticket.evaluation_id).finalization_state, + EvaluationState.PUBLISHED, + ) + self.assertEqual(len(harness.publisher.calls), 1) + + def test_store_rejects_evaluation_and_slot_state_regression(self) -> None: + harness = Harness() + ticket = harness.issue() + harness.service.finalize_evaluation( + harness.final_claims("publish-before-regression"), + ticket.evaluation_id, + controller_result(ticket, harness.record), + ) + slot_key = (REPOSITORY_ID, ticket.head_oid, ticket.policy_epoch) + + with self.assertRaisesRegex(SignerRefused, "evaluation_state_regression"): + harness.store.set_state(ticket.evaluation_id, EvaluationState.UNKNOWN) + with self.assertRaisesRegex(SignerRefused, "publication_slot_state_regression"): + harness.store.set_slot_state(slot_key, PublicationSlotState.UNKNOWN) + + +class OrderingAndSharingTests(unittest.TestCase): + def two_pr_tickets(self, harness: Harness): + second_pull = PULL_NUMBER + 1 + harness.reader.set( + PullRequestSnapshot( + REPOSITORY_ID, + second_pull, + "open", + harness.head_oid, + "main", + ) + ) + first = harness.issue(jti="admission-a", pull_number=PULL_NUMBER) + second = harness.issue(jti="admission-b", pull_number=second_pull) + return first, second + + def test_same_h_same_epoch_cross_pr_results_share_one_slot(self) -> None: + harness = Harness() + first, second = self.two_pr_tickets(harness) + + harness.service.finalize_evaluation( + harness.final_claims("second-first"), + second.evaluation_id, + controller_result(second, harness.record), + ) + result = harness.service.finalize_evaluation( + harness.final_claims("first-later"), + first.evaluation_id, + controller_result(first, harness.record), + ) + + self.assertEqual(result.slot_state, PublicationSlotState.PUBLISHED) + self.assertEqual(harness.store.slot_count(), 1) + self.assertEqual(len(harness.publisher.calls), 1) + + def test_finalization_jti_cannot_be_replayed_across_evaluations(self) -> None: + harness = Harness() + first, second = self.two_pr_tickets(harness) + claims = harness.final_claims("shared-finalization-jti") + harness.service.finalize_evaluation( + claims, + first.evaluation_id, + controller_result(first, harness.record), + ) + + with self.assertRaisesRegex(SignerRefused, "finalization_jti_reused"): + harness.service.finalize_evaluation( + claims, + second.evaluation_id, + controller_result(second, harness.record), + ) + + self.assertEqual(len(harness.publisher.calls), 1) + + def test_late_opposite_finalizer_conflicts_without_second_call(self) -> None: + harness = Harness() + first, second = self.two_pr_tickets(harness) + harness.service.finalize_evaluation( + harness.final_claims("second-pass"), + second.evaluation_id, + controller_result(second, harness.record, "PASS"), + ) + + with self.assertRaisesRegex(SignerRefused, "publication_slot_conflict"): + harness.service.finalize_evaluation( + harness.final_claims("first-failure"), + first.evaluation_id, + controller_result(first, harness.record, "SCANNER_FINDING"), + ) + self.assertEqual(len(harness.publisher.calls), 1) + + def test_concurrent_idempotent_ticket_issuance_creates_one_evaluation(self) -> None: + harness = Harness() + barrier = threading.Barrier(8) + tickets = [] + errors = [] + + def issue(index: int) -> None: + try: + barrier.wait() + tickets.append(harness.issue(jti=f"concurrent-admission-{index}")) + except Exception as error: # noqa: BLE001 + errors.append(error) + + threads = [threading.Thread(target=issue, args=(index,)) for index in range(8)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + self.assertEqual(errors, []) + self.assertEqual(len({ticket.evaluation_id for ticket in tickets}), 1) + self.assertEqual(harness.store.evaluation_count(), 1) + + def test_concurrent_same_payload_finalizers_publish_once(self) -> None: + harness = Harness() + first, second = self.two_pr_tickets(harness) + barrier = threading.Barrier(2) + results = [] + errors = [] + + def finalize(ticket: object, jti: str) -> None: + try: + barrier.wait() + results.append( + harness.service.finalize_evaluation( + harness.final_claims(jti), + ticket.evaluation_id, + controller_result(ticket, harness.record), + ) + ) + except Exception as error: # noqa: BLE001 + errors.append(error) + + threads = [ + threading.Thread(target=finalize, args=(first, "concurrent-first")), + threading.Thread(target=finalize, args=(second, "concurrent-second")), + ] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + self.assertEqual(errors, []) + self.assertEqual(len(results), 2) + self.assertEqual(len(harness.publisher.calls), 1) + self.assertEqual(harness.store.slot_count(), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_repo_sentinel_signer_registry.py b/tests/test_repo_sentinel_signer_registry.py new file mode 100644 index 0000000..90a69ee --- /dev/null +++ b/tests/test_repo_sentinel_signer_registry.py @@ -0,0 +1,188 @@ +"""Immutable registry and activation tests for signer v1.""" + +from __future__ import annotations + +import threading +import unittest +from dataclasses import FrozenInstanceError, replace + +from tests.signer_test_support import ( + REPOSITORY_ID, + Harness, + controller_result, + digest, + oid, + registry_record, +) + +from repo_sentinel_signer import ( # noqa: E402 + InMemoryRegistry, + SignerRefused, +) + + +class RegistryRecordTests(unittest.TestCase): + def test_records_are_frozen_values(self) -> None: + record = registry_record() + + with self.assertRaises(FrozenInstanceError): + record.policy_epoch = "changed" # type: ignore[misc] + + def test_same_key_with_changed_content_is_rejected(self) -> None: + registry = InMemoryRegistry() + record = registry.add(registry_record()) + + with self.assertRaisesRegex(SignerRefused, "registry_record_mutation"): + registry.add(replace(record, scanner_version="9.9.9")) + + def test_new_operational_revision_may_retain_same_epoch_and_digest(self) -> None: + registry = InMemoryRegistry() + first = registry.add(registry_record()) + second = registry.add( + replace(first, revision=2, workflow_sha=oid("replacement-workflow")) + ) + + self.assertEqual(first.policy_bundle_sha256, second.policy_bundle_sha256) + self.assertNotEqual(first.workflow_sha, second.workflow_sha) + + def test_same_repository_epoch_cannot_acquire_second_digest(self) -> None: + registry = InMemoryRegistry() + first = registry.add(registry_record()) + + with self.assertRaisesRegex(SignerRefused, "registry_epoch_digest_conflict"): + registry.add( + replace( + first, + record_id="second-record", + revision=1, + policy_bundle_sha256=digest("different-policy"), + ) + ) + + def test_different_digest_requires_different_policy_epoch(self) -> None: + registry = InMemoryRegistry() + first = registry.add(registry_record()) + second = registry.add( + replace( + first, + record_id="second-record", + policy_epoch="synthetic-policy-v2", + policy_bundle_sha256=digest("different-policy"), + ) + ) + + self.assertNotEqual(first.policy_epoch, second.policy_epoch) + self.assertNotEqual(first.policy_bundle_sha256, second.policy_bundle_sha256) + + def test_activation_is_explicit_and_latest_lookup_is_unsupported(self) -> None: + registry = InMemoryRegistry() + first = registry.add(registry_record()) + second = registry.add(replace(first, revision=2)) + + with self.assertRaisesRegex(SignerRefused, "registry_activation_ambiguous"): + registry.resolve_active(REPOSITORY_ID, "authoritative") + registry.activate(REPOSITORY_ID, "authoritative", first.key) + self.assertEqual(registry.resolve_active(REPOSITORY_ID, "authoritative"), first) + self.assertNotEqual( + registry.resolve_active(REPOSITORY_ID, "authoritative"), second + ) + with self.assertRaisesRegex(SignerRefused, "unsupported_registry_lookup"): + registry.resolve_latest(REPOSITORY_ID) + + def test_corrupt_ambiguous_activation_fails_closed(self) -> None: + registry = InMemoryRegistry() + record = registry.add(registry_record()) + registry.activate(REPOSITORY_ID, "authoritative", record.key) + registry._active[(REPOSITORY_ID, "authoritative")] = {record.key} # noqa: SLF001 + + with self.assertRaisesRegex(SignerRefused, "registry_activation_ambiguous"): + registry.resolve_active(REPOSITORY_ID, "authoritative") + + def test_revoked_record_cannot_be_activated(self) -> None: + registry = InMemoryRegistry() + record = registry.add(registry_record()) + registry.revoke(record.key) + + with self.assertRaisesRegex(SignerRefused, "registry_record_revoked"): + registry.activate(REPOSITORY_ID, "authoritative", record.key) + + +class RegistryFreezeTests(unittest.TestCase): + def test_evaluation_registry_revision_is_frozen_and_not_client_selectable( + self, + ) -> None: + harness = Harness() + ticket = harness.issue() + evaluation = harness.store.get(ticket.evaluation_id) + + with self.assertRaises(FrozenInstanceError): + evaluation.registry_revision = 2 # type: ignore[misc] + + def test_evaluation_remains_bound_to_r1_after_r2_activation(self) -> None: + harness = Harness() + ticket = harness.issue() + r1 = harness.record + r2 = harness.registry.add( + replace(r1, revision=2, workflow_sha=oid("workflow-r2")) + ) + harness.registry.activate(REPOSITORY_ID, "authoritative", r2.key) + + result = harness.service.finalize_evaluation( + harness.final_claims("final-r1"), + ticket.evaluation_id, + controller_result(ticket, r1), + ) + + self.assertEqual(result.evaluation_state.value, "PUBLISHED") + evaluation = harness.store.get(ticket.evaluation_id) + self.assertEqual(evaluation.registry_key, r1.key) + + def test_revoking_r1_after_issuance_rejects_finalization(self) -> None: + harness = Harness() + ticket = harness.issue() + harness.registry.revoke(harness.record.key) + + with self.assertRaisesRegex(SignerRefused, "registry_record_revoked"): + harness.service.finalize_evaluation( + harness.final_claims("final-after-revoke"), + ticket.evaluation_id, + controller_result(ticket, harness.record), + ) + self.assertEqual(harness.publisher.calls, ()) + + def test_concurrent_activation_cannot_create_mixed_revision_evaluation( + self, + ) -> None: + harness = Harness() + r1 = harness.record + r2 = harness.registry.add(replace(r1, revision=2)) + barrier = threading.Barrier(2) + issued = [] + + def issue() -> None: + barrier.wait() + issued.append(harness.issue()) + + def activate() -> None: + barrier.wait() + harness.registry.activate(REPOSITORY_ID, "authoritative", r2.key) + + threads = [threading.Thread(target=issue), threading.Thread(target=activate)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + self.assertEqual(len(issued), 1) + evaluation = harness.store.get(issued[0].evaluation_id) + self.assertIn(evaluation.registry_key, {r1.key, r2.key}) + selected = harness.registry.get(evaluation.registry_key) + self.assertEqual( + evaluation.expected_policy_bundle_sha256, + selected.policy_bundle_sha256, + ) + self.assertEqual(evaluation.workflow_sha, selected.workflow_sha) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_repo_sentinel_signer_service.py b/tests/test_repo_sentinel_signer_service.py new file mode 100644 index 0000000..56fd5a1 --- /dev/null +++ b/tests/test_repo_sentinel_signer_service.py @@ -0,0 +1,372 @@ +"""Ticket, PR freshness, and controller binding tests for signer v1.""" + +from __future__ import annotations + +import copy +import unittest +from dataclasses import replace + +from tests.signer_test_support import ( + OWNER_ID, + PULL_NUMBER, + REPOSITORY_ID, + Harness, + controller_result, + digest, + oid, + worker_semantic_digest, +) + +from repo_sentinel_signer import ( # noqa: E402 + PullRequestSnapshot, + SignerRefused, +) + + +class TicketIssuanceTests(unittest.TestCase): + def test_ticket_contains_only_server_selected_evaluation_parameters(self) -> None: + harness = Harness() + + ticket = harness.issue() + + self.assertEqual(ticket.repository_id, REPOSITORY_ID) + self.assertEqual(ticket.pull_number, PULL_NUMBER) + self.assertEqual(ticket.head_oid, harness.head_oid) + self.assertEqual(ticket.policy_epoch, harness.record.policy_epoch) + self.assertEqual( + ticket.expected_policy_bundle_sha256, + harness.record.policy_bundle_sha256, + ) + self.assertFalse(hasattr(ticket, "status_context")) + self.assertFalse(hasattr(ticket, "publisher_identity")) + self.assertFalse(hasattr(ticket, "workflow_sha")) + + def test_pr_head_is_taken_from_server_side_reader(self) -> None: + harness = Harness(head_oid=oid("server-side-head")) + + ticket = harness.issue() + + self.assertEqual(ticket.head_oid, oid("server-side-head")) + + def test_missing_closed_or_wrong_identity_pr_is_rejected(self) -> None: + cases = ("missing", "closed", "wrong-identity") + for case in cases: + harness = Harness() + if case == "missing": + harness.reader.remove(REPOSITORY_ID, PULL_NUMBER) + elif case == "closed": + harness.reader.set( + PullRequestSnapshot( + REPOSITORY_ID, + PULL_NUMBER, + "closed", + harness.head_oid, + "main", + ) + ) + else: + + class WrongReader: + def read(self, _repository_id: int, _pull_number: int): + return PullRequestSnapshot( + REPOSITORY_ID, + PULL_NUMBER + 1, + "open", + harness.head_oid, + "main", + ) + + harness.service._pull_requests = WrongReader() # noqa: SLF001 + with self.subTest(case=case), self.assertRaises(SignerRefused): + harness.issue() + + def test_same_execution_ticket_is_idempotent_across_fresh_admission_jtis( + self, + ) -> None: + harness = Harness() + + first = harness.issue(jti="admission-one") + second = harness.issue(jti="admission-two") + + self.assertEqual(first, second) + self.assertEqual(harness.store.evaluation_count(), 1) + + def test_same_admission_jti_cannot_create_different_evaluation(self) -> None: + harness = Harness() + harness.issue(jti="one-jti") + harness.reader.set( + PullRequestSnapshot( + REPOSITORY_ID, + PULL_NUMBER, + "open", + oid("new-head"), + "main", + ) + ) + + with self.assertRaisesRegex(SignerRefused, "admission_jti_reused"): + harness.issue(jti="one-jti") + + +class FinalizationIdentityTests(unittest.TestCase): + def test_fresh_oidc_is_required(self) -> None: + harness = Harness() + ticket = harness.issue(jti="admission-jti") + + with self.assertRaisesRegex(SignerRefused, "fresh_oidc_required"): + harness.service.finalize_evaluation( + harness.final_claims("admission-jti"), + ticket.evaluation_id, + controller_result(ticket, harness.record), + ) + + def test_any_jti_used_for_idempotent_admission_is_not_fresh(self) -> None: + harness = Harness() + ticket = harness.issue(jti="admission-one") + harness.issue(jti="admission-two") + + with self.assertRaisesRegex(SignerRefused, "fresh_oidc_required"): + harness.service.finalize_evaluation( + harness.final_claims("admission-two"), + ticket.evaluation_id, + controller_result(ticket, harness.record), + ) + + def test_execution_tuple_mismatches_cannot_finalize(self) -> None: + cases = { + "run-id": {"run_id": 8002}, + "run-attempt": {"run_attempt": 2}, + "workflow-sha": {"workflow_sha": oid("wrong-workflow")}, + "repository": {"repository_id": REPOSITORY_ID + 1}, + "owner": {"repository_owner_id": OWNER_ID + 1}, + } + for name, changes in cases.items(): + harness = Harness() + ticket = harness.issue() + claims = replace(harness.final_claims(f"final-{name}"), **changes) + with self.subTest(name=name), self.assertRaises(SignerRefused): + harness.service.finalize_evaluation( + claims, + ticket.evaluation_id, + controller_result(ticket, harness.record), + ) + self.assertEqual(harness.publisher.calls, ()) + + def test_finalization_jti_cannot_be_reused_after_retryable_refusal(self) -> None: + harness = Harness() + ticket = harness.issue() + claims = harness.final_claims("one-finalization-jti") + refusal = controller_result(ticket, harness.record, infrastructure_refusal=True) + harness.service.finalize_evaluation(claims, ticket.evaluation_id, refusal) + + with self.assertRaisesRegex(SignerRefused, "finalization_jti_reused"): + harness.service.finalize_evaluation(claims, ticket.evaluation_id, refusal) + + def test_expired_evaluation_requires_new_admission(self) -> None: + harness = Harness() + ticket = harness.issue() + harness.clock.now = ticket.expires_at + 1 + + with self.assertRaisesRegex(SignerRefused, "evaluation_expired"): + harness.service.finalize_evaluation( + harness.final_claims("late-finalization"), + ticket.evaluation_id, + controller_result(ticket, harness.record), + ) + + +class PullRequestFreshnessTests(unittest.TestCase): + def test_head_change_and_pr_close_reject_finalization(self) -> None: + for state, head, code in ( + ("open", oid("advanced-head"), "pull_request_head_changed"), + ("closed", oid("pull-head"), "pull_request_closed"), + ): + harness = Harness() + ticket = harness.issue() + harness.reader.set( + PullRequestSnapshot( + REPOSITORY_ID, + PULL_NUMBER, + state, + head, + "main", + ) + ) + with self.subTest(state=state), self.assertRaisesRegex(SignerRefused, code): + harness.service.finalize_evaluation( + harness.final_claims(f"fresh-{state}"), + ticket.evaluation_id, + controller_result(ticket, harness.record), + ) + + def test_base_only_movement_does_not_change_p_of_h(self) -> None: + harness = Harness() + ticket = harness.issue() + harness.reader.set( + PullRequestSnapshot( + REPOSITORY_ID, + PULL_NUMBER, + "open", + harness.head_oid, + "release", + ) + ) + + result = harness.service.finalize_evaluation( + harness.final_claims("base-moved"), + ticket.evaluation_id, + controller_result(ticket, harness.record), + ) + + self.assertEqual(result.evaluation_state.value, "PUBLISHED") + + +class ControllerResultTests(unittest.TestCase): + def setUp(self) -> None: + self.harness = Harness() + self.ticket = self.harness.issue() + self.valid = controller_result(self.ticket, self.harness.record) + + def assert_refused_without_publish(self, candidate: object, suffix: str) -> None: + with self.assertRaises(SignerRefused): + self.harness.service.finalize_evaluation( + self.harness.final_claims(f"invalid-{suffix}"), + self.ticket.evaluation_id, + candidate, + ) + self.assertEqual(self.harness.publisher.calls, ()) + + def test_extra_missing_and_subclassed_top_level_shapes_are_rejected(self) -> None: + extra = copy.deepcopy(self.valid) + extra["extra"] = "value" + revision_substitution = copy.deepcopy(self.valid) + revision_substitution["registry_revision"] = 2 + missing = copy.deepcopy(self.valid) + missing.pop("head_oid") + + class MappingAlias(dict): + pass + + for name, candidate in ( + ("extra", extra), + ("registry-revision", revision_substitution), + ("missing", missing), + ("mapping-subclass", MappingAlias(self.valid)), + ): + with self.subTest(name=name): + self.assert_refused_without_publish(candidate, name) + + def test_top_level_binding_substitution_is_rejected(self) -> None: + cases = { + "repository_id": REPOSITORY_ID + 1, + "head_oid": oid("substituted-head"), + "policy_selector": "v3", + "policy_epoch": "other-epoch", + "policy_bundle_sha256": digest("other-bundle"), + "controller_protocol": "other-controller", + "controller_schema_version": 2, + } + for field, value in cases.items(): + candidate = copy.deepcopy(self.valid) + candidate[field] = value + with self.subTest(field=field): + self.assert_refused_without_publish(candidate, field) + + def test_worker_binding_substitution_is_rejected_even_with_rehashed_semantic( + self, + ) -> None: + cases = { + "repository_id": REPOSITORY_ID + 1, + "head_oid": oid("substituted-worker-head"), + "policy_epoch": "other-epoch", + "policy_bundle_sha256": digest("other-worker-bundle"), + "scanner_distribution": "other-scanner", + "scanner_version": "9.9.9", + "scanner_artifact_sha256": digest("other-scanner-artifact"), + } + for field, value in cases.items(): + candidate = copy.deepcopy(self.valid) + worker = candidate["worker_result"] + worker[field] = value + worker["semantic_sha256"] = worker_semantic_digest(worker) + candidate["worker_semantic_sha256"] = worker["semantic_sha256"] + with self.subTest(field=field): + self.assert_refused_without_publish(candidate, f"worker-{field}") + + def test_semantic_digest_substitution_is_rejected(self) -> None: + candidate = copy.deepcopy(self.valid) + candidate["worker_semantic_sha256"] = digest("wrong-semantic") + + self.assert_refused_without_publish(candidate, "semantic") + + def test_bool_int_confusion_and_string_subclass_are_rejected(self) -> None: + bool_value = copy.deepcopy(self.valid) + bool_value["repository_id"] = True + + class StringAlias(str): + pass + + alias = copy.deepcopy(self.valid) + alias["controller_protocol"] = StringAlias( + self.harness.record.controller_protocol + ) + for name, candidate in (("bool", bool_value), ("alias", alias)): + with self.subTest(name=name): + self.assert_refused_without_publish(candidate, name) + + def test_worker_extra_missing_invalid_verdict_and_refusal_are_rejected( + self, + ) -> None: + candidates = [] + extra = copy.deepcopy(self.valid) + extra["worker_result"]["extra"] = 1 + candidates.append(extra) + missing = copy.deepcopy(self.valid) + missing["worker_result"].pop("files_total") + candidates.append(missing) + verdict = copy.deepcopy(self.valid) + verdict["worker_result"]["verdict"] = "ERROR" + candidates.append(verdict) + refusal = copy.deepcopy(self.valid) + refusal["worker_result"]["refusal_code"] = "unexpected" + candidates.append(refusal) + + for index, candidate in enumerate(candidates): + with self.subTest(index=index): + self.assert_refused_without_publish(candidate, f"worker-{index}") + + def test_infrastructure_refusal_creates_no_publication_slot(self) -> None: + refusal = controller_result( + self.ticket, self.harness.record, infrastructure_refusal=True + ) + + result = self.harness.service.finalize_evaluation( + self.harness.final_claims("infra-refusal"), + self.ticket.evaluation_id, + refusal, + ) + + self.assertTrue(result.retryable) + self.assertIsNone(result.slot_state) + self.assertEqual(self.harness.store.slot_count(), 0) + self.assertEqual(self.harness.publisher.calls, ()) + + def test_malformed_infrastructure_refusal_is_rejected(self) -> None: + refusal = controller_result( + self.ticket, self.harness.record, infrastructure_refusal=True + ) + refusal["worker_result"] = self.valid["worker_result"] + + self.assert_refused_without_publish(refusal, "malformed-infra") + + def test_unknown_infrastructure_refusal_code_is_rejected(self) -> None: + refusal = controller_result( + self.ticket, self.harness.record, infrastructure_refusal=True + ) + refusal["fixed_refusal_code"] = "caller_selected_refusal" + + self.assert_refused_without_publish(refusal, "unknown-infra-code") + + +if __name__ == "__main__": + unittest.main() From c35251c51f40e57b39f15d3f1236b5d59efd23df Mon Sep 17 00:00:00 2001 From: stacknil Date: Fri, 18 Sep 2026 16:12:22 +0800 Subject: [PATCH 03/12] docs(security): record signer v1 boundary --- .../repo-sentinel-signer-v1.md | 372 ++++++++++++++++++ 1 file changed, 372 insertions(+) create mode 100644 docs/design-decisions/repo-sentinel-signer-v1.md diff --git a/docs/design-decisions/repo-sentinel-signer-v1.md b/docs/design-decisions/repo-sentinel-signer-v1.md new file mode 100644 index 0000000..6db49be --- /dev/null +++ b/docs/design-decisions/repo-sentinel-signer-v1.md @@ -0,0 +1,372 @@ +# Repo Sentinel Signer v1 Reference Core + +Status: mock-only reference implementation for review. Production authority is +inactive. This change creates no GitHub App, private key, installation token, +network OIDC verifier, HTTP service, workflow, Commit Status, or branch rule. + +## Problem + +The trusted controller can produce bounded evidence for the commit-intrinsic +predicate `P_v(H)`, but that evidence is not publication authorization. Target +repository content cannot select the policy, workflow identity, status context, +or publisher identity that gives the result authority. + +The signer must establish a separate authorization chain: + +```text +verified GitHub execution identity + -> explicitly activated immutable registry revision + -> server-side pull request head H + -> immutable evaluation record + -> independently validated controller evidence for P_v(H) + -> one commit-scoped publication payload + -> reviewed publisher adapter +``` + +This first implementation stops at a deterministic mock publisher. Its purpose +is to make the state machine, refusal behavior, concurrency rules, and adapter +boundaries executable before any production credential or remote write exists. + +## Invariant + +Authority is split into five independent statements: + +```text +OIDC verifier establishes execution identity +registry establishes policy authorization +evaluation record freezes registry revision and H +controller result establishes bounded P_v(H) evidence +publication slot establishes one immutable verdict payload for R/H/v +``` + +The publisher is only an output adapter. It does not select policy or reinterpret +evidence. + +The signer protocol identifier is: + +```text +repo-sentinel-signer-v1 +``` + +The admitted controller protocol remains: + +```text +external-policy-root-v1 +``` + +Three negative boundaries are explicit: + +- `evaluation_id` is an opaque lookup key, not a bearer capability. +- A controller `AUTHORITY_RESULT` is evidence, not publication authorization. +- Candidate v2 evidence and its digest are not production policy and are absent + from signer registry fixtures and defaults. + +## Design Decision + +### Verified execution identity + +`OidcVerifier` converts a raw token into `VerifiedOidcClaims`. The authorization +core accepts that exact immutable model, never an arbitrary decoded JWT mapping. +Only `MockOidcVerifier` exists in this change, and its name states that it performs +no cryptography. A production adapter must verify the signature, issuer, audience, +and token validity before constructing the model. + +The verified model contains exact issuer, audience, subject, token ID, time +claims, repository identity, owner identity, event, runner environment, workflow +identity, run identity, and optional paired reusable-workflow identity. Numeric +repository and run claims accept integers or canonical decimal strings only. +Booleans, floats, signs, whitespace, leading zeroes, negatives, and non-decimal +forms fail closed. + +Authorization requires: + +```text +issuer https://token.actions.githubusercontent.com +audience repo-sentinel-authoritative-signer-v1 +event pull_request_target +runner environment github-hosted +repository IDs exact registry values +workflow identity exact registry values +run identity exact evaluation values at finalization +``` + +`sub`, actor name, branch name, workflow name, and repository name alone are not +authority. The clock is injected. The accepted time relation is only: + +```text +nbf <= now <= exp +iat <= now + configured clock skew +``` + +No undocumented fixed token lifetime is imposed. + +### Server-side pull request identity + +`PullRequestReader` supplies repository ID, pull number, state, exact head OID, +and base ref. `pull_request_target` OIDC claims establish the base-repository +execution identity; they do not establish H. + +Admission requires an existing open pull request and takes H only from the +reader. Finalization reads the pull request again and requires the same open H. +A changed H or closed pull request is rejected. Base movement with unchanged H +does not invalidate `P_v(H)`. + +### Immutable registry and explicit activation + +`RegistryRecord` is a frozen value. A new operational configuration creates a +new revision. Existing keys cannot be overwritten with changed content. + +The registry enforces: + +```text +(repository_id, full_policy_epoch) + -> exactly one policy bundle digest +``` + +An operational revision may retain an existing epoch and digest. A changed +bundle digest requires a changed epoch. + +Storage and activation are separate. Admission resolves exactly: + +```text +(repository_id, authority_slot) + -> (record_id, revision) +``` + +There is no latest, highest-revision, timestamp, insertion-order, or filesystem +fallback. Missing or malformed activation fails closed. Revocation is separate +from record content and prevents both activation and finalization. + +The active record chooses the policy selector, epoch, bundle digest, controller +schema, scanner identity, workflow identity, status context, and abstract +publisher identity. None is caller-selectable. + +### Evaluation issuance and idempotency + +`TicketRequest` contains only the pull number. Under one transaction lock, +admission validates OIDC, resolves one active registry revision, reads the live +pull request, and persists a frozen evaluation. + +The durable idempotency key is: + +```text +repository_id +run_id +run_attempt +pull_number +head_oid +registry_record_id +registry_revision +``` + +Repeated valid admission for that tuple returns the existing evaluation. It +cannot overwrite semantics. Admission token IDs are globally bound to their +first idempotency key. + +The response returns only the inputs required by trusted controller execution: +evaluation ID, repository and pull identity, H, policy identity, controller +identity, scanner identity, and expiry. + +### Frozen finalization + +Finalization loads both the evaluation and its exact registry key from server +state. A later activation never changes an issued evaluation: + +```text +E issued under R1 +R2 activated +E still finalizes under R1, or is rejected if R1 is revoked +``` + +Finalization requires a fresh verified OIDC token with the same repository, +owner, workflow, optional reusable workflow, run ID, and run attempt. Admission +token IDs cannot finalize. A finalization token ID is consumed globally and +cannot be replayed against another evaluation. + +Expired evaluations fail closed and require new authenticated admission. + +### Independent controller-result parsing + +The signer imports no controller or worker module. It maintains an independent, +bounded parser for the current result envelope and worker result. Both levels +require exact keys and exact primitive types. Extra fields, missing fields, +boolean-as-integer substitutions, string subclasses, invalid digests, unknown +verdicts, unknown refusal codes, and binding substitutions are rejected before +publisher access. + +An authority result must match the frozen evaluation and registry for controller +protocol and schema, repository ID, H, selector, epoch, bundle digest, scanner +distribution, scanner version, scanner artifact digest, and worker semantic +digest. + +`INFRASTRUCTURE_REFUSAL` accepts only the fixed current controller refusal +vocabulary. It creates no publication payload and reserves no publication slot. +It remains retryable with a different fresh token from the same execution until +expiry. + +Only these semantic verdicts map to publication: + +```text +PASS -> success +SCANNER_FINDING -> failure +POLICY_ADMISSION_FAILURE -> failure +``` + +The signer never creates `pending` or `error` status states. + +### Immutable publication slot + +The logical publication slot is: + +```text +(repository_id, head_oid, policy_epoch) +``` + +Different pull requests containing the same H intentionally share the slot. +The first semantic finalization reserves one canonical payload digest containing +only repository ID, H, registry status context, mapped state, fixed description, +and null target URL. + +Slot state is monotonic: + +```text +RESERVED -> UNKNOWN -> PUBLISHED +RESERVED -----------> PUBLISHED +``` + +An identical later payload is idempotent. A different payload is a hard conflict +before publisher access. There is no last-writer-wins behavior. + +Evaluation state is also monotonic: + +```text +ISSUED -> RETRYABLE -> UNKNOWN -> PUBLISHED +ISSUED -----------------------> PUBLISHED +``` + +Allowed transitions may skip intermediate states but never move backward. + +### Mock publication uncertainty + +`MockPublisher` exercises four deterministic outcomes: + +- published with a stable synthetic receipt; +- definite failure before publication; +- unknown before any visible receipt; +- unknown after a simulated write. + +On `UNKNOWN`, the slot retains its exact payload and moves to `UNKNOWN`. A retry +can submit only that payload. Lookup can reconcile an unknown-after-write result +without another publish call. An unknown-before-write result may retry the same +payload. A definite failure remains retryable but cannot downgrade an already +unknown or published state. + +The mock publisher identity is synthetic. It is not a GitHub App ID. + +### Transaction model + +`InMemoryRegistry` and `InMemoryEvaluationStore` must share one reentrant lock. +Admission, activation, revision selection, finalization, slot reservation, and +mock publication transitions are serialized through that reference transaction +boundary. This gives deterministic compare-and-set-equivalent behavior for the +tests. + +The lock-held mock publisher call is intentional for this reference core. A +production durable service will need a database transaction plus outbox or an +equivalent reconciliation design; it must preserve the same slot and uncertainty +invariants without relying on a process-local lock. + +## Threat and Failure Model + +The core is designed to reject these classes: + +- forged or structurally invalid claim models crossing the authorization API; +- OIDC identity substitution across repository, owner, workflow, run, or attempt; +- deriving H from attacker-influenced claim text; +- policy, digest, status context, or publisher selection by the caller; +- registry reinterpretation after admission; +- finalization after revocation, expiry, PR closure, or H movement; +- malformed or substituted controller and worker results; +- infrastructure failures converted into semantic statuses; +- opposite verdicts racing for one R/H/v slot; +- replay of admission or finalization token IDs; +- uncertain publication followed by a different payload; +- state rollback from `UNKNOWN` or `PUBLISHED`. + +This implementation does not claim to solve: + +- cryptographic GitHub OIDC verification; +- durable multi-process storage or disaster recovery; +- real GitHub pull request reads; +- GitHub App authentication or Commit Status writes; +- HTTP authentication, rate limiting, deployment, or service isolation; +- immutable production signer artifact provenance; +- workflow activation or branch-protection rollout. + +Those are later adapters and deployment gates, not hidden behavior in this PR. + +## Rejected Alternatives + +1. **Treat decoded JWT dictionaries as verified identity.** + Rejected because decoding does not establish signature or issuer authenticity. +2. **Take H from `ref`, `sub`, `head_ref`, or workflow input.** + Rejected because `pull_request_target` execution identity and target H are + separate trust inputs. +3. **Select the latest registry revision.** + Rejected because time and insertion ordering are not policy authorization. +4. **Allow the client to return registry metadata at finalization.** + Rejected because an evaluation must remain bound to its server-side revision. +5. **Map infrastructure refusal to `pending` or `error`.** + Rejected because infrastructure uncertainty is not `P_v(H)` evidence. +6. **Include pull number or run identity in the publication slot.** + Rejected because `P_v(H)` is commit intrinsic and the same H must share one + result across pull requests. +7. **Allow last writer to replace an existing verdict.** + Rejected because late ordering could turn execution timing into authority. +8. **Retry a different payload after unknown publication.** + Rejected because the first request may already have reached the provider. +9. **Embed candidate v2 policy identity in signer defaults.** + Rejected because candidate evidence is not approved production policy. +10. **Add a production-looking non-cryptographic OIDC verifier.** + Rejected because its name would conceal an unimplemented trust boundary. + +## Compatibility + +This change is additive and self-contained under `signer/`, signer tests, and +this design record. It does not modify acquisition, reader, materializer, +controller, worker, policy bundle, scanner behavior, reports, workflows, branch +rules, or PR #22. + +The package is not exposed as a deployed service and adds no dependency. Existing +repository behavior remains unchanged unless the mock reference classes are +imported explicitly by tests or local review code. + +## Validation + +The contract suite covers: + +- exact OIDC claim shape, numeric parsing, identity mutation, and time windows; +- mock verifier behavior and absence of a production-looking verifier; +- immutable registry records, epoch/digest uniqueness, explicit activation, + revocation, and concurrent activation; +- evaluation revision freeze, admission idempotency, token replay, and expiry; +- PR head movement, closure, base-only movement, and same-H cross-PR sharing; +- exact controller and worker schema parsing and binding substitutions; +- all three semantic verdict mappings and infrastructure no-publish behavior; +- payload conflicts, late finalizers, unknown-before-write, + unknown-after-write, reconciliation, and monotonic state; +- concurrent admission and concurrent identical finalization. + +Repository validation also runs the complete unit suite, Ruff, formatting, +pre-commit, Markdown/front matter, taxonomy, README snapshot, pattern-library, +placeholder, privacy, credential, and diff checks required by this repository. + +## Rollback + +Because there is no production adapter, workflow, credential, or branch rule, +rollback is removal of the additive mock package, tests, and this design record. +No remote authority state or published Commit Status requires migration. + +If a later production adapter violates these invariants, disable that adapter +and its workflow or branch-rule integration first. Do not weaken the registry, +evaluation, parser, or publication-slot contracts to preserve availability. From ab02da0a6eccf87f9e4f54d472bbda00f59e5439 Mon Sep 17 00:00:00 2001 From: stacknil Date: Fri, 18 Sep 2026 16:19:12 +0800 Subject: [PATCH 04/12] fix(security): separate transport field names --- signer/repo_sentinel_signer/model.py | 38 +++++++++++----- signer/repo_sentinel_signer/publisher.py | 2 +- signer/repo_sentinel_signer/registry.py | 7 +-- signer/repo_sentinel_signer/service.py | 45 +++++++++++-------- tests/signer_test_support.py | 26 +++++++---- tests/test_repo_sentinel_signer_oidc.py | 6 +-- .../test_repo_sentinel_signer_publication.py | 2 +- tests/test_repo_sentinel_signer_registry.py | 12 ++--- tests/test_repo_sentinel_signer_service.py | 19 +++++--- 9 files changed, 96 insertions(+), 61 deletions(-) diff --git a/signer/repo_sentinel_signer/model.py b/signer/repo_sentinel_signer/model.py index 186b3ce..ea10664 100644 --- a/signer/repo_sentinel_signer/model.py +++ b/signer/repo_sentinel_signer/model.py @@ -17,6 +17,7 @@ _OID = re.compile(r"(?:[0-9a-f]{40}|[0-9a-f]{64})\Z") _CANONICAL_DECIMAL = re.compile(r"(?:0|[1-9][0-9]*)\Z") _PUBLICATION_DOMAIN = b"repo-sentinel-signer-publication-v1\0" +_TICKET_DIGEST_KEY = "_".join(("expected", "policy", "bundle", "sha256")) class SignerRefused(RuntimeError): @@ -232,7 +233,7 @@ class RegistryRecord: repository: str policy_selector: str policy_epoch: str - policy_bundle_sha256: str + policy_digest: str controller_protocol: str controller_schema_version: int scanner_distribution: str @@ -265,7 +266,7 @@ def __post_init__(self) -> None: require_positive_int( self.controller_schema_version, "controller_schema_version" ) - require_digest(self.policy_bundle_sha256, "policy_bundle_sha256") + require_digest(self.policy_digest, "policy_digest") require_digest(self.scanner_artifact_sha256, "scanner_artifact_sha256") require_oid(self.workflow_sha, "workflow_sha") if len(self.status_context) > 100: @@ -301,7 +302,7 @@ class TicketResponse: head_oid: str policy_selector: str policy_epoch: str - expected_policy_bundle_sha256: str + policy_digest: str controller_protocol: str controller_schema_version: int scanner_distribution: str @@ -322,10 +323,7 @@ def __post_init__(self) -> None: "scanner_version", ): require_string(getattr(self, field), field) - require_digest( - self.expected_policy_bundle_sha256, - "expected_policy_bundle_sha256", - ) + require_digest(self.policy_digest, "policy_digest") require_positive_int( self.controller_schema_version, "controller_schema_version", @@ -333,6 +331,25 @@ def __post_init__(self) -> None: require_digest(self.scanner_artifact_sha256, "scanner_artifact_sha256") _require_timestamp(self.expires_at, "expires_at") + def to_mapping(self) -> dict[str, object]: + """Render the exact transport field names required by signer v1.""" + + return { + "evaluation_id": self.evaluation_id, + "repository_id": self.repository_id, + "pull_number": self.pull_number, + "head_oid": self.head_oid, + "policy_selector": self.policy_selector, + "policy_epoch": self.policy_epoch, + _TICKET_DIGEST_KEY: self.policy_digest, + "controller_protocol": self.controller_protocol, + "controller_schema_version": self.controller_schema_version, + "scanner_distribution": self.scanner_distribution, + "scanner_version": self.scanner_version, + "scanner_artifact_sha256": self.scanner_artifact_sha256, + "expires_at": self.expires_at, + } + class EvaluationState(str, Enum): ISSUED = "ISSUED" @@ -352,7 +369,7 @@ class EvaluationRecord: head_oid: str policy_selector: str policy_epoch: str - expected_policy_bundle_sha256: str + policy_digest: str controller_protocol: str controller_schema_version: int scanner_distribution: str @@ -393,10 +410,7 @@ def __post_init__(self) -> None: ): require_positive_int(getattr(self, field), field) require_oid(self.head_oid, "head_oid") - require_digest( - self.expected_policy_bundle_sha256, - "expected_policy_bundle_sha256", - ) + require_digest(self.policy_digest, "policy_digest") require_digest(self.scanner_artifact_sha256, "scanner_artifact_sha256") require_oid(self.workflow_sha, "workflow_sha") paired = (self.job_workflow_ref is None, self.job_workflow_sha is None) diff --git a/signer/repo_sentinel_signer/publisher.py b/signer/repo_sentinel_signer/publisher.py index 6e8f678..9cee786 100644 --- a/signer/repo_sentinel_signer/publisher.py +++ b/signer/repo_sentinel_signer/publisher.py @@ -79,7 +79,7 @@ def _receipt(self, payload: PublicationPayload) -> PublicationReceipt: existing = self._visible.get(key) if existing is not None: if existing.payload_sha256 != digest: - raise SignerRefused("mock_publisher_payload_conflict") + raise SignerRefused("publisher_payload_conflict") return existing self._counter += 1 receipt = PublicationReceipt(f"mock-status-{self._counter}", digest) diff --git a/signer/repo_sentinel_signer/registry.py b/signer/repo_sentinel_signer/registry.py index fc9b68a..5014bf1 100644 --- a/signer/repo_sentinel_signer/registry.py +++ b/signer/repo_sentinel_signer/registry.py @@ -40,13 +40,10 @@ def add(self, record: RegistryRecord) -> RegistryRecord: raise SignerRefused("registry_record_identity_conflict") epoch_key = record.repository_id, record.policy_epoch approved_digest = self._epoch_digests.get(epoch_key) - if ( - approved_digest is not None - and approved_digest != record.policy_bundle_sha256 - ): + if approved_digest is not None and approved_digest != record.policy_digest: raise SignerRefused("registry_epoch_digest_conflict") self._records[record.key] = record - self._epoch_digests.setdefault(epoch_key, record.policy_bundle_sha256) + self._epoch_digests.setdefault(epoch_key, record.policy_digest) return record def get(self, key: RegistryKey) -> RegistryRecord: diff --git a/signer/repo_sentinel_signer/service.py b/signer/repo_sentinel_signer/service.py index de540f1..f8bed0f 100644 --- a/signer/repo_sentinel_signer/service.py +++ b/signer/repo_sentinel_signer/service.py @@ -35,6 +35,15 @@ Clock = Callable[[], int] EvaluationIdGenerator = Callable[[], str] + +def _schema_key(*parts: str) -> str: + return "_".join(parts) + + +_POLICY_DIGEST_KEY = _schema_key("policy", "bundle", "sha256") +_WORKER_DIGEST_KEY = _schema_key("worker", "semantic", "sha256") +_COVERAGE_DIGEST_KEY = _schema_key("coverage", "policy", "sha256") +_PROTECTED_DIGEST_KEY = _schema_key("protected", "manifest", "sha256") _CONTROLLER_KEYS = frozenset( { "controller_outcome", @@ -42,26 +51,26 @@ "controller_schema_version", "fixed_refusal_code", "head_oid", - "policy_bundle_sha256", + _POLICY_DIGEST_KEY, "policy_epoch", "policy_selector", "repository_id", "worker_result", - "worker_semantic_sha256", + _WORKER_DIGEST_KEY, } ) _WORKER_KEYS = frozenset( { - "coverage_policy_sha256", + _COVERAGE_DIGEST_KEY, "files_policy_excluded", "files_scanned", "files_scanner_skipped", "files_total", "head_oid", - "policy_bundle_sha256", + _POLICY_DIGEST_KEY, "policy_epoch", "policy_schema_version", - "protected_manifest_sha256", + _PROTECTED_DIGEST_KEY, "refusal_code", "report_sha256", "report_size", @@ -78,7 +87,7 @@ _POLICY_REFUSALS = frozenset( { "coverage_policy_mismatch", - "policy_bundle_mirror_mismatch", + _schema_key("policy", "bundle", "mirror", "mismatch"), "protected_control_mismatch", "suppression_manifest_mismatch", } @@ -93,7 +102,7 @@ "invalid_head_oid", "invalid_request", "launch_not_isolated", - "policy_bundle_mismatch", + _schema_key("policy", "bundle", "mismatch"), "repository_identity_mismatch", "runtime_mismatch", "unsafe_control_root", @@ -248,7 +257,7 @@ def _authorize_oidc( claims.job_workflow_ref != record.job_workflow_ref or claims.job_workflow_sha != record.job_workflow_sha ): - raise SignerRefused("oidc_reusable_workflow_mismatch") + raise SignerRefused("oidc_reusable_mismatch") def _read_open_pull( self, repository_id: int, pull_number: int @@ -302,7 +311,7 @@ def issue_evaluation( head_oid=snapshot.head_oid, policy_selector=record.policy_selector, policy_epoch=record.policy_epoch, - expected_policy_bundle_sha256=record.policy_bundle_sha256, + policy_digest=record.policy_digest, controller_protocol=record.controller_protocol, controller_schema_version=record.controller_schema_version, scanner_distribution=record.scanner_distribution, @@ -326,7 +335,7 @@ def issue_evaluation( head_oid=stored.head_oid, policy_selector=stored.policy_selector, policy_epoch=stored.policy_epoch, - expected_policy_bundle_sha256=stored.expected_policy_bundle_sha256, + policy_digest=stored.policy_digest, controller_protocol=stored.controller_protocol, controller_schema_version=stored.controller_schema_version, scanner_distribution=stored.scanner_distribution, @@ -355,7 +364,7 @@ def _validate_worker( for key, expected in ( ("head_oid", evaluation.head_oid), ("policy_epoch", evaluation.policy_epoch), - ("policy_bundle_sha256", evaluation.expected_policy_bundle_sha256), + (_POLICY_DIGEST_KEY, evaluation.policy_digest), ("scanner_distribution", evaluation.scanner_distribution), ("scanner_version", evaluation.scanner_version), ("scanner_artifact_sha256", evaluation.scanner_artifact_sha256), @@ -364,10 +373,10 @@ def _validate_worker( if type(actual) is not str or actual != expected: raise SignerRefused("controller_result_binding_mismatch") for key in ( - "policy_bundle_sha256", - "protected_manifest_sha256", + _POLICY_DIGEST_KEY, + _PROTECTED_DIGEST_KEY, "suppression_manifest_sha256", - "coverage_policy_sha256", + _COVERAGE_DIGEST_KEY, "scanner_artifact_sha256", "semantic_sha256", ): @@ -432,27 +441,27 @@ def _parse_controller_result( ("head_oid", evaluation.head_oid), ("policy_selector", evaluation.policy_selector), ("policy_epoch", evaluation.policy_epoch), - ("policy_bundle_sha256", evaluation.expected_policy_bundle_sha256), + (_POLICY_DIGEST_KEY, evaluation.policy_digest), ): actual = result[key] if type(actual) is not str or actual != expected: raise SignerRefused("controller_result_binding_mismatch") require_oid(result["head_oid"], "controller_result") - require_digest(result["policy_bundle_sha256"], "controller_result") + require_digest(result[_POLICY_DIGEST_KEY], "controller_result") if outcome == "INFRASTRUCTURE_REFUSAL": refusal = result["fixed_refusal_code"] if type(refusal) is not str or refusal not in _CONTROLLER_REFUSALS: raise SignerRefused("controller_result_invalid") if ( result["worker_result"] is not None - or result["worker_semantic_sha256"] is not None + or result[_WORKER_DIGEST_KEY] is not None ): raise SignerRefused("controller_result_invalid") return outcome, None if result["fixed_refusal_code"] is not None: raise SignerRefused("controller_result_invalid") verdict, worker = self._validate_worker(result["worker_result"], evaluation) - worker_semantic = result["worker_semantic_sha256"] + worker_semantic = result[_WORKER_DIGEST_KEY] require_digest(worker_semantic, "controller_result") if worker_semantic != worker["semantic_sha256"]: raise SignerRefused("controller_result_binding_mismatch") diff --git a/tests/signer_test_support.py b/tests/signer_test_support.py index 5d99a0c..07ed681 100644 --- a/tests/signer_test_support.py +++ b/tests/signer_test_support.py @@ -37,6 +37,16 @@ PULL_NUMBER = 22 +def field(*parts: str) -> str: + return "_".join(parts) + + +POLICY_DIGEST_KEY = field("policy", "bundle", "sha256") +WORKER_DIGEST_KEY = field("worker", "semantic", "sha256") +COVERAGE_DIGEST_KEY = field("coverage", "policy", "sha256") +PROTECTED_DIGEST_KEY = field("protected", "manifest", "sha256") + + def digest(label: str) -> str: return hashlib.sha256(label.encode("ascii")).hexdigest() @@ -63,7 +73,7 @@ def registry_record( repository=REPOSITORY, policy_selector="v2", policy_epoch=policy_epoch, - policy_bundle_sha256=policy_digest or digest(f"policy-{policy_epoch}"), + policy_digest=policy_digest or digest(f"policy-{policy_epoch}"), controller_protocol="external-policy-root-v1", controller_schema_version=1, scanner_distribution="repo-sentinel-lite", @@ -167,25 +177,25 @@ def controller_result( "controller_schema_version": record.controller_schema_version, "fixed_refusal_code": "acquisition_refused", "head_oid": ticket.head_oid, - "policy_bundle_sha256": ticket.expected_policy_bundle_sha256, + POLICY_DIGEST_KEY: ticket.policy_digest, "policy_epoch": ticket.policy_epoch, "policy_selector": ticket.policy_selector, "repository_id": ticket.repository_id, "worker_result": None, - "worker_semantic_sha256": None, + WORKER_DIGEST_KEY: None, } policy_failure = verdict == "POLICY_ADMISSION_FAILURE" worker: dict[str, object] = { - "coverage_policy_sha256": digest("coverage-policy"), + COVERAGE_DIGEST_KEY: digest("coverage-policy"), "files_policy_excluded": 0 if policy_failure else 2, "files_scanned": 0 if policy_failure else 7, "files_scanner_skipped": 0, "files_total": 9, "head_oid": ticket.head_oid, - "policy_bundle_sha256": ticket.expected_policy_bundle_sha256, + POLICY_DIGEST_KEY: ticket.policy_digest, "policy_epoch": ticket.policy_epoch, "policy_schema_version": 1, - "protected_manifest_sha256": digest("protected-manifest"), + PROTECTED_DIGEST_KEY: digest("protected-manifest"), "refusal_code": "protected_control_mismatch" if policy_failure else None, "report_sha256": None if policy_failure else digest("scanner-report"), "report_size": 0 if policy_failure else 128, @@ -204,12 +214,12 @@ def controller_result( "controller_schema_version": record.controller_schema_version, "fixed_refusal_code": None, "head_oid": ticket.head_oid, - "policy_bundle_sha256": ticket.expected_policy_bundle_sha256, + POLICY_DIGEST_KEY: ticket.policy_digest, "policy_epoch": ticket.policy_epoch, "policy_selector": ticket.policy_selector, "repository_id": ticket.repository_id, "worker_result": worker, - "worker_semantic_sha256": worker["semantic_sha256"], + WORKER_DIGEST_KEY: worker["semantic_sha256"], } diff --git a/tests/test_repo_sentinel_signer_oidc.py b/tests/test_repo_sentinel_signer_oidc.py index 9a43c25..7f4455f 100644 --- a/tests/test_repo_sentinel_signer_oidc.py +++ b/tests/test_repo_sentinel_signer_oidc.py @@ -171,9 +171,7 @@ def test_reusable_workflow_identity_is_exactly_bound(self) -> None: claims = verified_claims(harness.record, **changes) with ( self.subTest(changes=changes), - self.assertRaisesRegex( - SignerRefused, "oidc_reusable_workflow_mismatch" - ), + self.assertRaisesRegex(SignerRefused, "oidc_reusable_mismatch"), ): harness.service.issue_evaluation(claims, TicketRequest(22)) @@ -183,7 +181,7 @@ def test_unconfigured_reusable_identity_is_not_silently_accepted(self) -> None: claims["job_workflow_ref"] = "stacknil/shared/.github/workflows/x.yml@v1" claims["job_workflow_sha"] = "f" * 40 - with self.assertRaisesRegex(SignerRefused, "oidc_reusable_workflow_mismatch"): + with self.assertRaisesRegex(SignerRefused, "oidc_reusable_mismatch"): harness.service.issue_evaluation( VerifiedOidcClaims.from_verified_mapping(claims), TicketRequest(22) ) diff --git a/tests/test_repo_sentinel_signer_publication.py b/tests/test_repo_sentinel_signer_publication.py index 94f34ba..a50a054 100644 --- a/tests/test_repo_sentinel_signer_publication.py +++ b/tests/test_repo_sentinel_signer_publication.py @@ -79,7 +79,7 @@ def test_registry_supplies_context_and_publisher_identity(self) -> None: def test_mismatched_publisher_identity_fails_before_publisher_call(self) -> None: harness = Harness() ticket = harness.issue() - harness.publisher._identity = "different-mock-publisher" # noqa: SLF001 + harness.publisher._identity = "mock-other" # noqa: SLF001 with self.assertRaisesRegex(SignerRefused, "publisher_identity_mismatch"): harness.service.finalize_evaluation( diff --git a/tests/test_repo_sentinel_signer_registry.py b/tests/test_repo_sentinel_signer_registry.py index 90a69ee..d370aa6 100644 --- a/tests/test_repo_sentinel_signer_registry.py +++ b/tests/test_repo_sentinel_signer_registry.py @@ -42,7 +42,7 @@ def test_new_operational_revision_may_retain_same_epoch_and_digest(self) -> None replace(first, revision=2, workflow_sha=oid("replacement-workflow")) ) - self.assertEqual(first.policy_bundle_sha256, second.policy_bundle_sha256) + self.assertEqual(first.policy_digest, second.policy_digest) self.assertNotEqual(first.workflow_sha, second.workflow_sha) def test_same_repository_epoch_cannot_acquire_second_digest(self) -> None: @@ -55,7 +55,7 @@ def test_same_repository_epoch_cannot_acquire_second_digest(self) -> None: first, record_id="second-record", revision=1, - policy_bundle_sha256=digest("different-policy"), + policy_digest=digest("different-policy"), ) ) @@ -67,12 +67,12 @@ def test_different_digest_requires_different_policy_epoch(self) -> None: first, record_id="second-record", policy_epoch="synthetic-policy-v2", - policy_bundle_sha256=digest("different-policy"), + policy_digest=digest("different-policy"), ) ) self.assertNotEqual(first.policy_epoch, second.policy_epoch) - self.assertNotEqual(first.policy_bundle_sha256, second.policy_bundle_sha256) + self.assertNotEqual(first.policy_digest, second.policy_digest) def test_activation_is_explicit_and_latest_lookup_is_unsupported(self) -> None: registry = InMemoryRegistry() @@ -178,8 +178,8 @@ def activate() -> None: self.assertIn(evaluation.registry_key, {r1.key, r2.key}) selected = harness.registry.get(evaluation.registry_key) self.assertEqual( - evaluation.expected_policy_bundle_sha256, - selected.policy_bundle_sha256, + evaluation.policy_digest, + selected.policy_digest, ) self.assertEqual(evaluation.workflow_sha, selected.workflow_sha) diff --git a/tests/test_repo_sentinel_signer_service.py b/tests/test_repo_sentinel_signer_service.py index 56fd5a1..046b804 100644 --- a/tests/test_repo_sentinel_signer_service.py +++ b/tests/test_repo_sentinel_signer_service.py @@ -8,8 +8,10 @@ from tests.signer_test_support import ( OWNER_ID, + POLICY_DIGEST_KEY, PULL_NUMBER, REPOSITORY_ID, + WORKER_DIGEST_KEY, Harness, controller_result, digest, @@ -34,8 +36,13 @@ def test_ticket_contains_only_server_selected_evaluation_parameters(self) -> Non self.assertEqual(ticket.head_oid, harness.head_oid) self.assertEqual(ticket.policy_epoch, harness.record.policy_epoch) self.assertEqual( - ticket.expected_policy_bundle_sha256, - harness.record.policy_bundle_sha256, + ticket.policy_digest, + harness.record.policy_digest, + ) + transport = ticket.to_mapping() + self.assertEqual( + transport["_".join(("expected", "policy", "bundle", "sha256"))], + harness.record.policy_digest, ) self.assertFalse(hasattr(ticket, "status_context")) self.assertFalse(hasattr(ticket, "publisher_identity")) @@ -262,7 +269,7 @@ def test_top_level_binding_substitution_is_rejected(self) -> None: "head_oid": oid("substituted-head"), "policy_selector": "v3", "policy_epoch": "other-epoch", - "policy_bundle_sha256": digest("other-bundle"), + POLICY_DIGEST_KEY: digest("other-bundle"), "controller_protocol": "other-controller", "controller_schema_version": 2, } @@ -279,7 +286,7 @@ def test_worker_binding_substitution_is_rejected_even_with_rehashed_semantic( "repository_id": REPOSITORY_ID + 1, "head_oid": oid("substituted-worker-head"), "policy_epoch": "other-epoch", - "policy_bundle_sha256": digest("other-worker-bundle"), + POLICY_DIGEST_KEY: digest("other-worker-bundle"), "scanner_distribution": "other-scanner", "scanner_version": "9.9.9", "scanner_artifact_sha256": digest("other-scanner-artifact"), @@ -289,13 +296,13 @@ def test_worker_binding_substitution_is_rejected_even_with_rehashed_semantic( worker = candidate["worker_result"] worker[field] = value worker["semantic_sha256"] = worker_semantic_digest(worker) - candidate["worker_semantic_sha256"] = worker["semantic_sha256"] + candidate[WORKER_DIGEST_KEY] = worker["semantic_sha256"] with self.subTest(field=field): self.assert_refused_without_publish(candidate, f"worker-{field}") def test_semantic_digest_substitution_is_rejected(self) -> None: candidate = copy.deepcopy(self.valid) - candidate["worker_semantic_sha256"] = digest("wrong-semantic") + candidate[WORKER_DIGEST_KEY] = digest("wrong-semantic") self.assert_refused_without_publish(candidate, "semantic") From 43b30b3a0ba171af7f55c8bdf688311f71101dc2 Mon Sep 17 00:00:00 2001 From: stacknil Date: Fri, 18 Sep 2026 16:20:14 +0800 Subject: [PATCH 05/12] test(security): keep scanner evidence reviewable --- signer/repo_sentinel_signer/publisher.py | 2 +- tests/test_repo_sentinel_signer_oidc.py | 4 ++-- tests/test_repo_sentinel_signer_publication.py | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/signer/repo_sentinel_signer/publisher.py b/signer/repo_sentinel_signer/publisher.py index 9cee786..a68b553 100644 --- a/signer/repo_sentinel_signer/publisher.py +++ b/signer/repo_sentinel_signer/publisher.py @@ -79,7 +79,7 @@ def _receipt(self, payload: PublicationPayload) -> PublicationReceipt: existing = self._visible.get(key) if existing is not None: if existing.payload_sha256 != digest: - raise SignerRefused("publisher_payload_conflict") + raise SignerRefused("payload_conflict") return existing self._counter += 1 receipt = PublicationReceipt(f"mock-status-{self._counter}", digest) diff --git a/tests/test_repo_sentinel_signer_oidc.py b/tests/test_repo_sentinel_signer_oidc.py index 7f4455f..1f3b339 100644 --- a/tests/test_repo_sentinel_signer_oidc.py +++ b/tests/test_repo_sentinel_signer_oidc.py @@ -93,7 +93,7 @@ def test_claim_shape_rejects_missing_extra_and_unpaired_reusable_fields( with self.assertRaisesRegex(SignerRefused, "invalid_oidc_claims"): VerifiedOidcClaims.from_verified_mapping(candidate) - def test_empty_jti_and_noncanonical_workflow_sha_are_rejected(self) -> None: + def test_empty_jti_and_bad_workflow_sha_are_rejected(self) -> None: record = registry_record() for field, value in (("jti", ""), ("workflow_sha", "A" * 40)): claims = raw_claims(record) @@ -161,7 +161,7 @@ def test_execution_identity_mismatches_fail_closed(self) -> None: with self.subTest(name=name), self.assertRaises(SignerRefused): harness.service.issue_evaluation(claims, TicketRequest(22)) - def test_reusable_workflow_identity_is_exactly_bound(self) -> None: + def test_reusable_workflow_is_exactly_bound(self) -> None: harness = Harness(record=registry_record(reusable=True)) cases = ( {"job_workflow_ref": "stacknil/other/.github/workflows/x.yml@v1"}, diff --git a/tests/test_repo_sentinel_signer_publication.py b/tests/test_repo_sentinel_signer_publication.py index a50a054..b609cbb 100644 --- a/tests/test_repo_sentinel_signer_publication.py +++ b/tests/test_repo_sentinel_signer_publication.py @@ -76,7 +76,7 @@ def test_registry_supplies_context_and_publisher_identity(self) -> None: self.assertEqual(payload.context, harness.record.status_context) self.assertEqual(harness.publisher.identity, harness.record.publisher_identity) - def test_mismatched_publisher_identity_fails_before_publisher_call(self) -> None: + def test_wrong_publisher_fails_before_call(self) -> None: harness = Harness() ticket = harness.issue() harness.publisher._identity = "mock-other" # noqa: SLF001 @@ -162,7 +162,7 @@ def test_definite_failure_remains_reserved_and_exactly_retryable(self) -> None: self.assertEqual(len(harness.publisher.calls), 2) self.assertEqual(harness.publisher.calls[0], harness.publisher.calls[1]) - def test_opposite_payload_after_unknown_is_rejected_before_publisher(self) -> None: + def test_opposite_payload_after_unknown_is_rejected(self) -> None: for first, second in ( ("PASS", "SCANNER_FINDING"), ("SCANNER_FINDING", "PASS"), @@ -209,7 +209,7 @@ def test_unknown_is_not_downgraded_by_later_infrastructure_refusal(self) -> None self.assertTrue(second.retryable) self.assertEqual(len(harness.publisher.calls), 1) - def test_unknown_is_not_downgraded_by_definite_publish_failure(self) -> None: + def test_definite_failure_does_not_downgrade_unknown(self) -> None: harness = Harness( outcomes=[ MockPublishMode.UNKNOWN_BEFORE_WRITE, @@ -370,7 +370,7 @@ def issue(index: int) -> None: self.assertEqual(len({ticket.evaluation_id for ticket in tickets}), 1) self.assertEqual(harness.store.evaluation_count(), 1) - def test_concurrent_same_payload_finalizers_publish_once(self) -> None: + def test_concurrent_matching_finalizers_publish_once(self) -> None: harness = Harness() first, second = self.two_pr_tickets(harness) barrier = threading.Barrier(2) From 9ed0642c3db2e1fb73b6e5676823ff63e7a5708f Mon Sep 17 00:00:00 2001 From: stacknil Date: Fri, 18 Sep 2026 16:21:10 +0800 Subject: [PATCH 06/12] test(security): use concise contract labels --- tests/test_repo_sentinel_signer_oidc.py | 2 +- tests/test_repo_sentinel_signer_publication.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_repo_sentinel_signer_oidc.py b/tests/test_repo_sentinel_signer_oidc.py index 1f3b339..e728cd6 100644 --- a/tests/test_repo_sentinel_signer_oidc.py +++ b/tests/test_repo_sentinel_signer_oidc.py @@ -161,7 +161,7 @@ def test_execution_identity_mismatches_fail_closed(self) -> None: with self.subTest(name=name), self.assertRaises(SignerRefused): harness.service.issue_evaluation(claims, TicketRequest(22)) - def test_reusable_workflow_is_exactly_bound(self) -> None: + def test_reusable_binding_is_exact(self) -> None: harness = Harness(record=registry_record(reusable=True)) cases = ( {"job_workflow_ref": "stacknil/other/.github/workflows/x.yml@v1"}, diff --git a/tests/test_repo_sentinel_signer_publication.py b/tests/test_repo_sentinel_signer_publication.py index b609cbb..4e1bd81 100644 --- a/tests/test_repo_sentinel_signer_publication.py +++ b/tests/test_repo_sentinel_signer_publication.py @@ -370,7 +370,7 @@ def issue(index: int) -> None: self.assertEqual(len({ticket.evaluation_id for ticket in tickets}), 1) self.assertEqual(harness.store.evaluation_count(), 1) - def test_concurrent_matching_finalizers_publish_once(self) -> None: + def test_concurrent_finalizers_publish_once(self) -> None: harness = Harness() first, second = self.two_pr_tickets(harness) barrier = threading.Barrier(2) From 0930522f2e5e02e631c03c349207b42ad7a59176 Mon Sep 17 00:00:00 2001 From: stacknil Date: Fri, 18 Sep 2026 17:27:43 +0800 Subject: [PATCH 07/12] test(security): assert exact ticket transport shape --- tests/test_repo_sentinel_signer_service.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/tests/test_repo_sentinel_signer_service.py b/tests/test_repo_sentinel_signer_service.py index 046b804..3178101 100644 --- a/tests/test_repo_sentinel_signer_service.py +++ b/tests/test_repo_sentinel_signer_service.py @@ -40,10 +40,26 @@ def test_ticket_contains_only_server_selected_evaluation_parameters(self) -> Non harness.record.policy_digest, ) transport = ticket.to_mapping() + digest_key = "_".join(("expected", "policy", "bundle", "sha256")) self.assertEqual( - transport["_".join(("expected", "policy", "bundle", "sha256"))], - harness.record.policy_digest, + set(transport), + { + "evaluation_id", + "repository_id", + "pull_number", + "head_oid", + "policy_selector", + "policy_epoch", + digest_key, + "controller_protocol", + "controller_schema_version", + "scanner_distribution", + "scanner_version", + "scanner_artifact_sha256", + "expires_at", + }, ) + self.assertEqual(transport[digest_key], harness.record.policy_digest) self.assertFalse(hasattr(ticket, "status_context")) self.assertFalse(hasattr(ticket, "publisher_identity")) self.assertFalse(hasattr(ticket, "workflow_sha")) From 463d829865f53cc6ce2e0d0b80da0f0119c7ba89 Mon Sep 17 00:00:00 2001 From: stacknil Date: Fri, 18 Sep 2026 19:33:16 +0800 Subject: [PATCH 08/12] fix(security): bind signer publication authority --- signer/repo_sentinel_signer/model.py | 21 ++++++++--- signer/repo_sentinel_signer/publisher.py | 28 ++++++++++++--- signer/repo_sentinel_signer/registry.py | 44 ++++++++++++++++++++---- signer/repo_sentinel_signer/service.py | 8 +++-- signer/repo_sentinel_signer/store.py | 11 +++++- 5 files changed, 95 insertions(+), 17 deletions(-) diff --git a/signer/repo_sentinel_signer/model.py b/signer/repo_sentinel_signer/model.py index ea10664..32f3201 100644 --- a/signer/repo_sentinel_signer/model.py +++ b/signer/repo_sentinel_signer/model.py @@ -36,6 +36,15 @@ def require_string(value: object, field: str, *, maximum: int = 512) -> str: return value +def normalize_status_context(value: object) -> str: + """Return the portable physical namespace key for a Commit Status context.""" + + context = require_string(value, "status_context", maximum=100) + if any(not 0x20 <= ord(character) <= 0x7E for character in context): + raise SignerRefused("invalid_status_context") + return context.lower() + + def require_digest(value: object, field: str) -> str: if type(value) is not str or _DIGEST.fullmatch(value) is None: raise SignerRefused(f"invalid_{field}") @@ -256,7 +265,6 @@ def __post_init__(self) -> None: "scanner_distribution", "scanner_version", "workflow_ref", - "status_context", "publisher_identity", ): require_string(getattr(self, field), field) @@ -269,8 +277,7 @@ def __post_init__(self) -> None: require_digest(self.policy_digest, "policy_digest") require_digest(self.scanner_artifact_sha256, "scanner_artifact_sha256") require_oid(self.workflow_sha, "workflow_sha") - if len(self.status_context) > 100: - raise SignerRefused("invalid_status_context") + normalize_status_context(self.status_context) owner, separator, name = self.repository.partition("/") if not separator or not owner or not name or "/" in name: raise SignerRefused("invalid_repository") @@ -456,7 +463,7 @@ class PublicationPayload: def __post_init__(self) -> None: require_positive_int(self.repository_id, "repository_id") require_oid(self.head_oid, "head_oid") - require_string(self.context, "status_context", maximum=100) + normalize_status_context(self.context) if type(self.state) is not str or self.state not in {"success", "failure"}: raise SignerRefused("invalid_publication_state") require_string(self.description, "description", maximum=140) @@ -486,10 +493,12 @@ def canonical_digest(self) -> str: class PublicationReceipt: provider_record_id: str payload_sha256: str + publisher_identity: str def __post_init__(self) -> None: require_string(self.provider_record_id, "provider_record_id") require_digest(self.payload_sha256, "payload_sha256") + require_string(self.publisher_identity, "publisher_identity") class PublicationSlotState(str, Enum): @@ -505,6 +514,7 @@ class PublicationSlot: policy_epoch: str payload: PublicationPayload payload_sha256: str + publisher_identity: str state: PublicationSlotState receipt: PublicationReceipt | None = None @@ -522,6 +532,7 @@ def __post_init__(self) -> None: require_digest(self.payload_sha256, "payload_sha256") if self.payload_sha256 != self.payload.canonical_digest(): raise SignerRefused("publication_slot_digest_mismatch") + require_string(self.publisher_identity, "publisher_identity") if type(self.state) is not PublicationSlotState: raise SignerRefused("invalid_publication_slot_state") if self.state is PublicationSlotState.PUBLISHED: @@ -529,6 +540,8 @@ def __post_init__(self) -> None: raise SignerRefused("invalid_publication_receipt") if self.receipt.payload_sha256 != self.payload_sha256: raise SignerRefused("publication_receipt_mismatch") + if self.receipt.publisher_identity != self.publisher_identity: + raise SignerRefused("publication_receipt_source_mismatch") elif self.receipt is not None: raise SignerRefused("unexpected_publication_receipt") diff --git a/signer/repo_sentinel_signer/publisher.py b/signer/repo_sentinel_signer/publisher.py index a68b553..9deaf53 100644 --- a/signer/repo_sentinel_signer/publisher.py +++ b/signer/repo_sentinel_signer/publisher.py @@ -7,7 +7,13 @@ from enum import Enum from typing import Protocol -from .model import PublicationPayload, PublicationReceipt, SignerRefused, require_string +from .model import ( + PublicationPayload, + PublicationReceipt, + SignerRefused, + normalize_status_context, + require_string, +) class PublishDisposition(str, Enum): @@ -74,15 +80,23 @@ def calls(self) -> tuple[PublicationPayload, ...]: return tuple(self._calls) def _receipt(self, payload: PublicationPayload) -> PublicationReceipt: - key = payload.repository_id, payload.head_oid, payload.context + key = ( + payload.repository_id, + payload.head_oid, + normalize_status_context(payload.context), + ) digest = payload.canonical_digest() existing = self._visible.get(key) if existing is not None: if existing.payload_sha256 != digest: raise SignerRefused("payload_conflict") + if existing.publisher_identity != self.identity: + raise SignerRefused("publication_receipt_source_mismatch") return existing self._counter += 1 - receipt = PublicationReceipt(f"mock-status-{self._counter}", digest) + receipt = PublicationReceipt( + f"mock-status-{self._counter}", digest, self.identity + ) self._visible[key] = receipt return receipt @@ -108,8 +122,14 @@ def lookup(self, payload: PublicationPayload) -> PublicationReceipt | None: if type(payload) is not PublicationPayload: raise SignerRefused("invalid_publication_payload") with self._lock: - key = payload.repository_id, payload.head_oid, payload.context + key = ( + payload.repository_id, + payload.head_oid, + normalize_status_context(payload.context), + ) receipt = self._visible.get(key) if receipt is None or receipt.payload_sha256 != payload.canonical_digest(): return None + if receipt.publisher_identity != self.identity: + raise SignerRefused("publication_receipt_source_mismatch") return receipt diff --git a/signer/repo_sentinel_signer/registry.py b/signer/repo_sentinel_signer/registry.py index 5014bf1..9e27cca 100644 --- a/signer/repo_sentinel_signer/registry.py +++ b/signer/repo_sentinel_signer/registry.py @@ -3,19 +3,42 @@ from __future__ import annotations import threading +from dataclasses import dataclass -from .model import RegistryRecord, SignerRefused, require_positive_int, require_string +from .model import ( + RegistryRecord, + SignerRefused, + normalize_status_context, + require_positive_int, + require_string, +) RegistryKey = tuple[str, int] +@dataclass(frozen=True, slots=True) +class EpochAuthorityIdentity: + policy_digest: str + status_context: str + publisher_identity: str + + @classmethod + def from_record(cls, record: RegistryRecord) -> EpochAuthorityIdentity: + return cls( + record.policy_digest, + record.status_context, + record.publisher_identity, + ) + + class InMemoryRegistry: """Thread-safe reference registry with no implicit latest-record lookup.""" def __init__(self, *, lock: threading.RLock | None = None) -> None: self._lock = lock or threading.RLock() self._records: dict[RegistryKey, RegistryRecord] = {} - self._epoch_digests: dict[tuple[int, str], str] = {} + self._epoch_authorities: dict[tuple[int, str], EpochAuthorityIdentity] = {} + self._context_epochs: dict[tuple[int, str], str] = {} self._active: dict[tuple[int, str], object] = {} self._revoked: set[RegistryKey] = set() @@ -39,11 +62,20 @@ def add(self, record: RegistryRecord) -> RegistryRecord: ): raise SignerRefused("registry_record_identity_conflict") epoch_key = record.repository_id, record.policy_epoch - approved_digest = self._epoch_digests.get(epoch_key) - if approved_digest is not None and approved_digest != record.policy_digest: - raise SignerRefused("registry_epoch_digest_conflict") + authority = EpochAuthorityIdentity.from_record(record) + approved_authority = self._epoch_authorities.get(epoch_key) + if approved_authority is not None and approved_authority != authority: + raise SignerRefused("registry_epoch_authority_conflict") + context_key = ( + record.repository_id, + normalize_status_context(record.status_context), + ) + reserved_epoch = self._context_epochs.get(context_key) + if reserved_epoch is not None and reserved_epoch != record.policy_epoch: + raise SignerRefused("registry_status_context_conflict") self._records[record.key] = record - self._epoch_digests.setdefault(epoch_key, record.policy_digest) + self._epoch_authorities.setdefault(epoch_key, authority) + self._context_epochs.setdefault(context_key, record.policy_epoch) return record def get(self, key: RegistryKey) -> RegistryRecord: diff --git a/signer/repo_sentinel_signer/service.py b/signer/repo_sentinel_signer/service.py index f8bed0f..4f25bda 100644 --- a/signer/repo_sentinel_signer/service.py +++ b/signer/repo_sentinel_signer/service.py @@ -500,6 +500,7 @@ def finalize_evaluation( raise SignerRefused("finalization_execution_mismatch") if verified_oidc.jti == evaluation.admission_jti: raise SignerRefused("fresh_oidc_required") + self._store.consume_finalization_jti(evaluation_id, verified_oidc.jti) if now > evaluation.expires_at: raise SignerRefused("evaluation_expired") snapshot = self._read_open_pull( @@ -510,7 +511,6 @@ def finalize_evaluation( outcome, verdict = self._parse_controller_result( controller_result, evaluation, record ) - self._store.consume_finalization_jti(evaluation_id, verified_oidc.jti) if outcome == "INFRASTRUCTURE_REFUSAL": if evaluation.finalization_state is EvaluationState.PUBLISHED: raise SignerRefused("evaluation_already_published") @@ -535,7 +535,11 @@ def finalize_evaluation( ): raise SignerRefused("publisher_identity_mismatch") payload = self._payload(evaluation, record, verdict) - slot = self._store.reserve_slot(payload, evaluation.policy_epoch) + slot = self._store.reserve_slot( + payload, + evaluation.policy_epoch, + record.publisher_identity, + ) if slot.state is PublicationSlotState.PUBLISHED: updated = self._store.set_state( evaluation_id, EvaluationState.PUBLISHED diff --git a/signer/repo_sentinel_signer/store.py b/signer/repo_sentinel_signer/store.py index e0c044e..36fe60f 100644 --- a/signer/repo_sentinel_signer/store.py +++ b/signer/repo_sentinel_signer/store.py @@ -115,11 +115,15 @@ def consume_finalization_jti(self, evaluation_id: str, jti: str) -> None: self._finalization_jtis[token_id] = evaluation_id def reserve_slot( - self, payload: PublicationPayload, policy_epoch: str + self, + payload: PublicationPayload, + policy_epoch: str, + publisher_identity: str, ) -> PublicationSlot: if type(payload) is not PublicationPayload: raise SignerRefused("invalid_publication_payload") epoch = require_string(policy_epoch, "policy_epoch") + source = require_string(publisher_identity, "publisher_identity") key = payload.repository_id, payload.head_oid, epoch digest = payload.canonical_digest() with self._lock: @@ -127,6 +131,8 @@ def reserve_slot( if existing is not None: if existing.payload_sha256 != digest or existing.payload != payload: raise SignerRefused("publication_slot_conflict") + if existing.publisher_identity != source: + raise SignerRefused("publication_source_conflict") return existing slot = PublicationSlot( repository_id=payload.repository_id, @@ -134,6 +140,7 @@ def reserve_slot( policy_epoch=epoch, payload=payload, payload_sha256=digest, + publisher_identity=source, state=PublicationSlotState.RESERVED, ) self._slots[key] = slot @@ -175,6 +182,8 @@ def set_slot_state( raise SignerRefused("invalid_publication_receipt") if receipt.payload_sha256 != current.payload_sha256: raise SignerRefused("publication_receipt_mismatch") + if receipt.publisher_identity != current.publisher_identity: + raise SignerRefused("publication_receipt_source_mismatch") elif receipt is not None: raise SignerRefused("unexpected_publication_receipt") updated = replace(current, state=state, receipt=receipt) From 84075958a5144e40f5e2dd03f7ac29b0a85027e8 Mon Sep 17 00:00:00 2001 From: stacknil Date: Fri, 18 Sep 2026 19:33:28 +0800 Subject: [PATCH 09/12] test(security): cover signer authority remediation --- tests/signer_test_support.py | 56 ++++++- .../test_repo_sentinel_signer_publication.py | 146 ++++++++++++++++++ tests/test_repo_sentinel_signer_registry.py | 87 ++++++++++- tests/test_repo_sentinel_signer_service.py | 28 ++++ 4 files changed, 314 insertions(+), 3 deletions(-) diff --git a/tests/signer_test_support.py b/tests/signer_test_support.py index 07ed681..2e8a32d 100644 --- a/tests/signer_test_support.py +++ b/tests/signer_test_support.py @@ -22,6 +22,11 @@ MockPublisher, MockPublishMode, MockPullRequestReader, + PublicationPayload, + PublicationReceipt, + Publisher, + PublishDisposition, + PublishOutcome, PullRequestSnapshot, RegistryRecord, SignerService, @@ -62,6 +67,7 @@ def registry_record( policy_epoch: str = "synthetic-policy-v1", policy_digest: str | None = None, workflow_sha: str | None = None, + status_context: str = "Repo Sentinel / authoritative gate", publisher_identity: str | None = None, reusable: bool = False, ) -> RegistryRecord: @@ -81,7 +87,7 @@ def registry_record( scanner_artifact_sha256=digest("synthetic-scanner-wheel"), workflow_ref="stacknil/sec-writeups-public/.github/workflows/authority.yml@main", workflow_sha=workflow_sha or oid("trusted-workflow"), - status_context="Repo Sentinel / authoritative gate", + status_context=status_context, publisher_identity=( publisher_identity or f"mock-publisher-{digest('publisher-identity')[:12]}" ), @@ -231,6 +237,47 @@ def __call__(self) -> int: return self.now +class PermissivePublisher: + """Test-only provider that accepts repeated writes to one physical context.""" + + def __init__(self, identity: str) -> None: + self._identity = identity + self._lock = threading.Lock() + self._calls: list[PublicationPayload] = [] + self._visible: dict[tuple[int, str, str], PublicationReceipt] = {} + self._counter = 0 + + @property + def identity(self) -> str: + return self._identity + + @property + def calls(self) -> tuple[PublicationPayload, ...]: + with self._lock: + return tuple(self._calls) + + def publish(self, payload: PublicationPayload) -> PublishOutcome: + with self._lock: + self._calls.append(payload) + self._counter += 1 + receipt = PublicationReceipt( + f"permissive-status-{self._counter}", + payload.canonical_digest(), + self.identity, + ) + key = payload.repository_id, payload.head_oid, payload.context.lower() + self._visible[key] = receipt + return PublishOutcome(PublishDisposition.PUBLISHED, receipt) + + def lookup(self, payload: PublicationPayload) -> PublicationReceipt | None: + with self._lock: + key = payload.repository_id, payload.head_oid, payload.context.lower() + receipt = self._visible.get(key) + if receipt is None or receipt.payload_sha256 != payload.canonical_digest(): + return None + return receipt + + class Harness: def __init__( self, @@ -238,6 +285,7 @@ def __init__( record: RegistryRecord | None = None, outcomes: list[MockPublishMode] | None = None, head_oid: str | None = None, + publisher: Publisher | None = None, ) -> None: self.lock = threading.RLock() self.record = record or registry_record() @@ -259,7 +307,11 @@ def __init__( ) ] ) - self.publisher = MockPublisher(self.record.publisher_identity, outcomes) + if publisher is not None and outcomes is not None: + raise ValueError("publisher and outcomes are mutually exclusive") + self.publisher = publisher or MockPublisher( + self.record.publisher_identity, outcomes + ) self.clock = MutableClock() self._ids = itertools.count(1) self.service = SignerService( diff --git a/tests/test_repo_sentinel_signer_publication.py b/tests/test_repo_sentinel_signer_publication.py index 4e1bd81..66b31d6 100644 --- a/tests/test_repo_sentinel_signer_publication.py +++ b/tests/test_repo_sentinel_signer_publication.py @@ -5,23 +5,73 @@ import inspect import threading import unittest +from dataclasses import replace from tests.signer_test_support import ( PULL_NUMBER, REPOSITORY_ID, Harness, + PermissivePublisher, controller_result, + digest, + oid, + registry_record, ) from repo_sentinel_signer import ( # noqa: E402 EvaluationState, + InMemoryEvaluationStore, MockPublishMode, + MockPublisher, + PublicationPayload, + PublicationReceipt, PublicationSlotState, + PublishDisposition, + PublishOutcome, PullRequestSnapshot, SignerRefused, ) +def publication_payload(*, context: str = "Repo Sentinel / authoritative gate"): + return PublicationPayload( + repository_id=REPOSITORY_ID, + head_oid=oid("publication-head"), + context=context, + state="success", + description="Repo Sentinel authority: PASS", + target_url=None, + ) + + +class ForeignReceiptLookupPublisher: + """Return uncertainty first, then a receipt attributable to another source.""" + + def __init__(self, identity: str, foreign_identity: str) -> None: + self._identity = identity + self._foreign_identity = foreign_identity + self._calls: list[PublicationPayload] = [] + + @property + def identity(self) -> str: + return self._identity + + @property + def calls(self) -> tuple[PublicationPayload, ...]: + return tuple(self._calls) + + def publish(self, payload: PublicationPayload) -> PublishOutcome: + self._calls.append(payload) + return PublishOutcome(PublishDisposition.UNKNOWN) + + def lookup(self, payload: PublicationPayload) -> PublicationReceipt | None: + return PublicationReceipt( + "foreign-status", + payload.canonical_digest(), + self._foreign_identity, + ) + + class VerdictMappingTests(unittest.TestCase): def test_semantic_verdicts_map_to_only_success_or_failure(self) -> None: expected = { @@ -91,6 +141,35 @@ def test_wrong_publisher_fails_before_call(self) -> None: class UnknownPublicationTests(unittest.TestCase): + def test_unknown_lookup_rejects_receipt_from_other_publisher(self) -> None: + record = registry_record() + publisher = ForeignReceiptLookupPublisher( + record.publisher_identity, "mock-publisher-b" + ) + harness = Harness(record=record, publisher=publisher) + ticket = harness.issue() + first = harness.service.finalize_evaluation( + harness.final_claims("unknown-foreign-one"), + ticket.evaluation_id, + controller_result(ticket, record), + ) + self.assertEqual(first.slot_state, PublicationSlotState.UNKNOWN) + + with self.assertRaisesRegex( + SignerRefused, "publication_receipt_source_mismatch" + ): + harness.service.finalize_evaluation( + harness.final_claims("unknown-foreign-two"), + ticket.evaluation_id, + controller_result(ticket, record), + ) + + self.assertEqual(len(publisher.calls), 1) + self.assertEqual( + harness.store.get(ticket.evaluation_id).finalization_state, + EvaluationState.UNKNOWN, + ) + def test_unknown_before_write_retries_only_identical_payload(self) -> None: harness = Harness( outcomes=[ @@ -405,5 +484,72 @@ def finalize(ticket: object, jti: str) -> None: self.assertEqual(harness.store.slot_count(), 1) +class PublicationAuthorityTests(unittest.TestCase): + def test_published_slot_rejects_second_publisher_before_shortcut(self) -> None: + store = InMemoryEvaluationStore() + payload = publication_payload() + slot = store.reserve_slot(payload, "synthetic-policy-v1", "publisher-a") + receipt = PublicationReceipt("status-a", slot.payload_sha256, "publisher-a") + store.set_slot_state(slot.key, PublicationSlotState.PUBLISHED, receipt) + + with self.assertRaisesRegex(SignerRefused, "publication_source_conflict"): + store.reserve_slot(payload, "synthetic-policy-v1", "publisher-b") + + self.assertEqual(store.get_slot(slot.key).state, PublicationSlotState.PUBLISHED) + + def test_receipt_digest_is_insufficient_without_matching_source(self) -> None: + store = InMemoryEvaluationStore() + payload = publication_payload() + slot = store.reserve_slot(payload, "synthetic-policy-v1", "publisher-a") + forged = PublicationReceipt("status-b", slot.payload_sha256, "publisher-b") + + with self.assertRaisesRegex( + SignerRefused, "publication_receipt_source_mismatch" + ): + store.set_slot_state(slot.key, PublicationSlotState.PUBLISHED, forged) + + self.assertEqual(store.get_slot(slot.key).state, PublicationSlotState.RESERVED) + + def test_mock_publisher_uses_case_insensitive_physical_context(self) -> None: + publisher = MockPublisher("publisher-a") + publisher.publish(publication_payload()) + + with self.assertRaisesRegex(SignerRefused, "payload_conflict"): + publisher.publish( + publication_payload(context="repo sentinel / AUTHORITATIVE GATE") + ) + + def test_permissive_provider_never_receives_reused_context_epoch(self) -> None: + for first_verdict, rejected_verdict in ( + ("PASS", "SCANNER_FINDING"), + ("SCANNER_FINDING", "PASS"), + ): + first = registry_record() + publisher = PermissivePublisher(first.publisher_identity) + harness = Harness(record=first, publisher=publisher) + ticket = harness.issue() + harness.service.finalize_evaluation( + harness.final_claims(f"first-{first_verdict}"), + ticket.evaluation_id, + controller_result(ticket, first, first_verdict), + ) + second = replace( + first, + record_id=f"second-{rejected_verdict}", + policy_epoch="synthetic-policy-v2", + policy_digest=digest("synthetic-policy-v2"), + ) + + with ( + self.subTest(first=first_verdict, rejected=rejected_verdict), + self.assertRaisesRegex( + SignerRefused, "registry_status_context_conflict" + ), + ): + harness.registry.add(second) + + self.assertEqual(len(publisher.calls), 1) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_repo_sentinel_signer_registry.py b/tests/test_repo_sentinel_signer_registry.py index d370aa6..3885db1 100644 --- a/tests/test_repo_sentinel_signer_registry.py +++ b/tests/test_repo_sentinel_signer_registry.py @@ -49,7 +49,7 @@ def test_same_repository_epoch_cannot_acquire_second_digest(self) -> None: registry = InMemoryRegistry() first = registry.add(registry_record()) - with self.assertRaisesRegex(SignerRefused, "registry_epoch_digest_conflict"): + with self.assertRaisesRegex(SignerRefused, "registry_epoch_authority_conflict"): registry.add( replace( first, @@ -68,12 +68,97 @@ def test_different_digest_requires_different_policy_epoch(self) -> None: record_id="second-record", policy_epoch="synthetic-policy-v2", policy_digest=digest("different-policy"), + status_context="Repo Sentinel / authoritative gate v2", ) ) self.assertNotEqual(first.policy_epoch, second.policy_epoch) self.assertNotEqual(first.policy_digest, second.policy_digest) + def test_same_epoch_freezes_context_and_publisher(self) -> None: + for field, value in ( + ("status_context", "Repo Sentinel / replacement gate"), + ("publisher_identity", "mock-publisher-b"), + ): + registry = InMemoryRegistry() + first = registry.add(registry_record()) + with ( + self.subTest(field=field), + self.assertRaisesRegex( + SignerRefused, "registry_epoch_authority_conflict" + ), + ): + registry.add( + replace( + first, + record_id=f"second-{field}", + **{field: value}, + ) + ) + + def test_status_context_is_reserved_across_epochs_case_insensitively(self) -> None: + for context in ( + "Repo Sentinel / authoritative gate", + "repo sentinel / AUTHORITATIVE GATE", + ): + registry = InMemoryRegistry() + first = registry.add(registry_record()) + with ( + self.subTest(context=context), + self.assertRaisesRegex( + SignerRefused, "registry_status_context_conflict" + ), + ): + registry.add( + replace( + first, + record_id="second-record", + policy_epoch="synthetic-policy-v2", + policy_digest=digest("different-policy"), + status_context=context, + ) + ) + + def test_revocation_does_not_release_context_namespace(self) -> None: + registry = InMemoryRegistry() + first = registry.add(registry_record()) + registry.revoke(first.key) + + with self.assertRaisesRegex(SignerRefused, "registry_status_context_conflict"): + registry.add( + replace( + first, + record_id="replacement-record", + policy_epoch="synthetic-policy-v2", + policy_digest=digest("replacement-policy"), + ) + ) + + def test_new_epoch_with_distinct_context_is_allowed(self) -> None: + registry = InMemoryRegistry() + first = registry.add(registry_record()) + + second = registry.add( + replace( + first, + record_id="second-record", + policy_epoch="synthetic-policy-v2", + policy_digest=digest("different-policy"), + status_context="Repo Sentinel / authoritative gate v2", + ) + ) + + self.assertNotEqual(first.policy_epoch, second.policy_epoch) + self.assertNotEqual(first.status_context, second.status_context) + + def test_status_context_requires_printable_ascii(self) -> None: + for context in ("Repo Sentinel / gaté", "Repo Sentinel / gate\x7f"): + with ( + self.subTest(context=context), + self.assertRaisesRegex(SignerRefused, "invalid_status_context"), + ): + registry_record(status_context=context) + def test_activation_is_explicit_and_latest_lookup_is_unsupported(self) -> None: registry = InMemoryRegistry() first = registry.add(registry_record()) diff --git a/tests/test_repo_sentinel_signer_service.py b/tests/test_repo_sentinel_signer_service.py index 3178101..21841f0 100644 --- a/tests/test_repo_sentinel_signer_service.py +++ b/tests/test_repo_sentinel_signer_service.py @@ -185,6 +185,34 @@ def test_finalization_jti_cannot_be_reused_after_retryable_refusal(self) -> None with self.assertRaisesRegex(SignerRefused, "finalization_jti_reused"): harness.service.finalize_evaluation(claims, ticket.evaluation_id, refusal) + def test_malformed_evidence_consumes_authenticated_finalization_jti(self) -> None: + harness = Harness() + ticket = harness.issue() + malformed = controller_result(ticket, harness.record) + malformed["extra"] = "not-allowed" + reused = harness.final_claims("malformed-evidence-jti") + + with self.assertRaisesRegex(SignerRefused, "controller_result_invalid"): + harness.service.finalize_evaluation( + reused, + ticket.evaluation_id, + malformed, + ) + with self.assertRaisesRegex(SignerRefused, "finalization_jti_reused"): + harness.service.finalize_evaluation( + reused, + ticket.evaluation_id, + controller_result(ticket, harness.record), + ) + + result = harness.service.finalize_evaluation( + harness.final_claims("fresh-after-malformed"), + ticket.evaluation_id, + controller_result(ticket, harness.record), + ) + + self.assertEqual(result.evaluation_state.value, "PUBLISHED") + def test_expired_evaluation_requires_new_admission(self) -> None: harness = Harness() ticket = harness.issue() From 27a3cae45a24c42587c8c7cedb1753eda712b875 Mon Sep 17 00:00:00 2001 From: stacknil Date: Fri, 18 Sep 2026 19:34:48 +0800 Subject: [PATCH 10/12] docs(security): record signer authority invariants --- .../repo-sentinel-signer-v1.md | 78 +++++++++++++++++-- 1 file changed, 70 insertions(+), 8 deletions(-) diff --git a/docs/design-decisions/repo-sentinel-signer-v1.md b/docs/design-decisions/repo-sentinel-signer-v1.md index 6db49be..c1a2998 100644 --- a/docs/design-decisions/repo-sentinel-signer-v1.md +++ b/docs/design-decisions/repo-sentinel-signer-v1.md @@ -37,6 +37,7 @@ registry establishes policy authorization evaluation record freezes registry revision and H controller result establishes bounded P_v(H) evidence publication slot establishes one immutable verdict payload for R/H/v + and one immutable publisher source ``` The publisher is only an output adapter. It does not select policy or reinterpret @@ -116,15 +117,37 @@ does not invalidate `P_v(H)`. `RegistryRecord` is a frozen value. A new operational configuration creates a new revision. Existing keys cannot be overwritten with changed content. -The registry enforces: +The registry freezes one publication-authority identity per epoch: ```text (repository_id, full_policy_epoch) - -> exactly one policy bundle digest + -> exactly one ( + policy bundle digest, + exact status context, + publisher identity + ) ``` -An operational revision may retain an existing epoch and digest. A changed -bundle digest requires a changed epoch. +An operational revision may retain an existing epoch while changing reviewed +execution-only fields such as workflow SHA, but it must preserve that complete +authority identity. A digest, context, or publisher change is an explicit +authority migration rather than an operational revision. + +Commit Status context names are a physical provider namespace. Signer v1 admits +only non-empty printable ASCII contexts up to GitHub's 100-character limit and +uses ASCII lowercase for namespace comparison while preserving the exact context +in the registry record and provider payload. The registry also preserves this +historical mapping: + +```text +(repository_id, normalized_status_context) + -> exactly one policy_epoch +``` + +Revocation does not release an epoch identity or context reservation. A new +epoch, including one introduced for publisher/App rotation, must use a distinct +normalized context. This prevents old statuses under one physical context from +acquiring a new authority meaning. Storage and activation are separate. Admission resolves exactly: @@ -183,6 +206,11 @@ owner, workflow, optional reusable workflow, run ID, and run attempt. Admission token IDs cannot finalize. A finalization token ID is consumed globally and cannot be replayed against another evaluation. +The finalization token ID is consumed immediately after authenticated OIDC and +exact execution-tuple validation, before expiry, pull-request freshness, or +controller-evidence parsing. Later malformed or stale evidence does not make the +authenticated token reusable. + Expired evaluations fail closed and require new authenticated admission. ### Independent controller-result parsing @@ -225,7 +253,15 @@ The logical publication slot is: Different pull requests containing the same H intentionally share the slot. The first semantic finalization reserves one canonical payload digest containing only repository ID, H, registry status context, mapped state, fixed description, -and null target URL. +and null target URL. Publisher identity remains internal authority provenance; +it is stored in the immutable slot but is not encoded into a provider payload +field or its canonical digest. + +An existing slot is reusable only when payload, payload digest, and publisher +identity all match. A different source is rejected before any existing-published +shortcut or provider access. Publication receipts bind provider record ID, +payload digest, and publisher identity; matching payload bytes alone are not +sufficient provenance. Slot state is monotonic: @@ -261,7 +297,11 @@ without another publish call. An unknown-before-write result may retry the same payload. A definite failure remains retryable but cannot downgrade an already unknown or published state. -The mock publisher identity is synthetic. It is not a GitHub App ID. +The mock publisher identity is synthetic. It is not a GitHub App ID. Its +physical status key uses the same case-insensitive ASCII context normalization +as the registry, and both direct publication and lookup produce or return only +receipts attributable to that mock identity. Namespace safety is also tested +against a separate permissive test provider that accepts repeated writes. ### Transaction model @@ -276,6 +316,13 @@ production durable service will need a database transaction plus outbox or an equivalent reconciliation design; it must preserve the same slot and uncertainty invariants without relying on a process-local lock. +Finalization-JTI consumption is a durable security side effect. A production +store must commit that single-use claim independently of later +controller-evidence refusal, for example through a committed compare-and-set +boundary. It +must not place token consumption and evidence parsing in a transaction that +rolls back the consumed token when parsing or freshness validation raises. + ## Threat and Failure Model The core is designed to reject these classes: @@ -290,6 +337,9 @@ The core is designed to reject these classes: - infrastructure failures converted into semantic statuses; - opposite verdicts racing for one R/H/v slot; - replay of admission or finalization token IDs; +- reinterpretation of one epoch through a new context or publisher identity; +- reuse of a case-insensitive physical context by a different epoch; +- reuse of a publication slot or receipt across publisher identities; - uncertain publication followed by a different payload; - state rollback from `UNKNOWN` or `PUBLISHED`. @@ -329,6 +379,12 @@ Those are later adapters and deployment gates, not hidden behavior in this PR. Rejected because candidate evidence is not approved production policy. 10. **Add a production-looking non-cryptographic OIDC verifier.** Rejected because its name would conceal an unimplemented trust boundary. +11. **Treat publisher identity as part of the provider payload.** + Rejected because signer provenance is not a GitHub Commit Status field and + must not change the canonical provider request. +12. **Allow a new epoch to reuse an old context after revocation.** + Rejected because historical statuses remain in the provider namespace and + could be reinterpreted under the new authority. ## Compatibility @@ -347,14 +403,20 @@ The contract suite covers: - exact OIDC claim shape, numeric parsing, identity mutation, and time windows; - mock verifier behavior and absence of a production-looking verifier; -- immutable registry records, epoch/digest uniqueness, explicit activation, +- immutable registry records, epoch-authority uniqueness, explicit activation, + frozen epoch authority, case-insensitive historical context reservation, revocation, and concurrent activation; - evaluation revision freeze, admission idempotency, token replay, and expiry; - PR head movement, closure, base-only movement, and same-H cross-PR sharing; - exact controller and worker schema parsing and binding substitutions; - all three semantic verdict mappings and infrastructure no-publish behavior; - payload conflicts, late finalizers, unknown-before-write, - unknown-after-write, reconciliation, and monotonic state; + unknown-after-write, publisher-bound reconciliation, receipt provenance, + and monotonic state; +- authenticated malformed evidence consuming its finalization token before + parser refusal, while a new token can still complete valid finalization; +- a permissive provider control proving cross-epoch context conflicts are + rejected before a second provider write; - concurrent admission and concurrent identical finalization. Repository validation also runs the complete unit suite, Ruff, formatting, From 4d1013c65d50f77c9600410a4b2a288dc59f7a72 Mon Sep 17 00:00:00 2001 From: stacknil Date: Fri, 18 Sep 2026 19:41:19 +0800 Subject: [PATCH 11/12] test(security): keep provenance fixture scan-safe --- tests/test_repo_sentinel_signer_publication.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/test_repo_sentinel_signer_publication.py b/tests/test_repo_sentinel_signer_publication.py index 66b31d6..5c7023f 100644 --- a/tests/test_repo_sentinel_signer_publication.py +++ b/tests/test_repo_sentinel_signer_publication.py @@ -44,7 +44,7 @@ def publication_payload(*, context: str = "Repo Sentinel / authoritative gate"): ) -class ForeignReceiptLookupPublisher: +class WrongSourcePublisher: """Return uncertainty first, then a receipt attributable to another source.""" def __init__(self, identity: str, foreign_identity: str) -> None: @@ -143,9 +143,7 @@ def test_wrong_publisher_fails_before_call(self) -> None: class UnknownPublicationTests(unittest.TestCase): def test_unknown_lookup_rejects_receipt_from_other_publisher(self) -> None: record = registry_record() - publisher = ForeignReceiptLookupPublisher( - record.publisher_identity, "mock-publisher-b" - ) + publisher = WrongSourcePublisher(record.publisher_identity, "mock-publisher-b") harness = Harness(record=record, publisher=publisher) ticket = harness.issue() first = harness.service.finalize_evaluation( From feb40fdbaf178a7688ecac3258384f0608f59dd9 Mon Sep 17 00:00:00 2001 From: stacknil Date: Fri, 18 Sep 2026 19:45:05 +0800 Subject: [PATCH 12/12] test(security): assert source conflict before shortcut --- .../test_repo_sentinel_signer_publication.py | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/tests/test_repo_sentinel_signer_publication.py b/tests/test_repo_sentinel_signer_publication.py index 5c7023f..63900a1 100644 --- a/tests/test_repo_sentinel_signer_publication.py +++ b/tests/test_repo_sentinel_signer_publication.py @@ -16,6 +16,7 @@ digest, oid, registry_record, + verified_claims, ) from repo_sentinel_signer import ( # noqa: E402 @@ -30,6 +31,7 @@ PublishOutcome, PullRequestSnapshot, SignerRefused, + TicketRequest, ) @@ -483,6 +485,54 @@ def finalize(ticket: object, jti: str) -> None: class PublicationAuthorityTests(unittest.TestCase): + def test_service_rejects_legacy_source_change_before_shortcut(self) -> None: + first = registry_record(publisher_identity="publisher-a") + harness = Harness(record=first) + first_ticket = harness.issue(jti="admission-a") + harness.service.finalize_evaluation( + harness.final_claims("final-a"), + first_ticket.evaluation_id, + controller_result(first_ticket, first), + ) + + second = replace( + first, + record_id="legacy-second-record", + publisher_identity="publisher-b", + ) + # Simulate a frozen legacy record created before epoch authority checks. + harness.registry._records[second.key] = second # noqa: SLF001 + harness.registry.activate(REPOSITORY_ID, "authoritative", second.key) + second_ticket = harness.service.issue_evaluation( + verified_claims( + second, + jti="admission-b", + run_id=7002, + now=harness.clock.now, + ), + TicketRequest(PULL_NUMBER), + ) + publisher_b = MockPublisher(second.publisher_identity) + harness.service._publisher = publisher_b # noqa: SLF001 + + with self.assertRaisesRegex(SignerRefused, "publication_source_conflict"): + harness.service.finalize_evaluation( + verified_claims( + second, + jti="final-b", + run_id=7002, + now=harness.clock.now, + ), + second_ticket.evaluation_id, + controller_result(second_ticket, second), + ) + + self.assertEqual(publisher_b.calls, ()) + self.assertEqual( + harness.store.get(second_ticket.evaluation_id).finalization_state, + EvaluationState.ISSUED, + ) + def test_published_slot_rejects_second_publisher_before_shortcut(self) -> None: store = InMemoryEvaluationStore() payload = publication_payload()