ai / security /verify_release_evidence.py
celik-muhammed's picture
Upload 34 files
89e4531 verified
Raw
History Blame Contribute Delete
21.2 kB
#!/usr/bin/env python3
"""
Verify production release evidence without externalizing secrets.
This verifier binds time-sensitive CI/deployment evidence to the exact B39
source lock, Python SBOM, immutable base-image policy, target platform and final
OCI image digest. It deliberately does not contact registries, scanners,
Redis, or telemetry services; evidence collection is a separate explicit CI or
operator action.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import logging
import re
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
import tomllib
logger = logging.getLogger(__name__)
HERE = Path(__file__).resolve().parent
ROOT = HERE.parent
POLICY = tomllib.loads((HERE / "release_evidence_policy.toml").read_text())
SUPPLY = tomllib.loads((HERE / "supply_chain_policy.toml").read_text())
HEX64 = re.compile(r"^[0-9a-f]{64}$")
DIGEST = re.compile(r"^sha256:([0-9a-f]{64})$")
SLSA_PREDICATE = "https://slsa.dev/provenance/v1"
RELEASE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{2,127}$")
RUNTIME_SOURCE_FILES = (
"Dockerfile",
".dockerignore",
"requirements.lock",
"app.py",
"deduplicate_dataset.py",
)
PROXY_VERSION_RE = re.compile(
r'^PROXY_VERSION: str = "([0-9]+\.[0-9]+\.[0-9]+)"$', re.MULTILINE
)
CYCLONEDX_VERSION_RE = re.compile(r"^[0-9]+\.[0-9]+$")
FORBIDDEN_SECRET_KEYS = {
"authorization",
"cookie",
"password",
"passwd",
"secret",
"token",
"apikey",
"api_key",
"privatekey",
"private_key",
"redisurl",
"redis_url",
"connectionstring",
"connection_string",
"hostname",
"username",
"host",
}
SECRET_VALUE_PATTERNS = (
re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"),
re.compile(r"(?i)\bbearer\s+[A-Za-z0-9._~+/-]{12,}"),
re.compile(r"(?i)\b(?:redis|rediss|https?)://[^\s/@:]+:[^\s/@]+@"),
)
class EvidenceError(RuntimeError): # ruff: ignore[undocumented-public-class]
pass
def _fail(code: str) -> None:
raise EvidenceError(code)
def _runtime_source_sha256(root: Path = ROOT) -> str:
"""Hash exact application/container inputs with stable names and lengths."""
paths = [root / name for name in RUNTIME_SOURCE_FILES]
utils_root = root / "_utils"
if not utils_root.is_dir() or utils_root.is_symlink():
_fail("RUNTIME_SOURCE_UTILS_INVALID")
paths.extend(
sorted(
path
for path in utils_root.rglob("*")
if path.is_file()
and "__pycache__" not in path.parts
and path.suffix not in {".pyc", ".pyo"}
)
)
h = hashlib.sha256()
for path in paths:
if not path.is_file() or path.is_symlink():
_fail("RUNTIME_SOURCE_FILE_INVALID")
rel = path.relative_to(root).as_posix().encode("utf-8")
data = path.read_bytes()
h.update(len(rel).to_bytes(4, "big"))
h.update(rel)
h.update(len(data).to_bytes(8, "big"))
h.update(data)
return h.hexdigest()
def _sha256(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
def _parse_time(value: Any, code: str) -> datetime:
if not isinstance(value, str) or not value.endswith("Z"):
_fail(code)
try:
dt = datetime.fromisoformat(value[:-1] + "+00:00")
except ValueError:
_fail(code)
if dt.tzinfo is None:
_fail(code)
return dt.astimezone(timezone.utc)
def _hex(value: Any, code: str) -> str:
if not isinstance(value, str) or HEX64.fullmatch(value) is None:
_fail(code)
return value
def _digest(value: Any, code: str) -> str:
if not isinstance(value, str) or DIGEST.fullmatch(value) is None:
_fail(code)
return value
def _mapping(value: Any, code: str) -> dict[str, Any]:
if not isinstance(value, dict):
_fail(code)
return value
def _exact_keys(value: dict[str, Any], expected: set[str], code: str) -> None:
if set(value) != expected:
_fail(code)
def _tool(value: Any, code: str) -> None:
tool = _mapping(value, code)
_exact_keys(tool, {"name", "version"}, code)
for field in ("name", "version"):
item = tool.get(field)
if (
not isinstance(item, str)
or not item.strip()
or len(item) > 128 # ruff: ignore[magic-value-comparison]
or any(ord(ch) < 32 for ch in item) # ruff: ignore[magic-value-comparison]
):
_fail(code)
def _current_proxy_version() -> str:
shared = ROOT / "_utils" / "_shared_logic.py"
try:
text = shared.read_text(encoding="utf-8")
except OSError:
_fail("PROXY_VERSION_SOURCE_UNREADABLE")
match = PROXY_VERSION_RE.search(text)
if match is None:
_fail("PROXY_VERSION_SOURCE_INVALID")
return match.group(1)
def _version_tuple(value: Any, code: str) -> tuple[int, int]:
if not isinstance(value, str) or CYCLONEDX_VERSION_RE.fullmatch(value) is None:
_fail(code)
major, minor = value.split(".", 1)
return int(major), int(minor)
def _bool(value: Any, expected: bool, code: str) -> None:
if value is not expected:
_fail(code)
def _walk_values(value: Any) -> None:
if isinstance(value, dict):
for key, child in value.items():
if str(key).replace("-", "_").lower() in FORBIDDEN_SECRET_KEYS:
_fail("EVIDENCE_SECRET_FIELD_FORBIDDEN")
_walk_values(child)
elif isinstance(value, list):
for child in value:
_walk_values(child)
elif isinstance(value, str):
if "\x00" in value or len(value) > 4096: # ruff: ignore[magic-value-comparison]
_fail("EVIDENCE_STRING_UNSAFE")
for pattern in SECRET_VALUE_PATTERNS:
if pattern.search(value):
_fail("EVIDENCE_SECRET_LIKE_VALUE_FORBIDDEN")
def _artifact_path(evidence_dir: Path, raw: Any) -> Path:
if not isinstance(raw, str) or not raw or raw.startswith(("/", "~")) or "\\" in raw:
_fail("ARTIFACT_PATH_INVALID")
rel = Path(raw)
if rel.is_absolute() or any(part in {"", ".", ".."} for part in rel.parts):
_fail("ARTIFACT_PATH_TRAVERSAL")
root = evidence_dir.resolve()
candidate = root / rel
current = root
for part in rel.parts:
current = current / part
if current.is_symlink():
_fail("ARTIFACT_SYMLINK_FORBIDDEN")
path = candidate.resolve()
if root not in path.parents:
_fail("ARTIFACT_PATH_ESCAPE")
if not path.is_file():
_fail("ARTIFACT_FILE_MISSING")
if path.stat().st_size > int(POLICY["max_evidence_file_bytes"]):
_fail("ARTIFACT_FILE_TOO_LARGE")
return path
def _verify_artifact(
evidence_dir: Path,
obj: Any,
name: str,
*,
subject: str,
extra_keys: set[str] | None = None,
) -> Path:
item = _mapping(obj, f"{name.upper()}_INVALID")
expected_keys = {"path", "sha256", "status", "subject", "tool"} | (
extra_keys or set()
)
_exact_keys(item, expected_keys, f"{name.upper()}_SCHEMA_INVALID")
_tool(item.get("tool"), f"{name.upper()}_TOOL_INVALID")
if item.get("status") != "pass":
_fail(f"{name.upper()}_NOT_PASS")
if item.get("subject") != subject:
_fail(f"{name.upper()}_SUBJECT_MISMATCH")
path = _artifact_path(evidence_dir, item.get("path"))
expected = _hex(item.get("sha256"), f"{name.upper()}_SHA256_INVALID")
if _sha256(path) != expected:
_fail(f"{name.upper()}_HASH_MISMATCH")
return path
def _verify_provenance(
path: Path, image_digest: str, base_manifest_digest: str, item: dict[str, Any]
) -> None:
if item.get("predicateType") != SLSA_PREDICATE:
_fail("PROVENANCE_PREDICATE_UNSUPPORTED")
_bool(item.get("signatureVerified"), True, "PROVENANCE_SIGNATURE_NOT_VERIFIED")
try:
statement = json.loads(path.read_text())
except Exception as exc:
raise EvidenceError("PROVENANCE_JSON_INVALID") from exc
if (
not isinstance(statement, dict)
or statement.get("_type") != "https://in-toto.io/Statement/v1"
):
_fail("PROVENANCE_STATEMENT_INVALID")
if statement.get("predicateType") != SLSA_PREDICATE:
_fail("PROVENANCE_STATEMENT_PREDICATE_MISMATCH")
wanted = image_digest.split(":", 1)[1]
subjects = statement.get("subject")
if not isinstance(subjects, list) or not any(
isinstance(s, dict)
and isinstance(s.get("digest"), dict)
and s["digest"].get("sha256") == wanted
for s in subjects
):
_fail("PROVENANCE_SUBJECT_IMAGE_MISMATCH")
base_wanted = base_manifest_digest.split(":", 1)[1]
predicate = statement.get("predicate")
definition = (
predicate.get("buildDefinition") if isinstance(predicate, dict) else None
)
dependencies = (
definition.get("resolvedDependencies") if isinstance(definition, dict) else None
)
if not isinstance(dependencies, list) or not any(
isinstance(dep, dict)
and isinstance(dep.get("digest"), dict)
and dep["digest"].get("sha256") == base_wanted
for dep in dependencies
):
_fail("PROVENANCE_BASE_MANIFEST_UNBOUND")
def _verify_redis(doc: dict[str, Any], now: datetime) -> None:
redis_policy = POLICY["redis"]
planes = _mapping(doc.get("redis"), "REDIS_EVIDENCE_MISSING")
_exact_keys(planes, {"rateLimit", "share", "contribution"}, "REDIS_SCHEMA_INVALID")
for name in ("rateLimit", "share", "contribution"):
item = _mapping(planes.get(name), f"REDIS_{name.upper()}_MISSING")
_exact_keys(
item,
{
"tlsVerified",
"nonDefaultIdentity",
"leastPrivilegeReviewed",
"persistenceVerified",
"replicationVerified",
"backupRestoreTestedAt",
},
f"REDIS_{name.upper()}_SCHEMA_INVALID",
)
if redis_policy["require_tls"]:
_bool(item.get("tlsVerified"), True, f"REDIS_{name.upper()}_TLS_UNVERIFIED")
if redis_policy["require_non_default_identity"]:
_bool(
item.get("nonDefaultIdentity"),
True,
f"REDIS_{name.upper()}_DEFAULT_IDENTITY",
)
if redis_policy["require_least_privilege_review"]:
_bool(
item.get("leastPrivilegeReviewed"),
True,
f"REDIS_{name.upper()}_ACL_UNREVIEWED",
)
for name, prefix in (("share", "share"), ("contribution", "contribution")):
item = planes[name]
if redis_policy[f"{prefix}_require_persistence"]:
_bool(
item.get("persistenceVerified"),
True,
f"REDIS_{name.upper()}_PERSISTENCE_UNVERIFIED",
)
if redis_policy[f"{prefix}_require_replication"]:
_bool(
item.get("replicationVerified"),
True,
f"REDIS_{name.upper()}_REPLICATION_UNVERIFIED",
)
tested = _parse_time(
item.get("backupRestoreTestedAt"),
f"REDIS_{name.upper()}_BACKUP_TIME_INVALID",
)
if tested > now + timedelta(minutes=int(POLICY["max_clock_skew_minutes"])):
_fail(f"REDIS_{name.upper()}_BACKUP_TIME_FUTURE")
if now - tested > timedelta(
days=int(redis_policy["backup_restore_max_age_days"])
):
_fail(f"REDIS_{name.upper()}_BACKUP_RESTORE_STALE")
def _verify_logging(doc: dict[str, Any], now: datetime) -> None:
logging = _mapping(doc.get("logging"), "LOGGING_EVIDENCE_MISSING")
_exact_keys(
logging,
{
"requestBodyLogging",
"authorizationHeaderLogging",
"capabilityHeaderLogging",
"queryStringLogging",
"wafBodyCapture",
"apmBodyCapture",
"thirdPartyTelemetryExport",
"reviewedAt",
},
"LOGGING_SCHEMA_INVALID",
)
mapping = {
"requestBodyLogging": "request_body_logging",
"authorizationHeaderLogging": "authorization_header_logging",
"capabilityHeaderLogging": "capability_header_logging",
"queryStringLogging": "query_string_logging",
"wafBodyCapture": "waf_body_capture",
"apmBodyCapture": "apm_body_capture",
"thirdPartyTelemetryExport": "third_party_telemetry_export",
}
for evidence_name, policy_name in mapping.items():
_bool(
logging.get(evidence_name),
bool(POLICY["logging"][policy_name]),
f"LOGGING_{evidence_name.upper()}_POLICY_MISMATCH",
)
reviewed = _parse_time(logging.get("reviewedAt"), "LOGGING_REVIEW_TIME_INVALID")
if reviewed > now + timedelta(minutes=int(POLICY["max_clock_skew_minutes"])):
_fail("LOGGING_REVIEW_TIME_FUTURE")
if now - reviewed > timedelta(hours=int(POLICY["max_age_hours"])):
_fail("LOGGING_REVIEW_STALE")
def verify( # ruff: ignore[too-many-branches, undocumented-public-function]
evidence_file: Path,
*,
now: datetime | None = None,
) -> dict[str, Any]:
if evidence_file.is_symlink():
_fail("EVIDENCE_SYMLINK_FORBIDDEN")
if not evidence_file.exists() or not evidence_file.is_file():
_fail("EVIDENCE_FILE_MISSING")
if evidence_file.stat().st_size > int(POLICY["max_manifest_bytes"]):
_fail("EVIDENCE_MANIFEST_TOO_LARGE")
evidence_file = evidence_file.resolve()
try:
doc = json.loads(evidence_file.read_text(encoding="utf-8"))
except Exception as exc:
raise EvidenceError("EVIDENCE_JSON_INVALID") from exc
doc = _mapping(doc, "EVIDENCE_ROOT_INVALID")
_walk_values(doc)
_exact_keys(
doc,
{
"schemaVersion",
"release",
"source",
"image",
"artifacts",
"redis",
"logging",
"riskExceptions",
},
"EVIDENCE_SCHEMA_FIELDS_INVALID",
)
if doc.get("schemaVersion") != POLICY["evidence_schema_version"]:
_fail("EVIDENCE_SCHEMA_UNSUPPORTED")
release = _mapping(doc.get("release"), "RELEASE_BLOCK_MISSING")
_exact_keys(
release,
{"releaseId", "generatedAt", "expiresAt", "proxyVersion", "targetPlatform"},
"RELEASE_SCHEMA_INVALID",
)
if (
not isinstance(release.get("releaseId"), str)
or RELEASE_ID.fullmatch(release["releaseId"]) is None
):
_fail("RELEASE_ID_INVALID")
if release.get("targetPlatform") != POLICY["target_platform"]:
_fail("RELEASE_PLATFORM_MISMATCH")
if release.get("proxyVersion") != _current_proxy_version():
_fail("RELEASE_PROXY_VERSION_MISMATCH")
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
generated = _parse_time(release.get("generatedAt"), "RELEASE_GENERATED_AT_INVALID")
expires = _parse_time(release.get("expiresAt"), "RELEASE_EXPIRES_AT_INVALID")
skew = timedelta(minutes=int(POLICY["max_clock_skew_minutes"]))
if generated > current + skew:
_fail("RELEASE_EVIDENCE_FROM_FUTURE")
if current - generated > timedelta(hours=int(POLICY["max_age_hours"])):
_fail("RELEASE_EVIDENCE_STALE")
if expires <= current or expires <= generated:
_fail("RELEASE_EVIDENCE_EXPIRED")
if expires - generated > timedelta(hours=int(POLICY["max_age_hours"])):
_fail("RELEASE_EVIDENCE_EXPIRY_TOO_LONG")
source = _mapping(doc.get("source"), "SOURCE_BLOCK_MISSING")
_exact_keys(
source,
{
"requirementsLockSha256",
"pythonSbomSha256",
"runtimeSourceSha256",
"baseImageIndexDigest",
"baseImageManifestDigest",
},
"SOURCE_SCHEMA_INVALID",
)
lock_sha = _hex(source.get("requirementsLockSha256"), "LOCK_SHA256_INVALID")
sbom_sha = _hex(source.get("pythonSbomSha256"), "PYTHON_SBOM_SHA256_INVALID")
if lock_sha != _sha256(ROOT / SUPPLY["lock_file"]):
_fail("LOCK_EVIDENCE_SOURCE_MISMATCH")
if sbom_sha != _sha256(ROOT / SUPPLY["sbom_file"]):
_fail("PYTHON_SBOM_EVIDENCE_SOURCE_MISMATCH")
runtime_sha = _hex(
source.get("runtimeSourceSha256"), "RUNTIME_SOURCE_SHA256_INVALID"
)
if runtime_sha != _runtime_source_sha256():
_fail("RUNTIME_SOURCE_EVIDENCE_MISMATCH")
if source.get("baseImageIndexDigest") != SUPPLY["base_image"]["index_digest"]:
_fail("BASE_IMAGE_INDEX_MISMATCH")
base_manifest_digest = _digest(
source.get("baseImageManifestDigest"), "BASE_IMAGE_MANIFEST_DIGEST_INVALID"
)
if base_manifest_digest == source.get("baseImageIndexDigest"):
_fail("BASE_IMAGE_MANIFEST_UNRESOLVED")
image = _mapping(doc.get("image"), "IMAGE_BLOCK_MISSING")
_exact_keys(image, {"digest", "platform"}, "IMAGE_SCHEMA_INVALID")
image_digest = _digest(image.get("digest"), "IMAGE_DIGEST_INVALID")
if image.get("platform") != POLICY["target_platform"]:
_fail("IMAGE_PLATFORM_MISMATCH")
artifacts = _mapping(doc.get("artifacts"), "ARTIFACTS_BLOCK_MISSING")
_exact_keys(
artifacts,
{
"dependencyScan",
"imageScan",
"imageSbom",
"provenance",
"signatureVerification",
},
"ARTIFACTS_SCHEMA_INVALID",
)
if POLICY["require_dependency_scan"]:
_verify_artifact(
evidence_file.parent,
artifacts.get("dependencyScan"),
"dependency_scan",
subject="sha256:" + lock_sha,
)
if POLICY["require_image_vulnerability_scan"]:
_verify_artifact(
evidence_file.parent,
artifacts.get("imageScan"),
"image_scan",
subject=image_digest,
)
if POLICY["require_full_image_sbom"]:
sbom_path = _verify_artifact(
evidence_file.parent,
artifacts.get("imageSbom"),
"image_sbom",
subject=image_digest,
)
try:
image_sbom = json.loads(sbom_path.read_text())
except Exception as exc:
raise EvidenceError("IMAGE_SBOM_JSON_INVALID") from exc
if (
not isinstance(image_sbom, dict)
or image_sbom.get("bomFormat") != "CycloneDX"
):
_fail("IMAGE_SBOM_NOT_CYCLONEDX")
if _version_tuple(
image_sbom.get("specVersion"), "IMAGE_SBOM_SPEC_VERSION_INVALID"
) < _version_tuple(
POLICY["minimum_cyclonedx_spec_version"], "POLICY_CYCLONEDX_VERSION_INVALID"
):
_fail("IMAGE_SBOM_SPEC_VERSION_TOO_OLD")
if POLICY["require_slsa_provenance"]:
provenance_item = _mapping(artifacts.get("provenance"), "PROVENANCE_INVALID")
provenance_path = _verify_artifact(
evidence_file.parent,
provenance_item,
"provenance",
subject=image_digest,
extra_keys={"predicateType", "signatureVerified"},
)
_verify_provenance(
provenance_path, image_digest, base_manifest_digest, provenance_item
)
if POLICY["require_signature_verification"]:
_verify_artifact(
evidence_file.parent,
artifacts.get("signatureVerification"),
"signature_verification",
subject=image_digest,
)
_verify_redis(doc, current)
if POLICY["require_log_privacy_attestation"]:
_verify_logging(doc, current)
exceptions = doc.get("riskExceptions")
if not isinstance(exceptions, list):
_fail("RISK_EXCEPTIONS_INVALID")
if POLICY["forbid_unexpired_risk_exceptions"] and exceptions:
_fail("RISK_EXCEPTIONS_FORBIDDEN")
return {
"ok": True,
"schema_version": doc["schemaVersion"],
"release_id": release["releaseId"],
"proxy_version": release["proxyVersion"],
"target_platform": release["targetPlatform"],
"image_digest": image_digest,
"runtime_source_sha256": runtime_sha,
"evidence_sha256": _sha256(evidence_file),
"evidence_expires_at": release["expiresAt"],
"redis_planes": ["rateLimit", "share", "contribution"],
"logging_privacy": "verified",
}
def main( # ruff: ignore[undocumented-public-function]
argv: list[str] | None = None,
) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("evidence", type=Path, help="Path to release-evidence.json")
args = parser.parse_args(argv)
try:
result = verify(args.evidence)
except EvidenceError as exc:
sys.stderr.write(
json.dumps({"ok": False, "code": str(exc)}, sort_keys=True) + "\n"
)
return 2
sys.stdout.write(json.dumps(result, sort_keys=True) + "\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())