fix(assessment): harden capability evidence trust

This commit is contained in:
2026-07-22 19:21:06 +02:00
parent 1dc9148ec3
commit 19c4b63ade
8 changed files with 1928 additions and 153 deletions

View File

@@ -0,0 +1,230 @@
"""Bounded, private, atomic JSON output for assessment evidence."""
from __future__ import annotations
import json
import os
from pathlib import Path
import stat
import tempfile
from typing import Any
class AtomicJsonWriteError(RuntimeError):
"""Raised when JSON output cannot be written with the required guarantees."""
def atomic_write_json(
path: str | os.PathLike[str],
payload: Any,
*,
max_bytes: int,
) -> None:
"""Write bounded JSON through a private same-directory temporary file.
A successful return means the file contents and the containing directory
entry have both been flushed. If flushing the directory fails after the
atomic replacement, the replacement may be visible but its crash
durability is unknown, so the function reports failure. Every parent must
already exist as a real directory; symbolic-link components are rejected.
"""
if isinstance(max_bytes, bool) or not isinstance(max_bytes, int) or max_bytes <= 0:
raise ValueError("max_bytes must be a positive integer")
encoded = _encode_bounded_json(payload, max_bytes=max_bytes)
target = Path(os.path.abspath(os.fspath(path)))
parent = target.parent
descriptor = -1
verification_descriptor = -1
temporary_path: Path | None = None
temporary_identity: tuple[int, int] | None = None
try:
_assert_real_parent(parent)
_assert_replaceable_target(target)
try:
descriptor, temporary_name = tempfile.mkstemp(
dir=parent,
prefix=".govoplan-json-",
suffix=".tmp",
)
temporary_path = Path(temporary_name)
os.fchmod(descriptor, 0o600)
metadata = os.fstat(descriptor)
if (
not stat.S_ISREG(metadata.st_mode)
or stat.S_IMODE(metadata.st_mode) != 0o600
):
raise AtomicJsonWriteError(
"Atomic JSON temporary file could not be secured."
)
temporary_identity = (metadata.st_dev, metadata.st_ino)
verification_descriptor = os.dup(descriptor)
handle = os.fdopen(descriptor, "wb")
descriptor = -1
with handle:
handle.write(encoded)
handle.flush()
os.fsync(handle.fileno())
# Recheck after creating and flushing the temporary file so an
# unsafe target discovered before replacement is never followed.
_assert_real_parent(parent)
_assert_replaceable_target(target)
os.replace(temporary_path, target)
temporary_path = None
_assert_installed_target(
target,
expected_identity=temporary_identity,
recovery_descriptor=verification_descriptor,
)
_fsync_directory(parent)
finally:
try:
if descriptor >= 0:
os.close(descriptor)
finally:
try:
if verification_descriptor >= 0:
os.close(verification_descriptor)
finally:
if temporary_path is not None:
try:
temporary_path.unlink()
except FileNotFoundError:
pass
except AtomicJsonWriteError:
raise
except OSError as exc:
raise AtomicJsonWriteError(
"JSON output could not be written atomically."
) from exc
def _encode_bounded_json(payload: Any, *, max_bytes: int) -> bytes:
encoder = json.JSONEncoder(
allow_nan=False,
ensure_ascii=False,
indent=2,
sort_keys=True,
)
chunks: list[bytes] = []
encoded_size = 1 # The document always ends with one newline.
for chunk in encoder.iterencode(payload):
encoded_chunk = chunk.encode("utf-8")
encoded_size += len(encoded_chunk)
if encoded_size > max_bytes:
raise AtomicJsonWriteError("JSON output exceeds its size limit.")
chunks.append(encoded_chunk)
return b"".join(chunks) + b"\n"
def _assert_replaceable_target(path: Path) -> None:
try:
metadata = path.lstat()
except FileNotFoundError:
return
except OSError as exc:
raise AtomicJsonWriteError(
"JSON output target could not be inspected safely."
) from exc
if stat.S_ISLNK(metadata.st_mode):
raise AtomicJsonWriteError("JSON output target must not be a symbolic link.")
if not stat.S_ISREG(metadata.st_mode):
raise AtomicJsonWriteError("JSON output target must be a regular file.")
def _assert_real_parent(path: Path) -> None:
current = Path(path.anchor)
for part in path.parts[1:]:
current /= part
try:
metadata = current.lstat()
except FileNotFoundError as exc:
raise AtomicJsonWriteError(
"JSON output parent directory must already exist."
) from exc
except OSError as exc:
raise AtomicJsonWriteError(
"JSON output parent directory could not be inspected safely."
) from exc
if stat.S_ISLNK(metadata.st_mode):
raise AtomicJsonWriteError(
"JSON output parent path must not contain symbolic links."
)
if not stat.S_ISDIR(metadata.st_mode):
raise AtomicJsonWriteError(
"JSON output parent path must contain only directories."
)
def _assert_installed_target(
path: Path,
*,
expected_identity: tuple[int, int] | None,
recovery_descriptor: int,
) -> None:
try:
metadata = path.lstat()
except OSError as exc:
raise AtomicJsonWriteError(
"Atomic JSON replacement could not be verified."
) from exc
observed_identity = (metadata.st_dev, metadata.st_ino)
if (
expected_identity is None
or observed_identity != expected_identity
or not stat.S_ISREG(metadata.st_mode)
):
raise AtomicJsonWriteError(
"Atomic JSON replacement did not preserve the secured regular file."
)
if stat.S_IMODE(metadata.st_mode) == 0o600:
return
try:
recovery_metadata = os.fstat(recovery_descriptor)
if (
not stat.S_ISREG(recovery_metadata.st_mode)
or (recovery_metadata.st_dev, recovery_metadata.st_ino) != expected_identity
):
raise AtomicJsonWriteError(
"Atomic JSON replacement recovery descriptor is invalid."
)
os.fchmod(recovery_descriptor, 0o600)
os.fsync(recovery_descriptor)
recovered_metadata = os.fstat(recovery_descriptor)
installed_metadata = path.lstat()
except OSError as exc:
raise AtomicJsonWriteError(
"Atomic JSON replacement permissions could not be restored."
) from exc
if (
stat.S_IMODE(recovered_metadata.st_mode) != 0o600
or (installed_metadata.st_dev, installed_metadata.st_ino) != expected_identity
or not stat.S_ISREG(installed_metadata.st_mode)
or stat.S_IMODE(installed_metadata.st_mode) != 0o600
):
raise AtomicJsonWriteError(
"Atomic JSON replacement permissions could not be restored."
)
_fsync_directory(path.parent)
raise AtomicJsonWriteError(
"Atomic JSON replacement permissions changed and were restored."
)
def _fsync_directory(path: Path) -> None:
flags = os.O_RDONLY
if hasattr(os, "O_DIRECTORY"):
flags |= os.O_DIRECTORY
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
descriptor = os.open(path, flags)
try:
if not stat.S_ISDIR(os.fstat(descriptor).st_mode):
raise AtomicJsonWriteError("JSON output parent path must be a directory.")
os.fsync(descriptor)
finally:
os.close(descriptor)

View File

@@ -28,6 +28,7 @@ from govoplan_release.version_alignment import candidate_catalog_version_issues
from .evidence import (
BoundaryEvidenceReview,
InstalledEvidenceReview,
public_key_material_from_keyrings,
review_boundary_evidence,
review_installed_composition,
)
@@ -63,6 +64,8 @@ def review_capability_fit(
boundary_authority_keyring: dict[str, Any] | None = None,
boundary_authority_keyring_schema: dict[str, Any] | None = None,
verification_time: datetime | None = None,
installed_evidence_mode: str = "imported_unsigned",
expected_external_provider_subject: str | None = None,
) -> dict[str, Any]:
"""Return a secret-free release-drift report for one fit assessment."""
@@ -356,6 +359,10 @@ def review_capability_fit(
)
)
evidence_verification_time = verification_time or datetime.now(tz=UTC)
evidence_verification_mode = (
"historical_override" if verification_time is not None else "current"
)
installed_review = review_installed_composition(
assessment=assessment,
catalog_entries=catalog_entries,
@@ -363,6 +370,9 @@ def review_capability_fit(
selected_commits=source_selection.selected_commits,
evidence=installed_evidence,
schema=installed_evidence_schema,
observation_mode=installed_evidence_mode,
verification_time=evidence_verification_time,
verification_mode=evidence_verification_mode,
)
findings.extend(
Finding(item.severity, item.code, item.message, item.assessment_ids)
@@ -378,7 +388,13 @@ def review_capability_fit(
evidence_schema=boundary_evidence_schema,
authority_keyring=boundary_authority_keyring,
authority_keyring_schema=boundary_authority_keyring_schema,
verification_time=verification_time,
verification_time=evidence_verification_time,
verification_mode=evidence_verification_mode,
expected_external_provider_subject=expected_external_provider_subject,
forbidden_authority_public_keys=public_key_material_from_keyrings(
published_keyring,
trusted_keyring,
),
)
findings.extend(
Finding(item.severity, item.code, item.message, item.assessment_ids)
@@ -396,6 +412,7 @@ def review_capability_fit(
assessment=assessment,
installed_review=installed_review,
),
boundary_review.review_targets,
)
return build_report(
assessment=assessment,
@@ -908,7 +925,7 @@ def build_report(
**boundary_scope,
}
return {
"report_version": "0.2.0",
"report_version": "0.3.0",
"status": status,
"assessment_id": assessment.get("assessment_id"),
"assessment_release": assessment.get("release", {}).get("ref")
@@ -930,9 +947,11 @@ def build_report(
def render_review(report: dict[str, Any]) -> str:
installed_checked = (
report.get("proof_scope", {}).get("installed_artifacts", {}).get("checked")
is True
proof_scope = report.get("proof_scope", {})
installed_scope = proof_scope.get("installed_artifacts", {})
installed_checked = installed_scope.get("checked") is True
installed_supplied = bool(installed_scope.get("evidence_sha256")) or (
"installed_evidence_observation" in proof_scope
)
lines = [
f"Capability fit rerun: {report['status']}",
@@ -941,12 +960,29 @@ def render_review(report: dict[str, Any]) -> str:
(
"Scope: schema, signed release metadata, optional local tag provenance, "
+ (
"and supplied installed-composition evidence."
"and locally observed installed-composition evidence."
if installed_checked
else "and no installed-composition evidence."
else (
"and supplied installed-composition evidence that did not establish local proof."
if installed_supplied
else "and no installed-composition evidence."
)
)
),
]
verification = proof_scope.get("boundary_evidence_verification") or proof_scope.get(
"installed_evidence_observation"
)
if isinstance(verification, dict) and verification.get("evaluated_at"):
mode = verification.get("verification_mode")
mode_label = (
"HISTORICAL override"
if mode == "historical_override"
else "current-time evaluation"
)
lines.append(
f"Evidence verification: {mode_label} at {verification['evaluated_at']}."
)
findings = report.get("findings") or []
if findings:
lines.append("Findings:")

File diff suppressed because it is too large Load Diff