The public tree and its history contain only the listed paths. Earlier projection history remains preserved internally. Source-Sha: 913fde029b935671833254797f0f20f1eb9fabba Policy-Sha: 913fde029b935671833254797f0f20f1eb9fabba Tree-Digest: 180ae530c1058a2a5c89837bdce2d323ae83e669e38590ca72e75b8d92b7262f
301 lines
12 KiB
Python
301 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Assemble and verify shared release-contract v1 candidates."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
import shutil
|
|
import sys
|
|
import tomllib
|
|
|
|
STATEMENT_TYPE = "https://in-toto.io/Statement/v1"
|
|
PREDICATE_TYPE = "https://slsa.dev/provenance/v1"
|
|
PROVENANCE_FORMAT = "in-toto-statement+slsa-provenance-v1"
|
|
|
|
|
|
class ContractError(ValueError):
|
|
pass
|
|
|
|
|
|
def sha256(path: Path) -> str:
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as handle:
|
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def safe_filename(value: str) -> str:
|
|
if not value or value in {".", ".."} or Path(value).name != value or "/" in value or "\\" in value:
|
|
raise ContractError(f"unsafe candidate filename: {value!r}")
|
|
return value
|
|
|
|
|
|
def load_json(path: Path):
|
|
try:
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError) as error:
|
|
raise ContractError(f"cannot read JSON {path}: {error}") from error
|
|
|
|
|
|
def write_json(path: Path, value) -> None:
|
|
path.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
|
|
|
|
def require_text(mapping: dict, key: str) -> str:
|
|
value = mapping.get(key)
|
|
if not isinstance(value, str) or not value:
|
|
raise ContractError(f"{key} must be a non-empty string")
|
|
return value
|
|
|
|
|
|
def validate_commit(value: str) -> str:
|
|
if len(value) not in range(40, 65) or any(char not in "0123456789abcdef" for char in value):
|
|
raise ContractError("source commit must be 40-64 lowercase hexadecimal characters")
|
|
return value
|
|
|
|
|
|
def assemble(args: argparse.Namespace) -> None:
|
|
declaration_path = Path(args.declaration)
|
|
declaration = tomllib.loads(declaration_path.read_text(encoding="utf-8"))
|
|
if declaration.get("schema_version") != 1:
|
|
raise ContractError("declaration schema_version must be 1")
|
|
|
|
project = require_text(declaration, "project")
|
|
version = require_text(declaration, "version")
|
|
repository = require_text(declaration, "source_repository")
|
|
build_type = require_text(declaration, "build_type")
|
|
source_commit = validate_commit(args.source_commit)
|
|
source_tag = require_text(vars(args), "source_tag")
|
|
|
|
descriptors = load_json(Path(args.artifacts))
|
|
if not isinstance(descriptors, list) or not descriptors:
|
|
raise ContractError("artifact descriptors must be a non-empty JSON array")
|
|
|
|
output = Path(args.output)
|
|
if output.resolve() in {Path("/").resolve(), Path(".").resolve()}:
|
|
raise ContractError("refusing unsafe output directory")
|
|
if output.exists():
|
|
shutil.rmtree(output)
|
|
output.mkdir(parents=True)
|
|
|
|
manifest_artifacts = []
|
|
seen = set()
|
|
for descriptor in descriptors:
|
|
if not isinstance(descriptor, dict):
|
|
raise ContractError("each artifact descriptor must be an object")
|
|
source = Path(require_text(descriptor, "path"))
|
|
filename = safe_filename(require_text(descriptor, "filename"))
|
|
if filename in seen:
|
|
raise ContractError(f"duplicate artifact filename: {filename}")
|
|
seen.add(filename)
|
|
if not source.is_file() or source.is_symlink():
|
|
raise ContractError(f"artifact must be a regular non-symlink file: {source}")
|
|
destination = output / filename
|
|
shutil.copyfile(source, destination)
|
|
manifest_artifacts.append(
|
|
{
|
|
"filename": filename,
|
|
"media_type": require_text(descriptor, "media_type"),
|
|
"package_type": require_text(descriptor, "package_type"),
|
|
"target_arch": require_text(descriptor, "target_arch"),
|
|
"target_os": require_text(descriptor, "target_os"),
|
|
"size": destination.stat().st_size,
|
|
"sha256": sha256(destination),
|
|
}
|
|
)
|
|
|
|
manifest_artifacts.sort(key=lambda item: item["filename"])
|
|
build = {
|
|
"builder_id": args.builder_id,
|
|
"build_type": build_type,
|
|
"invocation_id": args.invocation_id,
|
|
"workflow_id": args.workflow_id,
|
|
}
|
|
for key, value in build.items():
|
|
if not value:
|
|
raise ContractError(f"{key} must be a non-empty string")
|
|
|
|
manifest = {
|
|
"artifacts": manifest_artifacts,
|
|
"build": build,
|
|
"evidence": {
|
|
"authenticated": False,
|
|
"provenance_format": PROVENANCE_FORMAT,
|
|
"slsa_level_claimed": False,
|
|
},
|
|
"project": project,
|
|
"schema_version": 1,
|
|
"source": {"commit": source_commit, "repository": repository, "tag": source_tag},
|
|
"version": version,
|
|
}
|
|
write_json(output / "manifest.json", manifest)
|
|
|
|
subjects = [
|
|
{"name": item["filename"], "digest": {"sha256": item["sha256"]}}
|
|
for item in manifest_artifacts
|
|
]
|
|
provenance = {
|
|
"_type": STATEMENT_TYPE,
|
|
"predicateType": PREDICATE_TYPE,
|
|
"subject": subjects,
|
|
"predicate": {
|
|
"buildDefinition": {
|
|
"buildType": build_type,
|
|
"externalParameters": {"project": project, "sourceTag": source_tag, "version": version},
|
|
"internalParameters": {},
|
|
"resolvedDependencies": [
|
|
{"uri": repository, "digest": {"gitCommit": source_commit}}
|
|
],
|
|
},
|
|
"runDetails": {
|
|
"builder": {"id": args.builder_id},
|
|
"metadata": {"invocationId": args.invocation_id},
|
|
},
|
|
},
|
|
}
|
|
write_json(output / "provenance.json", provenance)
|
|
|
|
receipt_paths = [output / item["filename"] for item in manifest_artifacts]
|
|
receipt_paths.extend([output / "manifest.json", output / "provenance.json"])
|
|
lines = [f"{sha256(path)} {path.name}" for path in sorted(receipt_paths, key=lambda path: path.name)]
|
|
(output / "files.sha256").write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
|
|
|
|
def parse_receipt(candidate: Path) -> dict[str, str]:
|
|
receipt = candidate / "files.sha256"
|
|
try:
|
|
lines = receipt.read_text(encoding="utf-8").splitlines()
|
|
except OSError as error:
|
|
raise ContractError(f"cannot read receipt: {error}") from error
|
|
if not lines:
|
|
raise ContractError("receipt is empty")
|
|
entries: dict[str, str] = {}
|
|
for line in lines:
|
|
parts = line.split(" ", 1)
|
|
if len(parts) != 2 or len(parts[0]) != 64 or any(char not in "0123456789abcdef" for char in parts[0]):
|
|
raise ContractError(f"invalid receipt line: {line!r}")
|
|
filename = safe_filename(parts[1])
|
|
if filename in entries:
|
|
raise ContractError(f"duplicate receipt filename: {filename}")
|
|
entries[filename] = parts[0]
|
|
return entries
|
|
|
|
|
|
def verify(args: argparse.Namespace) -> None:
|
|
candidate = Path(args.candidate)
|
|
entries = parse_receipt(candidate)
|
|
for filename, expected in entries.items():
|
|
path = candidate / filename
|
|
if not path.is_file() or path.is_symlink():
|
|
raise ContractError(f"receipt file is absent or unsafe: {filename}")
|
|
if sha256(path) != expected:
|
|
raise ContractError(f"receipt digest mismatch: {filename}")
|
|
|
|
manifest = load_json(candidate / "manifest.json")
|
|
if manifest.get("schema_version") != 1:
|
|
raise ContractError("manifest schema_version must be 1")
|
|
if manifest.get("evidence") != {
|
|
"authenticated": False,
|
|
"provenance_format": PROVENANCE_FORMAT,
|
|
"slsa_level_claimed": False,
|
|
}:
|
|
raise ContractError("manifest evidence posture is invalid")
|
|
source = manifest.get("source")
|
|
build = manifest.get("build")
|
|
artifacts = manifest.get("artifacts")
|
|
if not isinstance(source, dict) or not isinstance(build, dict) or not isinstance(artifacts, list) or not artifacts:
|
|
raise ContractError("manifest source, build, or artifacts are invalid")
|
|
|
|
expected_values = {
|
|
"project": (manifest.get("project"), args.expect_project),
|
|
"source commit": (source.get("commit"), args.expect_source_commit),
|
|
"source tag": (source.get("tag"), args.expect_source_tag),
|
|
"builder": (build.get("builder_id"), args.expect_builder_id),
|
|
"workflow": (build.get("workflow_id"), args.expect_workflow_id),
|
|
}
|
|
for label, (actual, expected) in expected_values.items():
|
|
if expected is not None and actual != expected:
|
|
raise ContractError(f"unexpected {label}: {actual!r}")
|
|
|
|
artifact_digests: dict[str, str] = {}
|
|
expected_receipt = {"manifest.json", "provenance.json"}
|
|
for artifact in artifacts:
|
|
if not isinstance(artifact, dict):
|
|
raise ContractError("manifest artifact must be an object")
|
|
filename = safe_filename(require_text(artifact, "filename"))
|
|
if filename in artifact_digests:
|
|
raise ContractError(f"duplicate manifest artifact: {filename}")
|
|
path = candidate / filename
|
|
digest = require_text(artifact, "sha256")
|
|
if len(digest) != 64 or sha256(path) != digest or path.stat().st_size != artifact.get("size"):
|
|
raise ContractError(f"manifest artifact mismatch: {filename}")
|
|
artifact_digests[filename] = digest
|
|
expected_receipt.add(filename)
|
|
if set(entries) != expected_receipt:
|
|
raise ContractError("receipt membership does not equal manifest artifacts plus evidence")
|
|
|
|
provenance = load_json(candidate / "provenance.json")
|
|
if provenance.get("_type") != STATEMENT_TYPE or provenance.get("predicateType") != PREDICATE_TYPE:
|
|
raise ContractError("provenance statement type is invalid")
|
|
subjects = provenance.get("subject")
|
|
if not isinstance(subjects, list):
|
|
raise ContractError("provenance subjects are invalid")
|
|
subject_digests = {
|
|
safe_filename(item.get("name", "")): item.get("digest", {}).get("sha256")
|
|
for item in subjects if isinstance(item, dict) and isinstance(item.get("digest"), dict)
|
|
}
|
|
if subject_digests != artifact_digests:
|
|
raise ContractError("provenance subjects do not equal manifest artifacts")
|
|
definition = provenance.get("predicate", {}).get("buildDefinition", {})
|
|
details = provenance.get("predicate", {}).get("runDetails", {})
|
|
dependencies = definition.get("resolvedDependencies")
|
|
expected_dependency = [{"uri": source.get("repository"), "digest": {"gitCommit": source.get("commit")}}]
|
|
if definition.get("buildType") != build.get("build_type") or dependencies != expected_dependency:
|
|
raise ContractError("provenance build type or source dependency does not match manifest")
|
|
if details.get("builder", {}).get("id") != build.get("builder_id"):
|
|
raise ContractError("provenance builder does not match manifest")
|
|
if details.get("metadata", {}).get("invocationId") != build.get("invocation_id"):
|
|
raise ContractError("provenance invocation does not match manifest")
|
|
|
|
|
|
def parser() -> argparse.ArgumentParser:
|
|
root = argparse.ArgumentParser()
|
|
commands = root.add_subparsers(dest="command", required=True)
|
|
create = commands.add_parser("assemble")
|
|
create.add_argument("--declaration", required=True)
|
|
create.add_argument("--artifacts", required=True)
|
|
create.add_argument("--output", required=True)
|
|
create.add_argument("--source-commit", required=True)
|
|
create.add_argument("--source-tag", required=True)
|
|
create.add_argument("--builder-id", required=True)
|
|
create.add_argument("--workflow-id", required=True)
|
|
create.add_argument("--invocation-id", required=True)
|
|
create.set_defaults(func=assemble)
|
|
|
|
check = commands.add_parser("verify")
|
|
check.add_argument("--candidate", required=True)
|
|
check.add_argument("--expect-project")
|
|
check.add_argument("--expect-source-commit")
|
|
check.add_argument("--expect-source-tag")
|
|
check.add_argument("--expect-builder-id")
|
|
check.add_argument("--expect-workflow-id")
|
|
check.set_defaults(func=verify)
|
|
return root
|
|
|
|
|
|
def main() -> int:
|
|
args = parser().parse_args()
|
|
try:
|
|
args.func(args)
|
|
except (ContractError, OSError, tomllib.TOMLDecodeError) as error:
|
|
print(f"release-contract: {error}", file=sys.stderr)
|
|
return 1
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|