fix(assessment): establish independent release trust
Some checks failed
Dependency Audit / dependency-audit (push) Failing after 15s
Security Audit / security-audit (push) Failing after 13s

This commit is contained in:
2026-07-22 17:09:35 +02:00
parent 753dd2a3ee
commit ad8dd4f319
7 changed files with 577 additions and 102 deletions

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import sys
@@ -20,6 +21,9 @@ from govoplan_assessment import render_review, review_capability_fit # noqa: E4
from govoplan_release.catalog import DEFAULT_PUBLIC_BASE_URL, fetch_json # noqa: E402
TRUSTED_KEYRING_FILE_ENV = "GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE"
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Validate a fit assessment and identify conclusions needing review after release drift."
@@ -44,7 +48,17 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
"--catalog", type=Path, help="Read a catalog from a local file."
)
parser.add_argument(
"--keyring", type=Path, help="Trusted local keyring; required with --catalog."
"--keyring",
type=Path,
help="Published or candidate keyring whose hash is pinned by --catalog.",
)
parser.add_argument(
"--trusted-keyring",
type=Path,
help=(
"Independently pinned local keyring used as the catalog signature trust "
f"root; defaults to ${TRUSTED_KEYRING_FILE_ENV}."
),
)
parser.add_argument(
"--workspace-root",
@@ -74,7 +88,14 @@ def main(argv: list[str] | None = None) -> int:
raise SystemExit("--keyring is required with --catalog")
if args.public and args.keyring is not None:
raise SystemExit("--keyring cannot be combined with --public")
configured_trusted_keyring = os.getenv(TRUSTED_KEYRING_FILE_ENV, "").strip()
trusted_keyring_path = args.trusted_keyring or (
Path(configured_trusted_keyring) if configured_trusted_keyring else None
)
if trusted_keyring_path is None:
raise SystemExit(
f"--trusted-keyring or ${TRUSTED_KEYRING_FILE_ENV} is required"
)
assessment = read_object(args.assessment, label="assessment")
schema = read_object(args.schema, label="schema")
if args.public:
@@ -82,18 +103,24 @@ def main(argv: list[str] | None = None) -> int:
f"{DEFAULT_PUBLIC_BASE_URL}/catalogs/v1/channels/stable.json",
label="catalog",
)
keyring = fetch_public_json(
f"{DEFAULT_PUBLIC_BASE_URL}/catalogs/v1/keyring.json", label="keyring"
published_keyring = fetch_public_json(
f"{DEFAULT_PUBLIC_BASE_URL}/catalogs/v1/keyring.json",
label="published keyring",
)
else:
catalog = read_object(args.catalog, label="catalog")
keyring = read_object(args.keyring, label="keyring")
published_keyring = read_object(args.keyring, label="published keyring")
trusted_keyring = read_object(
trusted_keyring_path,
label="trusted keyring",
)
report = review_capability_fit(
assessment=assessment,
schema=schema,
catalog=catalog,
keyring=keyring,
published_keyring=published_keyring,
trusted_keyring=trusted_keyring,
workspace_root=None
if args.skip_tag_provenance
else args.workspace_root.resolve(),

View File

@@ -46,7 +46,8 @@ def review_capability_fit(
assessment: dict[str, Any],
schema: dict[str, Any],
catalog: dict[str, Any],
keyring: dict[str, Any],
published_keyring: dict[str, Any],
trusted_keyring: dict[str, Any],
workspace_root: Path | None = None,
) -> dict[str, Any]:
"""Return a secret-free release-drift report for one fit assessment."""
@@ -64,10 +65,15 @@ def review_capability_fit(
changes=[],
review_targets=[],
catalog_checked=False,
local_tag_provenance_checked=False,
local_tag_provenance_attempted=0,
local_tag_provenance_expected=0,
local_tag_catalog_scope_complete=False,
)
catalog_validation = validate_catalog(catalog=catalog, keyring=keyring)
catalog_validation = validate_catalog(
catalog=catalog,
trusted_keyring=trusted_keyring,
)
if catalog_validation.get("valid") is not True:
findings.append(
Finding(
@@ -100,13 +106,13 @@ def review_capability_fit(
for issue in source_selection.issues
)
expected_keyring_hash = _catalog_keyring_hash(catalog)
actual_keyring_hash = canonical_hash(keyring)
actual_keyring_hash = canonical_hash(published_keyring)
if expected_keyring_hash is None:
findings.append(
Finding(
"blocker",
"catalog_keyring_hash_missing",
"Catalog release metadata does not pin the trusted keyring hash.",
"Catalog release metadata does not pin the published keyring hash.",
("assessment.release",),
)
)
@@ -115,7 +121,7 @@ def review_capability_fit(
Finding(
"blocker",
"catalog_keyring_hash_mismatch",
"The supplied keyring does not match the hash pinned by the catalog.",
"The published keyring does not match the hash pinned by the catalog.",
("assessment.release",),
)
)
@@ -163,13 +169,58 @@ def review_capability_fit(
changes: list[dict[str, Any]] = []
changed_repositories: set[str] = set()
assessed_module_ids: set[str] = set()
composition = assessment["composition"]
local_tag_provenance_expected = len(composition)
local_tag_provenance_attempted = 0
local_tag_catalog_scope_complete = True
for module in assessment["composition"]:
for module in composition:
module_id = str(module["module_id"])
repository = str(module["repository"])
assessed_version = str(module["manifest_version"])
selected_version = source_selection.selected_versions.get(repository)
selected_commit = source_selection.selected_commits.get(repository)
selected_tag_object = source_selection.selected_tag_objects.get(repository)
assessed_module_ids.add(module_id)
if workspace_root is not None:
local_tag_provenance_attempted += 1
provenance = local_tag_provenance(
workspace_root=workspace_root,
repository=repository,
version=assessed_version,
assessed_commit=str(module["commit"]),
selected_commit=(
selected_commit if selected_version == assessed_version else None
),
selected_tag_object=(
selected_tag_object
if selected_version == assessed_version
else None
),
)
if provenance is not None:
changes.append(
{
"module_id": module_id,
"repository": repository,
"kind": provenance["kind"],
"assessed_commit": module["commit"],
"tag_commit": provenance.get("tag_commit"),
"tag_object": provenance.get("tag_object"),
}
)
changed_repositories.add(repository)
findings.append(
Finding(
"review",
"tag_provenance_changed",
str(provenance["message"]),
(f"composition.{module_id}",),
)
)
entry = catalog_entries.get(module_id)
if entry is None:
local_tag_catalog_scope_complete = False
changes.append(
{
"module_id": module_id,
@@ -211,7 +262,6 @@ def review_capability_fit(
)
)
assessed_version = str(module["manifest_version"])
catalog_version = entry["version"]
if assessed_version != catalog_version:
changes.append(
@@ -233,8 +283,6 @@ def review_capability_fit(
)
)
selected_version = source_selection.selected_versions.get(repository)
selected_commit = source_selection.selected_commits.get(repository)
assessed_commit = str(module["commit"]).lower()
if (
selected_version == assessed_version
@@ -280,33 +328,6 @@ def review_capability_fit(
)
)
if workspace_root is not None:
provenance = local_tag_provenance(
workspace_root=workspace_root,
repository=repository,
version=assessed_version,
assessed_commit=str(module["commit"]),
)
if provenance is not None:
changes.append(
{
"module_id": module_id,
"repository": repository,
"kind": provenance["kind"],
"assessed_commit": module["commit"],
"tag_commit": provenance.get("tag_commit"),
}
)
changed_repositories.add(repository)
findings.append(
Finding(
"review",
"tag_provenance_changed",
str(provenance["message"]),
(f"composition.{module_id}",),
)
)
extras = sorted(set(catalog_entries) - assessed_module_ids)
if extras:
findings.append(
@@ -331,7 +352,9 @@ def review_capability_fit(
changes=changes,
review_targets=review_targets,
catalog_checked=True,
local_tag_provenance_checked=workspace_root is not None,
local_tag_provenance_attempted=local_tag_provenance_attempted,
local_tag_provenance_expected=local_tag_provenance_expected,
local_tag_catalog_scope_complete=local_tag_catalog_scope_complete,
)
@@ -357,9 +380,9 @@ def validate_assessment(
def validate_catalog(
*, catalog: dict[str, Any], keyring: dict[str, Any]
*, catalog: dict[str, Any], trusted_keyring: dict[str, Any]
) -> dict[str, object]:
trusted_keys = trusted_keys_from_keyring(keyring)
trusted_keys = trusted_keys_from_keyring(trusted_keyring)
channel = _text(catalog.get("channel"))
with tempfile.NamedTemporaryFile(
mode="w", suffix=".json", encoding="utf-8"
@@ -394,11 +417,19 @@ def trusted_keys_from_keyring(payload: dict[str, Any]) -> dict[str, str]:
result: dict[str, str] = {}
keys = payload.get("keys")
if not isinstance(keys, list):
return result
return {
str(key): str(value)
for key, value in payload.items()
if str(key).strip() and str(value).strip()
}
for item in keys:
if not isinstance(item, dict):
continue
if str(item.get("status") or "active").strip().lower() != "active":
if str(item.get("status") or "active").strip().lower() in {
"revoked",
"disabled",
"retired",
}:
continue
raw_not_before = item.get("not_before")
raw_not_after = item.get("not_after")
@@ -497,6 +528,8 @@ def local_tag_provenance(
repository: str,
version: str,
assessed_commit: str,
selected_commit: str | None = None,
selected_tag_object: str | None = None,
) -> dict[str, str] | None:
if REPOSITORY_ID_PATTERN.fullmatch(repository) is None:
return {
@@ -535,6 +568,19 @@ def local_tag_provenance(
capture_output=True,
text=True,
)
tag_object_result = subprocess.run(
[
"git",
"-C",
str(repo),
"rev-parse",
"--verify",
f"refs/tags/v{version}",
],
check=False,
capture_output=True,
text=True,
)
except OSError:
return {
"kind": "tag_provenance_unavailable",
@@ -555,13 +601,40 @@ def local_tag_provenance(
"kind": "tag_provenance_unavailable",
"message": f"Cannot peel {repository} tag v{version} in the local checkout.",
}
if tag_object_result.returncode != 0:
return {
"kind": "tag_provenance_unavailable",
"message": f"Cannot resolve {repository} tag object v{version} in the local checkout.",
}
tag_commit = result.stdout.strip().lower()
tag_object = tag_object_result.stdout.strip().lower()
if not tag_commit.startswith(assessed_commit.lower()):
return {
"kind": "tag_commit_changed",
"message": f"{repository} v{version} peels to {tag_commit[:12]}, not assessed commit {assessed_commit}.",
"tag_commit": tag_commit,
}
if selected_commit is not None and tag_commit != selected_commit.lower():
return {
"kind": "tag_signed_commit_changed",
"message": (
f"{repository} v{version} peels to {tag_commit[:12]}, not signed "
f"selected-unit commit {selected_commit[:12]}."
),
"tag_commit": tag_commit,
"tag_object": tag_object,
}
if selected_tag_object is not None and tag_object != selected_tag_object.lower():
return {
"kind": "tag_object_changed",
"message": (
f"{repository} v{version} resolves to annotated tag object "
f"{tag_object[:12]}, not signed selected-unit tag object "
f"{selected_tag_object[:12]}."
),
"tag_commit": tag_commit,
"tag_object": tag_object,
}
return None
@@ -627,7 +700,9 @@ def build_report(
changes: list[dict[str, Any]],
review_targets: list[dict[str, Any]],
catalog_checked: bool,
local_tag_provenance_checked: bool,
local_tag_provenance_attempted: int,
local_tag_provenance_expected: int,
local_tag_catalog_scope_complete: bool,
) -> dict[str, Any]:
finding_list = sorted(
findings,
@@ -635,18 +710,24 @@ def build_report(
)
finding_codes = {item.code for item in finding_list}
schema_valid = "assessment_schema" not in finding_codes
catalog_valid = catalog_checked and not finding_codes.intersection(
trusted_signature_valid = catalog_checked and "catalog_trust" not in finding_codes
published_keyring_valid = catalog_checked and not finding_codes.intersection(
{
"catalog_trust",
"catalog_keyring_hash_missing",
"catalog_keyring_hash_mismatch",
}
)
catalog_valid = trusted_signature_valid and published_keyring_valid
release_valid = catalog_valid and not any(
item.severity in {"blocker", "review"} for item in finding_list
)
tag_provenance_checked = (
local_tag_provenance_expected > 0
and local_tag_provenance_attempted == local_tag_provenance_expected
and local_tag_catalog_scope_complete
)
tag_provenance_valid = (
local_tag_provenance_checked and "tag_provenance_changed" not in finding_codes
tag_provenance_checked and "tag_provenance_changed" not in finding_codes
)
if any(item.severity == "blocker" for item in finding_list):
status = "blocked"
@@ -672,13 +753,23 @@ def build_report(
"checked": catalog_checked,
"valid": catalog_valid if catalog_checked else None,
},
"catalog_signature_and_trusted_keyring": {
"checked": catalog_checked,
"valid": trusted_signature_valid if catalog_checked else None,
},
"published_keyring_hash": {
"checked": catalog_checked,
"valid": published_keyring_valid if catalog_checked else None,
},
"release_metadata": {
"checked": catalog_checked,
"valid": release_valid if catalog_checked else None,
},
"local_tag_provenance": {
"checked": local_tag_provenance_checked,
"valid": tag_provenance_valid if local_tag_provenance_checked else None,
"checked": tag_provenance_checked,
"valid": tag_provenance_valid if tag_provenance_checked else None,
"attempted_count": local_tag_provenance_attempted,
"expected_count": local_tag_provenance_expected,
},
"installed_artifacts": {"checked": False, "valid": None},
"target_environment": {"checked": False, "valid": None},

View File

@@ -7,11 +7,12 @@ import json
from pathlib import Path
from urllib.error import HTTPError, URLError
from .http_fetch import fetch_http
from .http_fetch import DEFAULT_MAX_RESPONSE_BYTES, fetch_http
from .model import CatalogSnapshot
from .workspace import website_root
DEFAULT_PUBLIC_BASE_URL = "https://govoplan.add-ideas.de"
MAX_PUBLIC_JSON_RESPONSE_BYTES = DEFAULT_MAX_RESPONSE_BYTES
def collect_catalog_snapshot(
@@ -22,9 +23,13 @@ def collect_catalog_snapshot(
check_public: bool = False,
) -> CatalogSnapshot:
web_root = website_root(workspace_root)
catalog_path = web_root / "public" / "catalogs" / "v1" / "channels" / f"{channel}.json"
catalog_path = (
web_root / "public" / "catalogs" / "v1" / "channels" / f"{channel}.json"
)
keyring_path = web_root / "public" / "catalogs" / "v1" / "keyring.json"
public_catalog_url = f"{public_base_url.rstrip('/')}/catalogs/v1/channels/{channel}.json"
public_catalog_url = (
f"{public_base_url.rstrip('/')}/catalogs/v1/channels/{channel}.json"
)
public_keyring_url = f"{public_base_url.rstrip('/')}/catalogs/v1/keyring.json"
catalog_version: str | None = None
@@ -129,8 +134,12 @@ def collect_catalog_snapshot(
public_keyring_hash=public_keyring_hash,
public_catalog_error=public_catalog_error,
public_keyring_error=public_keyring_error,
catalog_matches_public=compare_hashes(local_catalog_hash, public_catalog_hash) if check_public else None,
keyring_matches_public=compare_hashes(local_keyring_hash, public_keyring_hash) if check_public else None,
catalog_matches_public=compare_hashes(local_catalog_hash, public_catalog_hash)
if check_public
else None,
keyring_matches_public=compare_hashes(local_keyring_hash, public_keyring_hash)
if check_public
else None,
)
@@ -149,7 +158,9 @@ def parse_catalog_payload(payload: object) -> dict[str, str | int | None]:
signatures = payload.get("signatures")
return {
"catalog_version": string_or_none(payload.get("catalog_version")),
"core_release_version": string_or_none(core_release.get("version")) if isinstance(core_release, dict) else None,
"core_release_version": string_or_none(core_release.get("version"))
if isinstance(core_release, dict)
else None,
"module_count": len(modules) if isinstance(modules, list) else 0,
"signature_count": len(signatures) if isinstance(signatures, list) else 0,
"generated_at": string_or_none(payload.get("generated_at")),
@@ -164,6 +175,7 @@ def fetch_json(url: str) -> dict[str, object]:
timeout=10,
label="Public release catalog URL",
headers={"User-Agent": "GovOPlaN release-console"},
max_response_bytes=MAX_PUBLIC_JSON_RESPONSE_BYTES,
)
return {"ok": True, "payload": json.loads(response.text())}
except (HTTPError, URLError, TimeoutError, ValueError, json.JSONDecodeError) as exc:
@@ -171,7 +183,9 @@ def fetch_json(url: str) -> dict[str, object]:
def canonical_hash(payload: object) -> str:
canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8")
canonical = json.dumps(
payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True
).encode("utf-8")
return hashlib.sha256(canonical).hexdigest()

View File

@@ -8,6 +8,9 @@ from dataclasses import dataclass
from typing import Mapping
DEFAULT_MAX_RESPONSE_BYTES = 16 * 1024 * 1024
@dataclass(frozen=True, slots=True)
class HttpFetchResponse:
status: int
@@ -34,11 +37,29 @@ def fetch_http(
label: str = "URL",
method: str = "GET",
headers: Mapping[str, str] | None = None,
max_response_bytes: int = DEFAULT_MAX_RESPONSE_BYTES,
) -> HttpFetchResponse:
request = urllib.request.Request(validate_http_url(url, label=label), headers=dict(headers or {}), method=method)
if max_response_bytes <= 0:
raise ValueError("max_response_bytes must be positive.")
request = urllib.request.Request(
validate_http_url(url, label=label),
headers=dict(headers or {}),
method=method,
)
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 - URL is validated by validate_http_url. # nosec B310 # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
raw_length = response.headers.get("Content-Length")
if raw_length is not None:
try:
declared_length = int(raw_length)
except (TypeError, ValueError):
declared_length = None
if declared_length is not None and declared_length > max_response_bytes:
raise ValueError(f"{label} response exceeds the configured size limit.")
body = response.read(max_response_bytes + 1)
if len(body) > max_response_bytes:
raise ValueError(f"{label} response exceeds the configured size limit.")
return HttpFetchResponse(
status=int(getattr(response, "status", 0)),
headers=dict(response.headers.items()),
body=response.read(),
body=body,
)