fix(assessment): harden capability evidence trust
This commit is contained in:
230
tools/assessments/govoplan_assessment/atomic_io.py
Normal file
230
tools/assessments/govoplan_assessment/atomic_io.py
Normal 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)
|
||||
@@ -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
Reference in New Issue
Block a user