Files
govoplan/tools/assessments/govoplan_assessment/capability_fit.py
Albrecht Degering ad8dd4f319
Some checks failed
Dependency Audit / dependency-audit (push) Failing after 15s
Security Audit / security-audit (push) Failing after 13s
fix(assessment): establish independent release trust
2026-07-22 17:09:35 +02:00

857 lines
30 KiB
Python

"""Compare a capability-fit assessment with a signed release catalog.
The result is deliberately limited to release metadata and local tag
provenance. It never implies installed-artifact, target-provider, or production
proof.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass
from datetime import UTC, datetime
import hashlib
import json
from pathlib import Path
import re
import subprocess
import tempfile
from typing import Any, Iterable
from unittest import mock
from jsonschema import Draft202012Validator, FormatChecker
from jsonschema.exceptions import SchemaError
import govoplan_core.core.module_package_catalog as module_package_catalog
from govoplan_release.source_provenance import catalog_source_selection
from govoplan_release.version_alignment import candidate_catalog_version_issues
RELEASE_REF_PATTERN = re.compile(
r"^(?P<channel>[a-z][a-z0-9_-]*)-catalog-(?P<sequence>[0-9]+)$"
)
TAG_PATTERN = re.compile(r"(?:[.]git[@#])(?P<tag>[^\s]+)$")
REPOSITORY_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
@dataclass(frozen=True, slots=True)
class Finding:
severity: str
code: str
message: str
assessment_ids: tuple[str, ...] = ()
def review_capability_fit(
*,
assessment: dict[str, Any],
schema: dict[str, Any],
catalog: 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."""
findings: list[Finding] = []
schema_errors = validate_assessment(assessment=assessment, schema=schema)
findings.extend(
Finding("blocker", "assessment_schema", message) for message in schema_errors
)
if schema_errors:
return build_report(
assessment=assessment,
catalog=catalog,
findings=findings,
changes=[],
review_targets=[],
catalog_checked=False,
local_tag_provenance_attempted=0,
local_tag_provenance_expected=0,
local_tag_catalog_scope_complete=False,
)
catalog_validation = validate_catalog(
catalog=catalog,
trusted_keyring=trusted_keyring,
)
if catalog_validation.get("valid") is not True:
findings.append(
Finding(
"blocker",
"catalog_trust",
str(catalog_validation.get("error") or "Catalog validation failed."),
("assessment.release",),
)
)
findings.extend(
Finding(
"blocker",
"catalog_release_metadata",
(
f"{issue.source}: {issue.message}; expected {issue.expected!r}, "
f"found {issue.actual!r}."
),
("assessment.release",),
)
for issue in candidate_catalog_version_issues(catalog)
)
source_selection = catalog_source_selection(catalog)
findings.extend(
Finding(
"blocker",
"catalog_source_provenance",
issue.message,
("assessment.release",),
)
for issue in source_selection.issues
)
expected_keyring_hash = _catalog_keyring_hash(catalog)
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 published keyring hash.",
("assessment.release",),
)
)
elif expected_keyring_hash != actual_keyring_hash:
findings.append(
Finding(
"blocker",
"catalog_keyring_hash_mismatch",
"The published keyring does not match the hash pinned by the catalog.",
("assessment.release",),
)
)
release = assessment["release"]
ref_match = RELEASE_REF_PATTERN.fullmatch(str(release["ref"]))
if ref_match is None:
findings.append(
Finding(
"blocker",
"assessment_release_ref",
"Assessment release.ref must have the form '<channel>-catalog-<sequence>'.",
("assessment.release",),
)
)
assessed_channel = None
assessed_sequence = None
else:
assessed_channel = ref_match.group("channel")
assessed_sequence = int(ref_match.group("sequence"))
catalog_channel = _text(catalog.get("channel"))
catalog_sequence = _integer(catalog.get("sequence"))
if assessed_channel is not None and catalog_channel != assessed_channel:
findings.append(
Finding(
"blocker",
"catalog_channel_mismatch",
f"Assessment pins channel {assessed_channel!r}, but the supplied catalog is {catalog_channel!r}.",
("assessment.release",),
)
)
if assessed_sequence is not None and catalog_sequence != assessed_sequence:
findings.append(
Finding(
"review",
"catalog_sequence_changed",
f"Assessment pins catalog sequence {assessed_sequence}; supplied catalog sequence is {catalog_sequence!r}.",
("assessment.release",),
)
)
catalog_entries, entry_findings = catalog_entries_by_id(catalog)
findings.extend(entry_findings)
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 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,
"repository": repository,
"kind": "missing_from_catalog",
"assessed_version": module["manifest_version"],
"catalog_version": None,
}
)
changed_repositories.add(repository)
findings.append(
Finding(
"review",
"composition_entry_missing",
f"Assessed module {module_id!r} is not present in the supplied catalog.",
(f"composition.{module_id}",),
)
)
continue
catalog_repository = entry["repository"]
if catalog_repository != repository:
changes.append(
{
"module_id": module_id,
"repository": repository,
"kind": "repository_changed",
"assessed_repository": repository,
"catalog_repository": catalog_repository,
}
)
changed_repositories.add(repository)
findings.append(
Finding(
"review",
"composition_repository_changed",
f"Module {module_id!r} maps to {catalog_repository!r} in the catalog, not {repository!r}.",
(f"composition.{module_id}",),
)
)
catalog_version = entry["version"]
if assessed_version != catalog_version:
changes.append(
{
"module_id": module_id,
"repository": repository,
"kind": "version_changed",
"assessed_version": assessed_version,
"catalog_version": catalog_version,
}
)
changed_repositories.add(repository)
findings.append(
Finding(
"review",
"composition_version_changed",
f"Module {module_id!r} changed from {assessed_version!r} to {catalog_version!r}.",
(f"composition.{module_id}",),
)
)
assessed_commit = str(module["commit"]).lower()
if (
selected_version == assessed_version
and selected_commit is not None
and not selected_commit.lower().startswith(assessed_commit)
):
changes.append(
{
"module_id": module_id,
"repository": repository,
"kind": "commit_changed",
"assessed_commit": module["commit"],
"catalog_commit": selected_commit,
}
)
changed_repositories.add(repository)
findings.append(
Finding(
"review",
"composition_commit_changed",
(
f"Signed selected-unit provenance for {repository!r} at "
f"{assessed_version!r} points to commit {selected_commit[:12]}, "
f"not assessed commit {module['commit']}."
),
(f"composition.{module_id}",),
)
)
expected_tag = f"v{catalog_version}" if catalog_version else None
catalog_tags = entry["tags"]
if (
expected_tag is None
or not catalog_tags
or any(tag != expected_tag for tag in catalog_tags)
):
findings.append(
Finding(
"blocker",
"catalog_ref_version_mismatch",
f"Catalog refs for {module_id!r} do not consistently select expected tag {expected_tag!r}: {catalog_tags!r}.",
(f"composition.{module_id}",),
)
)
extras = sorted(set(catalog_entries) - assessed_module_ids)
if extras:
findings.append(
Finding(
"info",
"catalog_entries_outside_scope",
"Catalog entries outside this assessment composition: "
+ ", ".join(extras)
+ ".",
)
)
review_targets = conclusions_needing_review(
assessment=assessment,
changed_repositories=changed_repositories,
release_changed=assessed_sequence != catalog_sequence,
)
return build_report(
assessment=assessment,
catalog=catalog,
findings=findings,
changes=changes,
review_targets=review_targets,
catalog_checked=True,
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,
)
def validate_assessment(
*, assessment: dict[str, Any], schema: dict[str, Any]
) -> list[str]:
try:
Draft202012Validator.check_schema(schema)
except SchemaError as exc:
return [f"$schema: {exc.message}"]
validator = Draft202012Validator(schema, format_checker=FormatChecker())
return [
f"{_json_path(error.absolute_path)}: {error.message}"
for error in sorted(
validator.iter_errors(assessment),
key=lambda item: (
tuple(str(part) for part in item.absolute_path),
str(item.validator),
item.message,
),
)
]
def validate_catalog(
*, catalog: dict[str, Any], trusted_keyring: dict[str, Any]
) -> dict[str, object]:
trusted_keys = trusted_keys_from_keyring(trusted_keyring)
channel = _text(catalog.get("channel"))
with tempfile.NamedTemporaryFile(
mode="w", suffix=".json", encoding="utf-8"
) as handle:
json.dump(catalog, handle)
handle.flush()
# Installer replay state answers whether a catalog may be accepted again
# by one runtime. This read-only assessment instead compares explicit
# inputs, so ambient installer state must not affect its result.
with (
mock.patch.object(
module_package_catalog,
"_configured_sequence_state_path",
return_value=None,
),
mock.patch.object(
module_package_catalog,
"_configured_enforce_sequence",
return_value=False,
),
):
return module_package_catalog.validate_module_package_catalog(
Path(handle.name),
require_trusted=True,
approved_channels=(channel,) if channel else (),
trusted_keys=trusted_keys,
)
def trusted_keys_from_keyring(payload: dict[str, Any]) -> dict[str, str]:
now = datetime.now(tz=UTC)
result: dict[str, str] = {}
keys = payload.get("keys")
if not isinstance(keys, list):
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() in {
"revoked",
"disabled",
"retired",
}:
continue
raw_not_before = item.get("not_before")
raw_not_after = item.get("not_after")
not_before = _datetime(raw_not_before)
not_after = _datetime(raw_not_after)
if raw_not_before is not None and not_before is None:
continue
if raw_not_after is not None and not_after is None:
continue
if not_before is not None and now < not_before:
continue
if not_after is not None and now > not_after:
continue
key_id = _text(item.get("key_id"))
public_key = _text(item.get("public_key")) or _text(
item.get("public_key_base64")
)
if key_id and public_key:
result[key_id] = public_key
return result
def catalog_entries_by_id(
catalog: dict[str, Any],
) -> tuple[dict[str, dict[str, Any]], list[Finding]]:
findings: list[Finding] = []
entries: dict[str, dict[str, Any]] = {}
raw_entries: list[tuple[str, dict[str, Any]]] = []
core = catalog.get("core_release")
if isinstance(core, dict):
raw_entries.append(("core", core))
modules = catalog.get("modules")
if isinstance(modules, list):
raw_entries.extend(
(str(item.get("module_id") or ""), item)
for item in modules
if isinstance(item, dict)
)
for module_id, item in raw_entries:
if not module_id:
findings.append(
Finding(
"blocker",
"catalog_module_id_missing",
"Catalog entry has no module_id.",
)
)
continue
if module_id in entries:
findings.append(
Finding(
"blocker",
"catalog_module_id_duplicate",
f"Catalog repeats module_id {module_id!r}.",
)
)
continue
refs = [
value
for value in (item.get("python_ref"), item.get("webui_ref"))
if isinstance(value, str) and value
]
entries[module_id] = {
"module_id": module_id,
"repository": catalog_repository(item),
"version": _text(item.get("version")),
"tags": tuple(
tag for tag in (catalog_ref_tag(value) for value in refs) if tag
),
}
return entries, findings
def catalog_repository(entry: dict[str, Any]) -> str | None:
package = _text(entry.get("python_package"))
if package:
return package.split("[", 1)[0]
for field in ("python_ref", "webui_ref"):
value = _text(entry.get(field))
if not value:
continue
match = re.search(r"/([^/@#]+)[.]git(?:[@#]|$)", value)
if match:
return match.group(1)
return None
def catalog_ref_tag(value: str) -> str | None:
match = TAG_PATTERN.search(value)
return match.group("tag") if match else None
def local_tag_provenance(
*,
workspace_root: Path,
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 {
"kind": "tag_provenance_unavailable",
"message": "Cannot verify tag provenance for an invalid repository identifier.",
}
resolved_workspace = workspace_root.resolve()
repo = (resolved_workspace / repository).resolve()
if repo.parent != resolved_workspace:
return {
"kind": "tag_provenance_unavailable",
"message": f"Cannot verify {repository} tag provenance outside the configured workspace.",
}
if not (repo / ".git").exists():
return {
"kind": "tag_provenance_unavailable",
"message": f"Cannot verify {repository} tag provenance because the local checkout is unavailable.",
}
try:
tag_type = subprocess.run(
["git", "-C", str(repo), "cat-file", "-t", f"refs/tags/v{version}"],
check=False,
capture_output=True,
text=True,
)
result = subprocess.run(
[
"git",
"-C",
str(repo),
"rev-parse",
"--verify",
f"refs/tags/v{version}^{{commit}}",
],
check=False,
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",
"message": f"Cannot run the local tag provenance check for {repository}.",
}
if tag_type.returncode != 0:
return {
"kind": "tag_provenance_unavailable",
"message": f"Cannot inspect {repository} tag v{version} in the local checkout.",
}
if tag_type.stdout.strip() != "tag":
return {
"kind": "tag_not_annotated",
"message": f"{repository} v{version} is not an annotated release tag.",
}
if result.returncode != 0:
return {
"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
def conclusions_needing_review(
*,
assessment: dict[str, Any],
changed_repositories: set[str],
release_changed: bool,
) -> list[dict[str, Any]]:
targets: dict[str, dict[str, Any]] = {}
if release_changed:
targets["assessment.release"] = {
"id": "assessment.release",
"section": "release",
"reason": "Catalog sequence changed; review release-scoped evidence and conclusions.",
}
for section in ("capabilities", "infrastructure"):
for item in assessment.get(section, []):
if not isinstance(item, dict):
continue
locators = "\n".join(
str(evidence.get("locator") or "")
for evidence in item.get("evidence", [])
if isinstance(evidence, dict)
).lower()
matched = sorted(
repository
for repository in changed_repositories
if repository.lower() in locators
)
if not matched:
continue
item_id = str(item["id"])
targets[item_id] = {
"id": item_id,
"section": section,
"current_status": item.get("status"),
"repositories": matched,
"reason": "Evidence references a repository whose assessed release changed.",
}
for module in assessment.get("composition", []):
if (
not isinstance(module, dict)
or module.get("repository") not in changed_repositories
):
continue
module_id = str(module.get("module_id"))
target_id = f"composition.{module_id}"
targets[target_id] = {
"id": target_id,
"section": "composition",
"repositories": [module.get("repository")],
"reason": "Pinned module release or tag provenance changed.",
}
return [targets[key] for key in sorted(targets)]
def build_report(
*,
assessment: dict[str, Any],
catalog: dict[str, Any],
findings: Iterable[Finding],
changes: list[dict[str, Any]],
review_targets: list[dict[str, Any]],
catalog_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,
key=lambda item: (_severity_rank(item.severity), item.code, item.message),
)
finding_codes = {item.code for item in finding_list}
schema_valid = "assessment_schema" not in finding_codes
trusted_signature_valid = catalog_checked and "catalog_trust" not in finding_codes
published_keyring_valid = catalog_checked and not finding_codes.intersection(
{
"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 = (
tag_provenance_checked and "tag_provenance_changed" not in finding_codes
)
if any(item.severity == "blocker" for item in finding_list):
status = "blocked"
elif any(item.severity == "review" for item in finding_list):
status = "review_required"
else:
status = "current"
return {
"report_version": "0.1.0",
"status": status,
"assessment_id": assessment.get("assessment_id"),
"assessment_release": assessment.get("release", {}).get("ref")
if isinstance(assessment.get("release"), dict)
else None,
"catalog": {
"channel": catalog.get("channel"),
"sequence": catalog.get("sequence"),
"generated_at": catalog.get("generated_at"),
},
"proof_scope": {
"assessment_schema": {"checked": True, "valid": schema_valid},
"catalog_signature_and_keyring": {
"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": 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},
"external_providers": {"checked": False, "valid": None},
"production_approval": {"checked": False, "valid": None},
},
"findings": [asdict(item) for item in finding_list],
"changes": sorted(
changes,
key=lambda item: (str(item.get("module_id")), str(item.get("kind"))),
),
"review_targets": review_targets,
}
def render_review(report: dict[str, Any]) -> str:
lines = [
f"Capability fit rerun: {report['status']}",
f"Assessment: {report.get('assessment_id') or '-'} ({report.get('assessment_release') or '-'})",
f"Catalog: {report['catalog'].get('channel') or '-'} sequence {report['catalog'].get('sequence') or '-'}",
"Scope: schema, signed release metadata, and optional local tag provenance only.",
]
findings = report.get("findings") or []
if findings:
lines.append("Findings:")
lines.extend(
f"- [{item['severity']}] {item['code']}: {item['message']}"
for item in findings
)
else:
lines.append("Findings: none")
targets = report.get("review_targets") or []
if targets:
lines.append("Conclusions requiring review:")
lines.extend(f"- {item['id']}: {item['reason']}" for item in targets)
lines.append(
"No installed-artifact, target-provider, or production proof was performed."
)
return "\n".join(lines) + "\n"
def canonical_hash(payload: object) -> str:
data = json.dumps(
payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True
).encode("utf-8")
return hashlib.sha256(data).hexdigest()
def _catalog_keyring_hash(catalog: dict[str, Any]) -> str | None:
release = catalog.get("release")
return _text(release.get("keyring_sha256")) if isinstance(release, dict) else None
def _datetime(value: object) -> datetime | None:
text = _text(value)
if text is None:
return None
try:
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
except ValueError:
return None
return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=UTC)
def _integer(value: object) -> int | None:
try:
return int(value) if value is not None else None
except (TypeError, ValueError):
return None
def _json_path(parts: Iterable[object]) -> str:
path = "$"
for part in parts:
path += f"[{part}]" if isinstance(part, int) else f".{part}"
return path
def _severity_rank(severity: str) -> int:
return {"blocker": 0, "review": 1, "info": 2}.get(severity, 3)
def _text(value: object) -> str | None:
return value if isinstance(value, str) and value else None