diff --git a/CHANGELOG.md b/CHANGELOG.md index a1e9dc0..c688b35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ### Added +- Add a credential-free six-file release-evidence preparer that requires exactly + one matching wheel and source distribution, enforces compressed-byte bounds + before archive parsing, generates paired deterministic CycloneDX 1.7 SBOMs, + canonical source identity, and filename-sorted checksums, creates owner-only + exclusive outputs and a separately stored handoff, and independently + re-verifies the complete set after publication without claiming hosted + provenance or a SLSA Build level. - Add canonical `SOURCE_IDENTITY.json` evidence that seals the exact repository and 40-character protected-main source commit inside the checksummed release set. Handoff manifests now use format version 2 and include both source-identity @@ -60,6 +67,13 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). without changing the centrally managed review-agent credential contract. ### Security +- Bind each preflighted wheel and source distribution to its accepted device, + inode, and finite byte size through a no-follow descriptor, then copy only + bounded bytes into a fresh owner-only parser snapshot. ZIP and tar parsers + never receive the mutable caller-controlled pathname, so post-preflight path + replacement fails before parser execution; later artifact hashing and complete + evidence-set verification remain defense in depth without claiming immutable + local storage, hosted provenance, or a SLSA Build level. - Canonicalize the public manifest writer's optional `forbidden_root` before any output-parent creation or output-path access. Missing, non-directory, symlinked, unresolvable, or otherwise noncanonical roots now fail with one @@ -361,4 +375,4 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). gap (CWE-350), with redirects and environment proxies disabled. - `EgressNotAllowedError` (a `ValueError` subclass) and `ValidatedEgressURL`. - 35 tests covering URL rejection, address classification, the `allow_local` - container case, DNS-to-private rejection, and transport pinning. + container case, DNS-to-private rejection, and transport pinning. \ No newline at end of file diff --git a/docs/release-evidence-preparation.md b/docs/release-evidence-preparation.md new file mode 100644 index 0000000..c32b130 --- /dev/null +++ b/docs/release-evidence-preparation.md @@ -0,0 +1,173 @@ +# Release evidence preparation runbook + +## Purpose and trust boundary + +`scripts/ci/prepare_release_evidence.py` prepares the exact credential-free input +set consumed by the shipped sealed-evidence verifier. It operates only on inert, +already-built wheel and source-distribution archives. It does not import either +distribution, resolve dependencies, use the network, sign an artifact, publish a +package, create or move a tag, alter a protected ref, or acquire repository-write, +OIDC, attestation, package-index, or release credentials. + +The preparation control is intentionally separate from the organization-owned +credentialed attestation workflow. A valid handoff proves that one exact local +six-file set is internally consistent and bound to an explicit repository and +source commit. It does **not** prove that the distributions were honestly built +from that source and does not claim a SLSA Build level. That stronger claim +requires independently reviewed hosted-build provenance and credential-separated +attestation verification. + +## Initial directory contract + +Use a fresh real directory reached through a canonical absolute path with no +symbolic-link component. Before preparation, the directory must contain exactly +two regular direct-child files for one matching stable version: + +```text +egressweave-X.Y.Z-py3-none-any.whl +egressweave-X.Y.Z.tar.gz +``` + +Any additional file, nested directory, symbolic link, malformed archive name, +duplicate distribution kind, or wheel/source version mismatch fails before an +evidence output is created. The reviewed dependency manifest and hash-locked +runtime requirements must also be existing canonical regular files. + +Each selected wheel and source distribution is preflighted as a current regular +file with a finite compressed-byte bound before the deterministic generator is +loaded or any ZIP or tar archive parser runs. The preparer records that exact +device, inode, and size identity, opens the pathname with no-follow semantics, +and requires the opened descriptor and current pathname to match the accepted +identity. It then copies the bounded descriptor bytes into a fresh owner-only +parser-only snapshot with the same canonical filename. The parser receives only +that private snapshot and never the caller-controlled evidence pathname, so a +post-preflight path replacement cannot redirect parser work to an alternate or +oversized archive. + +The preparer rechecks the accepted descriptor and pathname after the bounded +copy. Archive-member cardinality, path, metadata, and semantic validation remain +separate controls. The original distributions are independently descriptor-bound, +hashed, and revalidated again while checksums and the final sealed evidence set +are produced. A writer that mutates the same accepted inode can still make the +candidate fail at a later digest or identity check; the control does not claim +immutable local storage and never converts such a race into trusted evidence. + +The handoff-manifest parent must already exist as a real canonical directory. The +handoff path must remain outside the evidence directory. The preparer never +creates convenience directory aliases and never follows an output-path symbolic +link. + +## Credential-free command + +Run this only after the exact protected-main source commit has passed all quality, +security, review, approval, package-acceptance, and reproducibility gates: + +```bash +PYTHONPATH=src python scripts/ci/prepare_release_evidence.py \ + --evidence-dir "$RUNNER_TEMP/release-evidence" \ + --handoff-manifest "$RUNNER_TEMP/release-evidence-manifest.json" \ + --repository ContextualWisdomLab/EgressWeave \ + --source-sha "$GITHUB_SHA" \ + --dependency-manifest scripts/ci/runtime-dependency-manifest.json \ + --runtime-lock requirements-runtime.txt +``` + +The command must run in a job whose token has no write permission and whose +checkout is detached at the exact accepted source SHA with persisted credentials +disabled. The job must not expose signing, publication, release, tag, model, or +attestation credentials. + +## Generated contract + +The preparer computes both deterministic CycloneDX 1.7 JSON documents from the +private identity-bound parser snapshots, constructs canonical strict-JSON source +identity, computes sorted lowercase SHA-256 entries over the original accepted +distributions and generated payloads, and then exclusively creates owner-only +generated files. The private parser snapshots are deleted with their temporary +directory before any generated evidence is published. After successful +preparation, the evidence directory contains exactly: + +```text +egressweave-X.Y.Z-py3-none-any.whl +egressweave-X.Y.Z-py3-none-any.whl.cdx.json +egressweave-X.Y.Z.tar.gz +egressweave-X.Y.Z.tar.gz.cdx.json +SOURCE_IDENTITY.json +SHA256SUMS +``` + +`SOURCE_IDENTITY.json` uses the versioned canonical profile documented in +`sealed-release-evidence.md`. `SHA256SUMS` covers all five other payloads and is +ordered by filename, not by digest. The SBOM root components bind to the exact +archive filenames and SHA-256 values and use deterministic content-derived UUID +version 5 serial numbers. + +The preparer then invokes the shipped verifier to: + +1. validate cardinality, names, sizes, strict JSON, source identity, checksums, + CycloneDX profile, artifact/SBOM bindings, and descriptor/path identity; +2. create a new owner-only deterministic handoff manifest outside the set; +3. independently rebuild the complete manifest semantics from a second bounded + evidence pass; +4. reread the closed handoff through bounded descriptor/path checks; and +5. report success only when both post-publication snapshots exactly match. + +## Failure and retry semantics + +Every failure is non-success. A failed run may leave newly created but untrusted +partial evidence because local filesystems cannot atomically publish six separate +paths as one transaction. Never repair, overwrite, or reuse that candidate in +place. Delete the complete disposable directory and failed handoff, rebuild the +wheel and source distribution from the unchanged exact accepted source in a clean +credential-free job, and run the full preparation again with a fresh output path. + +Do not treat a queued check, review latency, an incomplete automated review, or a +pending external approval as accepted release evidence. Do not pass a failed or +partially generated set to any job holding write, OIDC, attestation, publication, +tag, or release authority. + +## Credentialed consumer requirements + +A later organization-owned reusable workflow may consume only an immutable copy +of the six payloads plus the separately stored handoff. Before requesting an +attestation, that workflow must recheck the exact repository, source SHA, +source-identity digest, checksum-file digest, payload cardinality, and every +payload digest. It must not rebuild archives, resolve dependencies, import the +wheel, execute repository scripts, or accept a branch name in place of the exact +source commit while privileged credentials are present. + +Workflow source must be immutable and independently reviewed. Artifact transfer +must be digest-bound, and publication must refuse stale protected-main heads, +mutable tags, alternate artifact sets, or handoff/source disagreement. The +repository-side preparer deliberately contains no fallback that weakens these +organization controls. + +## Standards alignment and precise claims + +- The JSON encoders reject non-finite values and emit deterministic RFC 8259 JSON. +- SBOMs use the CycloneDX 1.7 JSON schema and bind their root components to exact + distribution bytes. +- Repository and source identity are checksum-covered assertions, not provenance. +- No SLSA Build level is claimed. Future claims must be stated as `SLSA Build Lx + (v1.2)` only after every normative requirement is mapped to independently + verifiable evidence. +- The clean rebuild and evidence-preservation procedure supports NIST SSDF + practices for protecting release integrity and retaining evidence useful to + suppliers, purchasers, and assessors. + +## References + +Bray, T. (2017). *The JavaScript Object Notation (JSON) data interchange format* +(RFC 8259). Internet Engineering Task Force. https://doi.org/10.17487/RFC8259 + +OWASP Foundation. (n.d.). *CycloneDX v1.7 JSON reference*. Retrieved August 6, +2026, from https://cyclonedx.org/docs/1.7/json/ + +Souppaya, M., Scarfone, K., & Dodson, D. (2022). *Secure software development +framework (SSDF) version 1.1: Recommendations for mitigating the risk of software +vulnerabilities* (NIST SP 800-218). National Institute of Standards and +Technology. https://doi.org/10.6028/NIST.SP.800-218 + +Supply-chain Levels for Software Artifacts. (n.d.). *Build provenance: SLSA +specification v1.2*. Retrieved August 6, 2026, from +https://slsa.dev/spec/v1.2/build-provenance diff --git a/docs/release.md b/docs/release.md index 909a785..20c4d71 100644 --- a/docs/release.md +++ b/docs/release.md @@ -68,6 +68,22 @@ a repository-secret fallback. 100% statement/branch coverage, docstring checks, and independent review gates pass. +### Credential-free release-evidence preparation + +After local and pull-request package acceptance, the separately documented +[release-evidence preparation control](release-evidence-preparation.md) may be +run only from a read-only, credential-free checkout detached at the exact +accepted source SHA. It requires exactly one wheel and matching source +distribution, applies compressed-byte bounds before archive parsing, and creates +the deterministic six-file evidence set plus a separately stored handoff for +independent re-verification. + +This branch-local preparer is not integrated into the credential-bearing release +workflow and does not modify or weaken that workflow's tag, OIDC, publication, +release, or approval boundaries. Its output is a credential-free consistency +handoff, not hosted build provenance, publication authorization, or a SLSA Build +level claim. + ## Publish 1. Open **Actions → release → Run workflow**. diff --git a/scripts/ci/prepare_release_evidence.py b/scripts/ci/prepare_release_evidence.py new file mode 100644 index 0000000..f3ed7a2 --- /dev/null +++ b/scripts/ci/prepare_release_evidence.py @@ -0,0 +1,492 @@ +"""Prepare one credential-free six-file release evidence set. + +The script treats built distributions as untrusted inert archives. It generates +paired deterministic CycloneDX 1.7 documents, seals exact repository and source +identity, writes sorted checksums, and asks the shipped verifier to create and +independently recheck a handoff manifest outside the evidence directory. It has +no network, signing, publication, release, tag, ref, model, or credential logic. +""" + +from __future__ import annotations + +import argparse +import hashlib +import importlib.util +import json +import os +import stat +import tempfile +from pathlib import Path +from types import ModuleType +from typing import Any + +from egressweave import release_evidence + +ATTESTABLE_GENERATOR_PATH = Path(__file__).with_name( + "generate_attestable_release_sbom.py" +) +MAX_DISTRIBUTION_BYTES = release_evidence.MAX_ARTIFACT_BYTES +COPY_BLOCK_BYTES = 1_048_576 +DistributionIdentity = tuple[int, int, int] + +__all__ = ["main", "prepare_release_evidence"] + + +def _parse_arguments() -> argparse.Namespace: + """Parse exact evidence, reviewed dependency, identity, and handoff inputs.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--evidence-dir", type=Path, required=True) + parser.add_argument("--handoff-manifest", type=Path, required=True) + parser.add_argument("--repository", required=True) + parser.add_argument("--source-sha", required=True) + parser.add_argument("--dependency-manifest", type=Path, required=True) + parser.add_argument("--runtime-lock", type=Path, required=True) + return parser.parse_args() + + +def _require_source_identity(repository: str, source_sha: str) -> None: + """Require the exact repository and one lowercase Git object identifier.""" + if ( + repository != release_evidence.EXPECTED_REPOSITORY + or release_evidence.SOURCE_SHA_PATTERN.fullmatch(source_sha) is None + ): + raise SystemExit("release repository or source identity is invalid") + + +def _require_canonical_directory(path: Path, *, label: str) -> Path: + """Return one existing real directory reached without symbolic links.""" + if not path.is_dir() or path.is_symlink(): + raise SystemExit(f"{label} is missing or unsafe") + try: + lexical_path = Path(os.path.abspath(path)) + resolved_path = path.resolve(strict=True) + except (OSError, RuntimeError) as error: + raise SystemExit(f"{label} is missing or unsafe") from error + if lexical_path != resolved_path: + raise SystemExit(f"{label} must not traverse symbolic links") + return resolved_path + + +def _require_canonical_file(path: Path, *, label: str) -> Path: + """Return one existing regular repository input reached without links.""" + if not path.is_file() or path.is_symlink(): + raise SystemExit(f"{label} is missing or unsafe") + try: + lexical_path = Path(os.path.abspath(path)) + resolved_path = path.resolve(strict=True) + path_state = path.lstat() + except (OSError, RuntimeError) as error: + raise SystemExit(f"{label} is missing or unsafe") from error + if lexical_path != resolved_path or not stat.S_ISREG(path_state.st_mode): + raise SystemExit(f"{label} is missing or unsafe") + return resolved_path + + +def _require_handoff_outside_evidence( + handoff_path: Path, + evidence_root: Path, +) -> Path: + """Return one output path whose real parent remains outside sealed evidence.""" + parent = _require_canonical_directory( + handoff_path.parent, + label="handoff manifest parent", + ) + resolved_output = parent / handoff_path.name + if resolved_output == evidence_root or resolved_output.is_relative_to(evidence_root): + raise SystemExit("handoff manifest must remain outside the sealed evidence set") + return resolved_output + + +def _select_distributions(evidence_root: Path) -> tuple[Path, Path]: + """Select exactly one canonical wheel and one matching source distribution.""" + try: + entries = sorted(evidence_root.iterdir(), key=lambda path: path.name) + except OSError as error: + raise SystemExit("release evidence input directory is unreadable") from error + if any(path.is_symlink() or not path.is_file() for path in entries): + raise SystemExit("release evidence inputs must be regular direct-child files") + + wheels = [path for path in entries if release_evidence.WHEEL_PATTERN.fullmatch(path.name)] + sdists = [path for path in entries if release_evidence.SDIST_PATTERN.fullmatch(path.name)] + if len(entries) != 2 or len(wheels) != 1 or len(sdists) != 1: + raise SystemExit( + "release evidence inputs require exactly one wheel and source distribution" + ) + + wheel_match = release_evidence.WHEEL_PATTERN.fullmatch(wheels[0].name) + sdist_match = release_evidence.SDIST_PATTERN.fullmatch(sdists[0].name) + if wheel_match is None or sdist_match is None: + raise SystemExit( + "release evidence inputs require exactly one wheel and source distribution" + ) + if wheel_match.group("version") != sdist_match.group("version"): + raise SystemExit("release wheel and source distribution versions do not match") + return wheels[0], sdists[0] + + +def _distribution_identity(metadata: os.stat_result) -> DistributionIdentity: + """Return the device, inode, and finite byte size that identify one archive.""" + return metadata.st_dev, metadata.st_ino, metadata.st_size + + +def _require_distribution_metadata( + metadata: os.stat_result, + *, + label: str, +) -> DistributionIdentity: + """Return one regular finite distribution identity or fail through stable errors.""" + if not stat.S_ISREG(metadata.st_mode): + raise SystemExit(f"{label} is unreadable or unsafe") + if metadata.st_size > MAX_DISTRIBUTION_BYTES: + raise SystemExit(f"{label} exceeds the safety bound") + return _distribution_identity(metadata) + + +def _require_distribution_preflight( + path: Path, + *, + label: str, +) -> DistributionIdentity: + """Bind current regular-file identity before loading any archive parser.""" + try: + path_state = path.lstat() + except OSError as error: + raise SystemExit(f"{label} is unreadable or unsafe") from error + return _require_distribution_metadata(path_state, label=label) + + +def _snapshot_distribution( + path: Path, + snapshot_root: Path, + accepted_identity: DistributionIdentity, + *, + label: str, +) -> Path: + """Copy one accepted descriptor into a private parser-only immutable snapshot. + + The accepted path identity is checked against both the no-follow descriptor + and the current pathname before and after the bounded copy. Archive parsers + receive only the private snapshot, never the mutable caller-controlled path. + """ + read_flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0) + write_flags = ( + os.O_WRONLY + | os.O_CREAT + | os.O_EXCL + | getattr(os, "O_BINARY", 0) + | getattr(os, "O_NOFOLLOW", 0) + ) + source_descriptor: int | None = None + snapshot_descriptor: int | None = None + snapshot_path = snapshot_root / path.name + try: + source_descriptor = os.open(path, read_flags) + opened_identity = _require_distribution_metadata( + os.fstat(source_descriptor), + label=label, + ) + current_identity = _require_distribution_metadata(path.lstat(), label=label) + if opened_identity != accepted_identity or current_identity != accepted_identity: + raise SystemExit(f"{label} is unreadable or unsafe") + + snapshot_descriptor = os.open(snapshot_path, write_flags, 0o600) + os.fchmod(snapshot_descriptor, 0o600) + copied_bytes = 0 + while True: + block = os.read(source_descriptor, COPY_BLOCK_BYTES) + if not block: + break + copied_bytes += len(block) + if copied_bytes > MAX_DISTRIBUTION_BYTES: + raise SystemExit(f"{label} exceeds the safety bound") + remaining = memoryview(block) + while remaining: + written = os.write(snapshot_descriptor, remaining) + if written <= 0: + raise OSError("short snapshot write") + remaining = remaining[written:] + os.fsync(snapshot_descriptor) + + final_opened_identity = _require_distribution_metadata( + os.fstat(source_descriptor), + label=label, + ) + final_path_identity = _require_distribution_metadata(path.lstat(), label=label) + snapshot_state = os.fstat(snapshot_descriptor) + if ( + final_opened_identity != accepted_identity + or final_path_identity != accepted_identity + or not stat.S_ISREG(snapshot_state.st_mode) + or stat.S_IMODE(snapshot_state.st_mode) != 0o600 + or snapshot_state.st_size != copied_bytes + ): + raise SystemExit(f"{label} is unreadable or unsafe") + return snapshot_path + except FileExistsError: + raise SystemExit(f"{label} parser snapshot already exists") from None + except OSError as error: + raise SystemExit(f"{label} is unreadable or unsafe") from error + finally: + if snapshot_descriptor is not None: + os.close(snapshot_descriptor) + if source_descriptor is not None: + os.close(source_descriptor) + + +def _load_attestable_generator() -> ModuleType: + """Load the repository-only deterministic generator without importing archives.""" + specification = importlib.util.spec_from_file_location( + "egressweave_generate_attestable_release_sbom_for_preparation", + ATTESTABLE_GENERATOR_PATH, + ) + if specification is None or specification.loader is None: + raise SystemExit("attestable release SBOM generator could not be loaded") + module = importlib.util.module_from_spec(specification) + specification.loader.exec_module(module) + return module + + +def _strict_pretty_json_bytes(document: dict[str, Any]) -> bytes: + """Return deterministic indented strict-JSON bytes with one final newline.""" + try: + return ( + json.dumps( + document, + indent=2, + sort_keys=True, + ensure_ascii=True, + allow_nan=False, + ) + + "\n" + ).encode("utf-8") + except (RecursionError, TypeError, ValueError): + raise SystemExit("generated release evidence is not strict JSON") from None + + +def _source_identity_bytes(repository: str, source_sha: str) -> bytes: + """Return canonical compact source-identity bytes for the sealed set.""" + document = { + "format": release_evidence.SOURCE_IDENTITY_FORMAT, + "formatVersion": release_evidence.SOURCE_IDENTITY_VERSION, + "repository": repository, + "sourceSha": source_sha, + } + return ( + json.dumps( + document, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + allow_nan=False, + ) + + "\n" + ).encode("utf-8") + + +def _sha256_file(path: Path, *, label: str) -> str: + """Hash one bounded descriptor-bound regular distribution.""" + digest = hashlib.sha256() + total_bytes = 0 + try: + with path.open("rb") as stream: + path_state = path.lstat() + opened_state = os.fstat(stream.fileno()) + if ( + not stat.S_ISREG(path_state.st_mode) + or not stat.S_ISREG(opened_state.st_mode) + or (path_state.st_dev, path_state.st_ino) + != (opened_state.st_dev, opened_state.st_ino) + ): + raise SystemExit(f"{label} is unreadable or unsafe") + for block in iter(lambda: stream.read(1_048_576), b""): + total_bytes += len(block) + if total_bytes > MAX_DISTRIBUTION_BYTES: + raise SystemExit(f"{label} exceeds the safety bound") + digest.update(block) + except OSError as error: + raise SystemExit(f"{label} is unreadable") from error + return digest.hexdigest() + + +def _write_private_file(path: Path, payload: bytes, *, label: str) -> None: + """Exclusively create one owner-only regular file and durably write all bytes.""" + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0) + descriptor: int | None = None + try: + descriptor = os.open(path, flags, 0o600) + os.fchmod(descriptor, 0o600) + remaining = memoryview(payload) + while remaining: + written = os.write(descriptor, remaining) + if written <= 0: + raise OSError("short write") + remaining = remaining[written:] + os.fsync(descriptor) + opened_state = os.fstat(descriptor) + path_state = path.lstat() + if ( + not stat.S_ISREG(opened_state.st_mode) + or not stat.S_ISREG(path_state.st_mode) + or (opened_state.st_dev, opened_state.st_ino) + != (path_state.st_dev, path_state.st_ino) + or stat.S_IMODE(opened_state.st_mode) != 0o600 + ): + raise OSError("unsafe output identity") + except FileExistsError: + raise SystemExit(f"{label} already exists") from None + except OSError as error: + raise SystemExit(f"{label} cannot be created safely") from error + finally: + if descriptor is not None: + os.close(descriptor) + + +def _checksum_bytes(payloads: dict[str, bytes | Path]) -> bytes: + """Return sorted canonical SHA-256 lines for five exact payloads.""" + lines: list[str] = [] + for filename in sorted(payloads): + payload = payloads[filename] + digest = ( + _sha256_file(payload, label=f"release distribution {filename}") + if isinstance(payload, Path) + else hashlib.sha256(payload).hexdigest() + ) + lines.append(f"{digest} {filename}\n") + return "".join(lines).encode("ascii") + + +def prepare_release_evidence( + evidence_dir: Path, + handoff_path: Path, + *, + repository: str, + source_sha: str, + dependency_manifest_path: Path, + runtime_lock_path: Path, +) -> dict[str, Any]: + """Create and independently verify one credential-free release handoff. + + The input directory must initially contain only one canonical wheel and one + matching source distribution. Each accepted archive is copied from its + no-follow identity-bound descriptor into a private parser-only snapshot + before the generator loads. Every generated file is new, owner-only, and + deterministic. The returned mapping is rebuilt from the sealed six-file set + after the separately stored handoff has been durably published. + """ + _require_source_identity(repository, source_sha) + evidence_root = _require_canonical_directory( + evidence_dir, + label="release evidence input directory", + ) + resolved_handoff = _require_handoff_outside_evidence(handoff_path, evidence_root) + dependency_manifest = _require_canonical_file( + dependency_manifest_path, + label="reviewed runtime dependency manifest", + ) + runtime_lock = _require_canonical_file( + runtime_lock_path, + label="hash-locked runtime requirements", + ) + wheel_path, sdist_path = _select_distributions(evidence_root) + wheel_label = f"release distribution {wheel_path.name}" + sdist_label = f"release distribution {sdist_path.name}" + wheel_identity = _require_distribution_preflight(wheel_path, label=wheel_label) + sdist_identity = _require_distribution_preflight(sdist_path, label=sdist_label) + + with tempfile.TemporaryDirectory(prefix="egressweave-release-evidence-") as temporary: + snapshot_root = Path(temporary) + wheel_snapshot = _snapshot_distribution( + wheel_path, + snapshot_root, + wheel_identity, + label=wheel_label, + ) + sdist_snapshot = _snapshot_distribution( + sdist_path, + snapshot_root, + sdist_identity, + label=sdist_label, + ) + generator = _load_attestable_generator() + wheel_sbom = _strict_pretty_json_bytes( + generator.build_attestable_sbom( + wheel_snapshot, + dependency_manifest, + runtime_lock, + ) + ) + sdist_sbom = _strict_pretty_json_bytes( + generator.build_attestable_sbom( + sdist_snapshot, + dependency_manifest, + runtime_lock, + ) + ) + + source_identity = _source_identity_bytes(repository, source_sha) + generated_payloads = { + f"{wheel_path.name}.cdx.json": wheel_sbom, + f"{sdist_path.name}.cdx.json": sdist_sbom, + release_evidence.SOURCE_IDENTITY_FILENAME: source_identity, + } + checksum_payloads: dict[str, bytes | Path] = { + wheel_path.name: wheel_path, + sdist_path.name: sdist_path, + **generated_payloads, + } + checksums = _checksum_bytes(checksum_payloads) + + for filename, payload in generated_payloads.items(): + _write_private_file( + evidence_root / filename, + payload, + label=f"release evidence {filename}", + ) + _write_private_file( + evidence_root / "SHA256SUMS", + checksums, + label="release evidence SHA256SUMS", + ) + + prepared_manifest = release_evidence.build_evidence_manifest( + evidence_root, + repository=repository, + source_sha=source_sha, + ) + manifest_payload = _strict_pretty_json_bytes(prepared_manifest) + release_evidence.write_evidence_manifest( + prepared_manifest, + resolved_handoff, + forbidden_root=evidence_root, + ) + release_evidence._require_post_publication_state( + evidence_root, + resolved_handoff, + repository=repository, + source_sha=source_sha, + expected_payload=manifest_payload, + ) + return release_evidence.build_evidence_manifest( + evidence_root, + repository=repository, + source_sha=source_sha, + ) + + +def main() -> int: + """Prepare one exact evidence set and return zero only after re-verification.""" + arguments = _parse_arguments() + prepare_release_evidence( + arguments.evidence_dir, + arguments.handoff_manifest, + repository=arguments.repository, + source_sha=arguments.source_sha, + dependency_manifest_path=arguments.dependency_manifest, + runtime_lock_path=arguments.runtime_lock, + ) + print(f"prepared sealed release evidence: {arguments.evidence_dir}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_prepare_release_evidence.py b/tests/test_prepare_release_evidence.py new file mode 100644 index 0000000..c5b61da --- /dev/null +++ b/tests/test_prepare_release_evidence.py @@ -0,0 +1,294 @@ +"""Tests for credential-free preparation of the sealed release evidence set.""" + +from __future__ import annotations + +import importlib.util +import json +import shutil +import stat +from pathlib import Path + +import pytest +from test_release_sbom import LOCK_PATH, MANIFEST_PATH, _write_sdist, _write_wheel + +from egressweave import release_evidence + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +PREPARER_PATH = REPOSITORY_ROOT / "scripts" / "ci" / "prepare_release_evidence.py" +REPOSITORY = "ContextualWisdomLab/EgressWeave" +SOURCE_SHA = "0123456789abcdef0123456789abcdef01234567" +WHEEL_NAME = "egressweave-0.3.0-py3-none-any.whl" +SDIST_NAME = "egressweave-0.3.0.tar.gz" + + +def _load_preparer(): + """Load the repository-only preparation script from its exact path.""" + specification = importlib.util.spec_from_file_location( + "egressweave_prepare_release_evidence", + PREPARER_PATH, + ) + assert specification is not None and specification.loader is not None + module = importlib.util.module_from_spec(specification) + specification.loader.exec_module(module) + return module + + +def _write_distributions(directory: Path) -> tuple[Path, Path]: + """Create one canonical wheel and source distribution fixture.""" + directory.mkdir() + wheel_path = directory / WHEEL_NAME + sdist_path = directory / SDIST_NAME + _write_wheel(wheel_path) + _write_sdist(sdist_path) + return wheel_path, sdist_path + + +def _prepare(preparer, evidence_dir: Path, handoff_path: Path): + """Run the public preparation function with reviewed repository inputs.""" + return preparer.prepare_release_evidence( + evidence_dir, + handoff_path, + repository=REPOSITORY, + source_sha=SOURCE_SHA, + dependency_manifest_path=MANIFEST_PATH, + runtime_lock_path=LOCK_PATH, + ) + + +def test_prepare_release_evidence_emits_one_verified_six_file_set( + tmp_path: Path, +) -> None: + """Generate the exact sealed payloads and one separately stored handoff.""" + preparer = _load_preparer() + evidence_dir = tmp_path / "evidence" + _write_distributions(evidence_dir) + handoff_path = tmp_path / "handoff.json" + + prepared_manifest = _prepare(preparer, evidence_dir, handoff_path) + + expected_names = { + WHEEL_NAME, + f"{WHEEL_NAME}.cdx.json", + SDIST_NAME, + f"{SDIST_NAME}.cdx.json", + "SOURCE_IDENTITY.json", + "SHA256SUMS", + } + assert {path.name for path in evidence_dir.iterdir()} == expected_names + checksum_lines = (evidence_dir / "SHA256SUMS").read_text(encoding="ascii").splitlines() + checksum_names = [line.split(" ", 1)[1] for line in checksum_lines] + assert checksum_names == sorted(checksum_names) + assert len(checksum_lines) == 5 + assert set(checksum_names) == expected_names - {"SHA256SUMS"} + assert (evidence_dir / "SOURCE_IDENTITY.json").read_bytes() == ( + b'{"format":"egressweave.release-source-identity","formatVersion":1,' + b'"repository":"ContextualWisdomLab/EgressWeave",' + b'"sourceSha":"0123456789abcdef0123456789abcdef01234567"}\n' + ) + + independently_verified = release_evidence.build_evidence_manifest( + evidence_dir, + repository=REPOSITORY, + source_sha=SOURCE_SHA, + ) + assert prepared_manifest == independently_verified + assert json.loads(handoff_path.read_text(encoding="utf-8")) == independently_verified + for generated_path in [ + evidence_dir / f"{WHEEL_NAME}.cdx.json", + evidence_dir / f"{SDIST_NAME}.cdx.json", + evidence_dir / "SOURCE_IDENTITY.json", + evidence_dir / "SHA256SUMS", + handoff_path, + ]: + assert stat.S_IMODE(generated_path.stat().st_mode) == 0o600 + + +def test_prepare_release_evidence_is_repeatable_for_identical_archives( + tmp_path: Path, +) -> None: + """Produce byte-identical evidence when every exact input byte is reused.""" + preparer = _load_preparer() + first_dir = tmp_path / "first" + first_wheel, first_sdist = _write_distributions(first_dir) + second_dir = tmp_path / "second" + second_dir.mkdir() + shutil.copyfile(first_wheel, second_dir / first_wheel.name) + shutil.copyfile(first_sdist, second_dir / first_sdist.name) + + _prepare(preparer, first_dir, tmp_path / "first-handoff.json") + _prepare(preparer, second_dir, tmp_path / "second-handoff.json") + + for filename in ( + f"{WHEEL_NAME}.cdx.json", + f"{SDIST_NAME}.cdx.json", + "SOURCE_IDENTITY.json", + "SHA256SUMS", + ): + assert (first_dir / filename).read_bytes() == (second_dir / filename).read_bytes() + assert (tmp_path / "first-handoff.json").read_bytes() == ( + tmp_path / "second-handoff.json" + ).read_bytes() + + +def test_prepare_release_evidence_rejects_unexpected_input_before_writing( + tmp_path: Path, +) -> None: + """Refuse stale or unrelated files before any evidence output is created.""" + preparer = _load_preparer() + evidence_dir = tmp_path / "evidence" + _write_distributions(evidence_dir) + (evidence_dir / "stale.txt").write_text("stale", encoding="utf-8") + handoff_path = tmp_path / "handoff.json" + + with pytest.raises(SystemExit, match="exactly one wheel and source distribution"): + _prepare(preparer, evidence_dir, handoff_path) + + assert {path.name for path in evidence_dir.iterdir()} == { + WHEEL_NAME, + SDIST_NAME, + "stale.txt", + } + assert not handoff_path.exists() + + +def test_prepare_release_evidence_rejects_oversized_archive_before_generator( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Reject compressed input bytes before loading any archive parser.""" + preparer = _load_preparer() + evidence_dir = tmp_path / "evidence" + wheel_path, _ = _write_distributions(evidence_dir) + wheel_path.write_bytes(b"") + with wheel_path.open("r+b") as stream: + stream.truncate(preparer.MAX_DISTRIBUTION_BYTES + 1) + handoff_path = tmp_path / "handoff.json" + + def fail_if_loaded(): + raise AssertionError("the generator ran before the distribution size preflight") + + monkeypatch.setattr(preparer, "_load_attestable_generator", fail_if_loaded) + + with pytest.raises(SystemExit, match="release distribution .* exceeds the safety bound"): + _prepare(preparer, evidence_dir, handoff_path) + + assert {path.name for path in evidence_dir.iterdir()} == {WHEEL_NAME, SDIST_NAME} + assert not handoff_path.exists() + + +def test_archive_replacement_after_preflight_never_reaches_parser( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Bind parser input to the exact regular archive accepted by preflight.""" + preparer = _load_preparer() + evidence_dir = tmp_path / "evidence" + wheel_path, _ = _write_distributions(evidence_dir) + replacement = tmp_path / "replacement.whl" + replacement.write_bytes(b"") + with replacement.open("r+b") as stream: + stream.truncate(preparer.MAX_DISTRIBUTION_BYTES + 1) + handoff_path = tmp_path / "handoff.json" + original_preflight = preparer._require_distribution_preflight + replaced = False + parser_inputs: list[Path] = [] + + def replace_after_preflight(path: Path, *, label: str): + nonlocal replaced + accepted_identity = original_preflight(path, label=label) + if path == wheel_path and not replaced: + wheel_path.unlink() + replacement.replace(wheel_path) + replaced = True + return accepted_identity + + class RecordingGenerator: + """Fail if a pathname replacement is ever delegated to an archive parser.""" + + def build_attestable_sbom(self, artifact_path: Path, *args): + parser_inputs.append(artifact_path) + raise AssertionError("the parser received a post-preflight replacement") + + monkeypatch.setattr( + preparer, + "_require_distribution_preflight", + replace_after_preflight, + ) + monkeypatch.setattr( + preparer, + "_load_attestable_generator", + lambda: RecordingGenerator(), + ) + + with pytest.raises( + SystemExit, + match="release distribution .* (?:exceeds the safety bound|is unreadable or unsafe)", + ): + _prepare(preparer, evidence_dir, handoff_path) + + assert replaced + assert parser_inputs == [] + assert {path.name for path in evidence_dir.iterdir()} == {WHEEL_NAME, SDIST_NAME} + assert not handoff_path.exists() + + +def test_prepare_release_evidence_rejects_symlinked_distribution_before_writing( + tmp_path: Path, +) -> None: + """Reject a linked archive instead of following it into the sealed set.""" + preparer = _load_preparer() + source_dir = tmp_path / "source" + wheel_path, sdist_path = _write_distributions(source_dir) + evidence_dir = tmp_path / "evidence" + evidence_dir.mkdir() + try: + (evidence_dir / wheel_path.name).symlink_to(wheel_path) + except OSError: + pytest.skip("symbolic links are unavailable on this platform") + shutil.copyfile(sdist_path, evidence_dir / sdist_path.name) + handoff_path = tmp_path / "handoff.json" + + with pytest.raises(SystemExit, match="regular direct-child files"): + _prepare(preparer, evidence_dir, handoff_path) + + assert {path.name for path in evidence_dir.iterdir()} == {WHEEL_NAME, SDIST_NAME} + assert not handoff_path.exists() + + +def test_prepare_release_evidence_rejects_handoff_inside_the_sealed_set( + tmp_path: Path, +) -> None: + """Keep the generated handoff from mutating the evidence it summarizes.""" + preparer = _load_preparer() + evidence_dir = tmp_path / "evidence" + _write_distributions(evidence_dir) + handoff_path = evidence_dir / "handoff.json" + + with pytest.raises(SystemExit, match="handoff manifest must remain outside"): + _prepare(preparer, evidence_dir, handoff_path) + + assert {path.name for path in evidence_dir.iterdir()} == {WHEEL_NAME, SDIST_NAME} + assert not handoff_path.exists() + + +def test_prepare_release_evidence_rejects_invalid_source_identity_before_writing( + tmp_path: Path, +) -> None: + """Validate exact repository and source authority before creating evidence.""" + preparer = _load_preparer() + evidence_dir = tmp_path / "evidence" + _write_distributions(evidence_dir) + handoff_path = tmp_path / "handoff.json" + + with pytest.raises(SystemExit, match="repository or source identity is invalid"): + preparer.prepare_release_evidence( + evidence_dir, + handoff_path, + repository="other/repository", + source_sha="main", + dependency_manifest_path=MANIFEST_PATH, + runtime_lock_path=LOCK_PATH, + ) + + assert {path.name for path in evidence_dir.iterdir()} == {WHEEL_NAME, SDIST_NAME} + assert not handoff_path.exists()