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

@@ -7,6 +7,7 @@ import argparse
import json
import os
from pathlib import Path
import re
import sys
@@ -22,6 +23,10 @@ from govoplan_assessment import ( # noqa: E402
render_review,
review_capability_fit,
)
from govoplan_assessment.atomic_io import ( # noqa: E402
AtomicJsonWriteError,
atomic_write_json,
)
from govoplan_assessment.evidence import validate_payload # noqa: E402
from govoplan_release.catalog import DEFAULT_PUBLIC_BASE_URL, fetch_json # noqa: E402
@@ -29,6 +34,8 @@ from govoplan_release.catalog import DEFAULT_PUBLIC_BASE_URL, fetch_json # noqa
TRUSTED_KEYRING_FILE_ENV = "GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE"
MAX_ASSESSMENT_INPUT_BYTES = 16 * 1024 * 1024
MAX_EVIDENCE_INPUT_BYTES = 4 * 1024 * 1024
MAX_REPORT_OUTPUT_BYTES = 16 * 1024 * 1024
OPAQUE_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$")
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
@@ -130,6 +137,13 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
"--verification-time",
help="Optional ISO-8601 time for reproducible boundary-proof verification.",
)
parser.add_argument(
"--expected-external-provider-subject",
help=(
"Opaque provider-environment ID that external-provider proof must match; "
"never supply a URL, credential, or endpoint identifier."
),
)
parser.add_argument(
"--json", action="store_true", help="Print the machine-readable review report."
)
@@ -157,6 +171,13 @@ def main(argv: list[str] | None = None) -> int:
raise SystemExit(
"--boundary-evidence requires --installed-evidence or --collect-installed-evidence"
)
if (
args.expected_external_provider_subject is not None
and OPAQUE_ID_PATTERN.fullmatch(args.expected_external_provider_subject) is None
):
raise SystemExit(
"--expected-external-provider-subject must be a bounded opaque ID"
)
if (
args.collect_installed_evidence is not None
and args.output is not None
@@ -232,7 +253,12 @@ def main(argv: list[str] | None = None) -> int:
raise SystemExit(
"Collected installed evidence did not satisfy its bounded schema"
)
write_object(args.collect_installed_evidence, installed_evidence)
write_object(
args.collect_installed_evidence,
installed_evidence,
max_bytes=MAX_EVIDENCE_INPUT_BYTES,
label="installed evidence",
)
boundary_evidence = None
boundary_evidence_schema = None
@@ -260,6 +286,7 @@ def main(argv: list[str] | None = None) -> int:
max_bytes=MAX_EVIDENCE_INPUT_BYTES,
)
verification_time = parse_datetime(args.verification_time)
report = review_capability_fit(
assessment=assessment,
schema=schema,
@@ -275,12 +302,22 @@ def main(argv: list[str] | None = None) -> int:
boundary_evidence_schema=boundary_evidence_schema,
boundary_authority_keyring=boundary_authority_keyring,
boundary_authority_keyring_schema=boundary_authority_keyring_schema,
verification_time=parse_datetime(args.verification_time),
verification_time=verification_time,
installed_evidence_mode=(
"direct_local"
if args.collect_installed_evidence is not None
else "imported_unsigned"
),
expected_external_provider_subject=args.expected_external_provider_subject,
)
encoded = json.dumps(report, indent=2, sort_keys=True) + "\n"
if args.output is not None:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(encoded, encoding="utf-8")
write_object(
args.output,
report,
max_bytes=MAX_REPORT_OUTPUT_BYTES,
label="review report",
)
sys.stdout.write(encoded if args.json else render_review(report))
return {"current": 0, "blocked": 1, "review_required": 2}[report["status"]]
@@ -307,16 +344,19 @@ def read_object(path: Path | None, *, label: str, max_bytes: int) -> dict[str, o
return payload
def write_object(path: Path | None, payload: dict[str, object]) -> None:
def write_object(
path: Path | None,
payload: dict[str, object],
*,
max_bytes: int,
label: str,
) -> None:
if path is None:
raise SystemExit("Missing installed evidence output path")
encoded = json.dumps(payload, indent=2, sort_keys=True) + "\n"
if len(encoded.encode("utf-8")) > MAX_EVIDENCE_INPUT_BYTES:
raise SystemExit(
"Collected installed evidence exceeds the bounded output limit"
)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(encoded, encoding="utf-8")
raise SystemExit(f"Missing {label} output path")
try:
atomic_write_json(path, payload, max_bytes=max_bytes)
except AtomicJsonWriteError as exc:
raise SystemExit(f"Could not securely write {label}") from exc
def parse_datetime(value: str | None):

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