From 945b143cce31e0499f16a324943b5e2b0390e2dd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 22:27:03 +0900 Subject: [PATCH 1/9] test(release): reproduce mutable dependency evidence inputs --- ...ease_evidence_dependency_input_snapshot.py | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 tests/test_prepare_release_evidence_dependency_input_snapshot.py diff --git a/tests/test_prepare_release_evidence_dependency_input_snapshot.py b/tests/test_prepare_release_evidence_dependency_input_snapshot.py new file mode 100644 index 0000000..743e557 --- /dev/null +++ b/tests/test_prepare_release_evidence_dependency_input_snapshot.py @@ -0,0 +1,72 @@ +"""Test immutable snapshots for reviewed dependency evidence inputs.""" + +from __future__ import annotations + +import shutil +from pathlib import Path + +import pytest +from test_prepare_release_evidence import ( + LOCK_PATH, + MANIFEST_PATH, + REPOSITORY, + SOURCE_SHA, + _load_preparer, + _write_distributions, +) + + +def test_reviewed_dependency_inputs_are_snapshotted_before_generator_load( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Keep one accepted manifest/lock byte snapshot across both SBOM builds.""" + preparer = _load_preparer() + evidence_dir = tmp_path / "evidence" + _write_distributions(evidence_dir) + dependency_manifest = tmp_path / "reviewed-runtime-dependencies.json" + runtime_lock = tmp_path / "reviewed-runtime-lock.txt" + shutil.copyfile(MANIFEST_PATH, dependency_manifest) + shutil.copyfile(LOCK_PATH, runtime_lock) + accepted_manifest = dependency_manifest.read_bytes() + accepted_lock = runtime_lock.read_bytes() + + class SnapshotObserved(RuntimeError): + """Stop after proving parser inputs are detached from mutable caller paths.""" + + class RecordingGenerator: + """Require generator inputs to remain the bytes accepted before mutation.""" + + def build_attestable_sbom( + self, + artifact_path: Path, + manifest_path: Path, + lock_path: Path, + ) -> dict[str, object]: + del artifact_path + assert manifest_path != dependency_manifest + assert lock_path != runtime_lock + assert manifest_path.read_bytes() == accepted_manifest + assert lock_path.read_bytes() == accepted_lock + raise SnapshotObserved + + def mutate_inputs_then_load_generator() -> RecordingGenerator: + dependency_manifest.write_bytes(b"{}\n") + runtime_lock.write_text("# replaced after acceptance\n", encoding="utf-8") + return RecordingGenerator() + + monkeypatch.setattr( + preparer, + "_load_attestable_generator", + mutate_inputs_then_load_generator, + ) + + with pytest.raises(SnapshotObserved): + preparer.prepare_release_evidence( + evidence_dir, + tmp_path / "handoff.json", + repository=REPOSITORY, + source_sha=SOURCE_SHA, + dependency_manifest_path=dependency_manifest, + runtime_lock_path=runtime_lock, + ) From c54b1bcf75ec91cdfa802c10f5749ccc93ecdb3d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 23:16:32 +0900 Subject: [PATCH 2/9] fix(release): snapshot reviewed dependency inputs --- scripts/ci/prepare_release_evidence.py | 88 +++++++++++++++++++++----- 1 file changed, 71 insertions(+), 17 deletions(-) diff --git a/scripts/ci/prepare_release_evidence.py b/scripts/ci/prepare_release_evidence.py index ea2f753..4957bdd 100644 --- a/scripts/ci/prepare_release_evidence.py +++ b/scripts/ci/prepare_release_evidence.py @@ -26,6 +26,7 @@ "generate_attestable_release_sbom.py" ) MAX_DISTRIBUTION_BYTES = release_evidence.MAX_ARTIFACT_BYTES +MAX_REVIEWED_INPUT_BYTES = 1_048_576 COPY_BLOCK_BYTES = 1_048_576 DistributionIdentity = tuple[int, int, int] @@ -135,11 +136,12 @@ def _require_distribution_metadata( metadata: os.stat_result, *, label: str, + max_bytes: int = MAX_DISTRIBUTION_BYTES, ) -> DistributionIdentity: - """Return one regular finite distribution identity or fail through stable errors.""" + """Return one regular finite input 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: + if metadata.st_size > max_bytes: raise SystemExit(f"{label} exceeds the safety bound") return _distribution_identity(metadata) @@ -157,17 +159,35 @@ def _require_distribution_preflight( return _require_distribution_metadata(path_state, label=label) +def _require_reviewed_input_preflight( + path: Path, + *, + label: str, +) -> DistributionIdentity: + """Bind one reviewed dependency input to the generator's one-MiB ceiling.""" + 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, + max_bytes=MAX_REVIEWED_INPUT_BYTES, + ) + + def _snapshot_distribution( path: Path, snapshot_root: Path, accepted_identity: DistributionIdentity, *, label: str, + max_bytes: int = MAX_DISTRIBUTION_BYTES, ) -> 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 + and the current pathname before and after the bounded copy. Downstream 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) @@ -186,8 +206,13 @@ def _snapshot_distribution( opened_identity = _require_distribution_metadata( os.fstat(source_descriptor), label=label, + max_bytes=max_bytes, + ) + current_identity = _require_distribution_metadata( + path.lstat(), + label=label, + max_bytes=max_bytes, ) - 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") @@ -199,7 +224,7 @@ def _snapshot_distribution( if not block: break copied_bytes += len(block) - if copied_bytes > MAX_DISTRIBUTION_BYTES: + if copied_bytes > max_bytes: raise SystemExit(f"{label} exceeds the safety bound") remaining = memoryview(block) while remaining: @@ -212,8 +237,13 @@ def _snapshot_distribution( final_opened_identity = _require_distribution_metadata( os.fstat(source_descriptor), label=label, + max_bytes=max_bytes, + ) + final_path_identity = _require_distribution_metadata( + path.lstat(), + label=label, + max_bytes=max_bytes, ) - final_path_identity = _require_distribution_metadata(path.lstat(), label=label) snapshot_state = os.fstat(snapshot_descriptor) if ( final_opened_identity != accepted_identity @@ -369,11 +399,11 @@ def prepare_release_evidence( """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 the exact manifest already rebuilt - and verified after the separately stored handoff has been durably published. + matching source distribution. Each accepted archive and reviewed dependency + input is copied from its no-follow identity-bound descriptor into one private + parser-only snapshot before the generator loads. Every generated file is new, + owner-only, and deterministic. The returned mapping is the exact manifest + already rebuilt and verified after the separately stored handoff is published. """ _require_source_identity(repository, source_sha) evidence_root = _require_canonical_directory( @@ -381,13 +411,23 @@ def prepare_release_evidence( label="release evidence input directory", ) resolved_handoff = _require_handoff_outside_evidence(handoff_path, evidence_root) + dependency_manifest_label = "reviewed runtime dependency manifest" + runtime_lock_label = "hash-locked runtime requirements" dependency_manifest = _require_canonical_file( dependency_manifest_path, - label="reviewed runtime dependency manifest", + label=dependency_manifest_label, ) runtime_lock = _require_canonical_file( runtime_lock_path, - label="hash-locked runtime requirements", + label=runtime_lock_label, + ) + dependency_manifest_identity = _require_reviewed_input_preflight( + dependency_manifest, + label=dependency_manifest_label, + ) + runtime_lock_identity = _require_reviewed_input_preflight( + runtime_lock, + label=runtime_lock_label, ) wheel_path, sdist_path = _select_distributions(evidence_root) wheel_label = f"release distribution {wheel_path.name}" @@ -409,19 +449,33 @@ def prepare_release_evidence( sdist_identity, label=sdist_label, ) + dependency_manifest_snapshot = _snapshot_distribution( + dependency_manifest, + snapshot_root, + dependency_manifest_identity, + label=dependency_manifest_label, + max_bytes=MAX_REVIEWED_INPUT_BYTES, + ) + runtime_lock_snapshot = _snapshot_distribution( + runtime_lock, + snapshot_root, + runtime_lock_identity, + label=runtime_lock_label, + max_bytes=MAX_REVIEWED_INPUT_BYTES, + ) generator = _load_attestable_generator() wheel_sbom = _strict_pretty_json_bytes( generator.build_attestable_sbom( wheel_snapshot, - dependency_manifest, - runtime_lock, + dependency_manifest_snapshot, + runtime_lock_snapshot, ) ) sdist_sbom = _strict_pretty_json_bytes( generator.build_attestable_sbom( sdist_snapshot, - dependency_manifest, - runtime_lock, + dependency_manifest_snapshot, + runtime_lock_snapshot, ) ) From 7531a3f704b7495b92be3a14450adb3b6074b96a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 23:44:32 +0900 Subject: [PATCH 3/9] test(release): cover reviewed input snapshot boundaries --- ...ease_evidence_dependency_input_snapshot.py | 192 ++++++++++++++++-- 1 file changed, 179 insertions(+), 13 deletions(-) diff --git a/tests/test_prepare_release_evidence_dependency_input_snapshot.py b/tests/test_prepare_release_evidence_dependency_input_snapshot.py index 743e557..682e44f 100644 --- a/tests/test_prepare_release_evidence_dependency_input_snapshot.py +++ b/tests/test_prepare_release_evidence_dependency_input_snapshot.py @@ -16,26 +16,46 @@ ) +def _prepare_with_reviewed_inputs( + preparer, + tmp_path: Path, + dependency_manifest: Path, + runtime_lock: Path, +) -> dict[str, object]: + """Run release preparation with caller-selected reviewed dependency inputs.""" + evidence_dir = tmp_path / "evidence" + _write_distributions(evidence_dir) + return preparer.prepare_release_evidence( + evidence_dir, + tmp_path / "handoff.json", + repository=REPOSITORY, + source_sha=SOURCE_SHA, + dependency_manifest_path=dependency_manifest, + runtime_lock_path=runtime_lock, + ) + + def test_reviewed_dependency_inputs_are_snapshotted_before_generator_load( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: """Keep one accepted manifest/lock byte snapshot across both SBOM builds.""" preparer = _load_preparer() - evidence_dir = tmp_path / "evidence" - _write_distributions(evidence_dir) dependency_manifest = tmp_path / "reviewed-runtime-dependencies.json" runtime_lock = tmp_path / "reviewed-runtime-lock.txt" shutil.copyfile(MANIFEST_PATH, dependency_manifest) shutil.copyfile(LOCK_PATH, runtime_lock) accepted_manifest = dependency_manifest.read_bytes() accepted_lock = runtime_lock.read_bytes() + real_generator = preparer._load_attestable_generator() + observed_snapshots: tuple[Path, Path] | None = None + call_count = 0 class SnapshotObserved(RuntimeError): - """Stop after proving parser inputs are detached from mutable caller paths.""" + """Stop after proving both parsers consumed the same detached snapshots.""" class RecordingGenerator: - """Require generator inputs to remain the bytes accepted before mutation.""" + """Require both generator calls to reuse the accepted immutable inputs.""" def build_attestable_sbom( self, @@ -43,12 +63,23 @@ def build_attestable_sbom( manifest_path: Path, lock_path: Path, ) -> dict[str, object]: - del artifact_path + nonlocal call_count, observed_snapshots + call_count += 1 assert manifest_path != dependency_manifest assert lock_path != runtime_lock assert manifest_path.read_bytes() == accepted_manifest assert lock_path.read_bytes() == accepted_lock - raise SnapshotObserved + current_snapshots = (manifest_path, lock_path) + if observed_snapshots is None: + observed_snapshots = current_snapshots + else: + assert current_snapshots == observed_snapshots + raise SnapshotObserved + return real_generator.build_attestable_sbom( + artifact_path, + manifest_path, + lock_path, + ) def mutate_inputs_then_load_generator() -> RecordingGenerator: dependency_manifest.write_bytes(b"{}\n") @@ -62,11 +93,146 @@ def mutate_inputs_then_load_generator() -> RecordingGenerator: ) with pytest.raises(SnapshotObserved): - preparer.prepare_release_evidence( - evidence_dir, - tmp_path / "handoff.json", - repository=REPOSITORY, - source_sha=SOURCE_SHA, - dependency_manifest_path=dependency_manifest, - runtime_lock_path=runtime_lock, + _prepare_with_reviewed_inputs( + preparer, + tmp_path, + dependency_manifest, + runtime_lock, ) + + assert call_count == 2 + + +def test_reviewed_inputs_with_same_basename_use_distinct_snapshots( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Reach generator loading when manifest and lock share one basename.""" + preparer = _load_preparer() + manifest_dir = tmp_path / "manifest" + lock_dir = tmp_path / "lock" + manifest_dir.mkdir() + lock_dir.mkdir() + dependency_manifest = manifest_dir / "requirements.txt" + runtime_lock = lock_dir / "requirements.txt" + shutil.copyfile(MANIFEST_PATH, dependency_manifest) + shutil.copyfile(LOCK_PATH, runtime_lock) + + class GeneratorReached(RuntimeError): + """Prove both reviewed snapshots were created without basename collision.""" + + def stop_at_generator() -> None: + raise GeneratorReached + + monkeypatch.setattr(preparer, "_load_attestable_generator", stop_at_generator) + + with pytest.raises(GeneratorReached): + _prepare_with_reviewed_inputs( + preparer, + tmp_path, + dependency_manifest, + runtime_lock, + ) + + +def test_reviewed_input_exact_size_limit_reaches_generator( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Accept exactly one MiB of reviewed input before parser delegation.""" + preparer = _load_preparer() + dependency_manifest = tmp_path / "reviewed-runtime-dependencies.json" + runtime_lock = tmp_path / "reviewed-runtime-lock.txt" + dependency_manifest.write_bytes(b"x" * preparer.MAX_REVIEWED_INPUT_BYTES) + shutil.copyfile(LOCK_PATH, runtime_lock) + + class GeneratorReached(RuntimeError): + """Prove the exact finite boundary reaches generator loading.""" + + def stop_at_generator() -> None: + raise GeneratorReached + + monkeypatch.setattr(preparer, "_load_attestable_generator", stop_at_generator) + + with pytest.raises(GeneratorReached): + _prepare_with_reviewed_inputs( + preparer, + tmp_path, + dependency_manifest, + runtime_lock, + ) + + +def test_reviewed_input_over_size_limit_is_generically_rejected_before_generator( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Reject oversized reviewed bytes without exposing the rejection rule.""" + preparer = _load_preparer() + dependency_manifest = tmp_path / "reviewed-runtime-dependencies.json" + runtime_lock = tmp_path / "reviewed-runtime-lock.txt" + dependency_manifest.write_bytes(b"x" * (preparer.MAX_REVIEWED_INPUT_BYTES + 1)) + shutil.copyfile(LOCK_PATH, runtime_lock) + generator_loaded = False + + def fail_if_loaded() -> None: + nonlocal generator_loaded + generator_loaded = True + raise AssertionError("generator loaded after reviewed-input rejection") + + monkeypatch.setattr(preparer, "_load_attestable_generator", fail_if_loaded) + + with pytest.raises(SystemExit) as rejected: + _prepare_with_reviewed_inputs( + preparer, + tmp_path, + dependency_manifest, + runtime_lock, + ) + + assert str(rejected.value) == "reviewed input is unreadable or unsafe" + assert not generator_loaded + + +def test_reviewed_input_growth_after_preflight_is_generically_rejected( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Hide the rule when accepted input grows beyond the bound before snapshotting.""" + preparer = _load_preparer() + dependency_manifest = tmp_path / "reviewed-runtime-dependencies.json" + runtime_lock = tmp_path / "reviewed-runtime-lock.txt" + shutil.copyfile(MANIFEST_PATH, dependency_manifest) + shutil.copyfile(LOCK_PATH, runtime_lock) + original_preflight = preparer._require_reviewed_input_preflight + mutated = False + + def grow_after_preflight(path: Path, *, label: str): + nonlocal mutated + accepted_identity = original_preflight(path, label=label) + if path == dependency_manifest and not mutated: + with path.open("ab") as stream: + stream.truncate(preparer.MAX_REVIEWED_INPUT_BYTES + 1) + mutated = True + return accepted_identity + + def fail_if_loaded() -> None: + raise AssertionError("generator loaded after reviewed-input identity changed") + + monkeypatch.setattr( + preparer, + "_require_reviewed_input_preflight", + grow_after_preflight, + ) + monkeypatch.setattr(preparer, "_load_attestable_generator", fail_if_loaded) + + with pytest.raises(SystemExit) as rejected: + _prepare_with_reviewed_inputs( + preparer, + tmp_path, + dependency_manifest, + runtime_lock, + ) + + assert mutated + assert str(rejected.value) == "reviewed input is unreadable or unsafe" From b31f0e4581cba6d5c00ca11344d2a05d65cc015c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 23:49:47 +0900 Subject: [PATCH 4/9] fix(release): normalize reviewed input snapshots --- scripts/ci/prepare_release_evidence.py | 65 +++++++++++++++++--------- 1 file changed, 43 insertions(+), 22 deletions(-) diff --git a/scripts/ci/prepare_release_evidence.py b/scripts/ci/prepare_release_evidence.py index 4957bdd..826c4c3 100644 --- a/scripts/ci/prepare_release_evidence.py +++ b/scripts/ci/prepare_release_evidence.py @@ -28,6 +28,7 @@ MAX_DISTRIBUTION_BYTES = release_evidence.MAX_ARTIFACT_BYTES MAX_REVIEWED_INPUT_BYTES = 1_048_576 COPY_BLOCK_BYTES = 1_048_576 +REVIEWED_INPUT_REJECTION = "reviewed input is unreadable or unsafe" DistributionIdentity = tuple[int, int, int] __all__ = ["main", "prepare_release_evidence"] @@ -164,16 +165,17 @@ def _require_reviewed_input_preflight( *, label: str, ) -> DistributionIdentity: - """Bind one reviewed dependency input to the generator's one-MiB ceiling.""" + """Bind one reviewed input while hiding which safety rule rejected it.""" + del label 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, - max_bytes=MAX_REVIEWED_INPUT_BYTES, - ) + return _require_distribution_metadata( + path_state, + label="reviewed input", + max_bytes=MAX_REVIEWED_INPUT_BYTES, + ) + except (OSError, SystemExit): + raise SystemExit(REVIEWED_INPUT_REJECTION) from None def _snapshot_distribution( @@ -183,6 +185,7 @@ def _snapshot_distribution( *, label: str, max_bytes: int = MAX_DISTRIBUTION_BYTES, + snapshot_name: str | None = None, ) -> Path: """Copy one accepted descriptor into a private parser-only immutable snapshot. @@ -200,7 +203,7 @@ def _snapshot_distribution( ) source_descriptor: int | None = None snapshot_descriptor: int | None = None - snapshot_path = snapshot_root / path.name + snapshot_path = snapshot_root / (snapshot_name or path.name) try: source_descriptor = os.open(path, read_flags) opened_identity = _require_distribution_metadata( @@ -265,6 +268,27 @@ def _snapshot_distribution( os.close(source_descriptor) +def _snapshot_reviewed_input( + path: Path, + snapshot_root: Path, + accepted_identity: DistributionIdentity, + *, + snapshot_name: str, +) -> Path: + """Copy one reviewed input while normalizing every rejection to one message.""" + try: + return _snapshot_distribution( + path, + snapshot_root, + accepted_identity, + label="reviewed input", + max_bytes=MAX_REVIEWED_INPUT_BYTES, + snapshot_name=snapshot_name, + ) + except SystemExit: + raise SystemExit(REVIEWED_INPUT_REJECTION) from None + + def _load_attestable_generator() -> ModuleType: """Load the repository-only deterministic generator without importing archives.""" specification = importlib.util.spec_from_file_location( @@ -411,23 +435,22 @@ def prepare_release_evidence( label="release evidence input directory", ) resolved_handoff = _require_handoff_outside_evidence(handoff_path, evidence_root) - dependency_manifest_label = "reviewed runtime dependency manifest" - runtime_lock_label = "hash-locked runtime requirements" + reviewed_input_label = "reviewed input" dependency_manifest = _require_canonical_file( dependency_manifest_path, - label=dependency_manifest_label, + label=reviewed_input_label, ) runtime_lock = _require_canonical_file( runtime_lock_path, - label=runtime_lock_label, + label=reviewed_input_label, ) dependency_manifest_identity = _require_reviewed_input_preflight( dependency_manifest, - label=dependency_manifest_label, + label=reviewed_input_label, ) runtime_lock_identity = _require_reviewed_input_preflight( runtime_lock, - label=runtime_lock_label, + label=reviewed_input_label, ) wheel_path, sdist_path = _select_distributions(evidence_root) wheel_label = f"release distribution {wheel_path.name}" @@ -449,19 +472,17 @@ def prepare_release_evidence( sdist_identity, label=sdist_label, ) - dependency_manifest_snapshot = _snapshot_distribution( + dependency_manifest_snapshot = _snapshot_reviewed_input( dependency_manifest, snapshot_root, dependency_manifest_identity, - label=dependency_manifest_label, - max_bytes=MAX_REVIEWED_INPUT_BYTES, + snapshot_name="reviewed-dependency-manifest", ) - runtime_lock_snapshot = _snapshot_distribution( + runtime_lock_snapshot = _snapshot_reviewed_input( runtime_lock, snapshot_root, runtime_lock_identity, - label=runtime_lock_label, - max_bytes=MAX_REVIEWED_INPUT_BYTES, + snapshot_name="reviewed-runtime-lock", ) generator = _load_attestable_generator() wheel_sbom = _strict_pretty_json_bytes( @@ -540,4 +561,4 @@ def main() -> int: if __name__ == "__main__": - raise SystemExit(main()) + raise SystemExit(main()) \ No newline at end of file From 25b440ac6bd6c10430047e6dffd5c13fc3452eb7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 02:19:33 +0900 Subject: [PATCH 5/9] docs(release): document reviewed-input snapshots --- docs/release-evidence-preparation.md | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/docs/release-evidence-preparation.md b/docs/release-evidence-preparation.md index 81da606..a4bc413 100644 --- a/docs/release-evidence-preparation.md +++ b/docs/release-evidence-preparation.md @@ -33,6 +33,19 @@ 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 reviewed dependency input is independently limited to 1 MiB and bound to +its accepted device, inode, and size identity before the deterministic generator +is loaded. The preparer opens each pathname with no-follow semantics, copies the +bounded descriptor bytes into a fresh owner-only private snapshot under a +distinct fixed internal name, and rechecks descriptor and pathname identity +before and after the copy. Both wheel and source-distribution SBOM builds receive +the same detached dependency-manifest and runtime-lock snapshots; the generator +never reopens the caller-controlled reviewed-input paths. A pathname replacement +after acceptance therefore cannot change the dependency bytes delegated to the +generator or make the two SBOM passes observe different reviewed inputs. Every +reviewed-input rejection remains generic, and the private snapshots are removed +before evidence publication. + 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 @@ -80,12 +93,13 @@ 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: +private identity-bound distribution snapshots and the detached reviewed-input +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. All private +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 From e7ac1b4c270d793aec388815eda5e64380723702 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 02:23:30 +0900 Subject: [PATCH 6/9] docs(architecture): bind release input trust boundary --- ARCHITECTURE.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e7ea4b5..381bff3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -168,6 +168,29 @@ detect configuration drift; they are not cryptographic proof against arbitrary in-process code execution. The evidence artifact does not authorize a request or replace application path, credential, tenant, or destination authorization. +### Release-evidence preparation layer + +Release-evidence preparation is repository tooling, not part of the runtime +network path. It runs without repository-write, signing, OIDC-attestation, +publication, tag, or release credentials and accepts only already-built wheel +and source-distribution archives plus two reviewed dependency inputs. + +The reviewed dependency manifest and hash-locked runtime requirements are each +bounded to 1 MiB and bound to an accepted `(device, inode, size)` identity. The +preparer opens each path with no-follow semantics and copies the descriptor bytes +into separate owner-only private snapshots with fixed internal names before the +SBOM generator is loaded. Both wheel and source-distribution SBOM passes receive +the same detached snapshots; the generator never reopens caller-controlled +reviewed-input paths. Distribution archives are independently copied through the +same identity-bound pattern into private parser snapshots. All private snapshots +are removed before generated evidence is published. + +This layer establishes a deterministic, internally consistent handoff for one +exact repository/source identity. It does not prove that the distributions were +honestly built from that source and does not itself create provenance; those +claims remain the responsibility of independently reviewed, credential-separated +hosted build and attestation controls. + ## Trust boundaries | Boundary | Trusted input | Untrusted input | Required behavior | @@ -180,6 +203,7 @@ or replace application path, credential, tenant, or destination authorization. | TLS | fresh context and validated hostname | peer certificate and caller SNI override | bind identity; deny mismatch | | Response delivery | finite response policy | peer fields, framing, coding, and body | bound and validate before exposure | | Audit export | revalidated decision | paths, credentials, IPs, response data | omit sensitive request and peer data | +| Release evidence | exact accepted file identities | mutable reviewed-input and archive paths | consume bounded no-follow private snapshots; fail closed on drift | Arbitrary code execution inside the embedding Python process is outside the security model. Network firewalls, service-mesh policies, sandboxing, tenant From 04596a4f7dd6a5fac8e789d628ae37e6dae00acf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 02:32:45 +0900 Subject: [PATCH 7/9] test(release): reproduce reviewed input rejection leak --- ...ease_evidence_dependency_input_snapshot.py | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/tests/test_prepare_release_evidence_dependency_input_snapshot.py b/tests/test_prepare_release_evidence_dependency_input_snapshot.py index 682e44f..d85a854 100644 --- a/tests/test_prepare_release_evidence_dependency_input_snapshot.py +++ b/tests/test_prepare_release_evidence_dependency_input_snapshot.py @@ -236,3 +236,66 @@ def fail_if_loaded() -> None: assert mutated assert str(rejected.value) == "reviewed input is unreadable or unsafe" + + +def test_missing_reviewed_input_is_generically_rejected_before_generator( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Hide whether a reviewed dependency input is missing before parser loading.""" + preparer = _load_preparer() + dependency_manifest = tmp_path / "missing-reviewed-runtime-dependencies.json" + runtime_lock = tmp_path / "reviewed-runtime-lock.txt" + shutil.copyfile(LOCK_PATH, runtime_lock) + generator_loaded = False + + def fail_if_loaded() -> None: + nonlocal generator_loaded + generator_loaded = True + raise AssertionError("generator loaded after missing reviewed input") + + monkeypatch.setattr(preparer, "_load_attestable_generator", fail_if_loaded) + + with pytest.raises(SystemExit) as rejected: + _prepare_with_reviewed_inputs( + preparer, + tmp_path, + dependency_manifest, + runtime_lock, + ) + + assert str(rejected.value) == "reviewed input is unreadable or unsafe" + assert not generator_loaded + + +def test_symlinked_reviewed_input_is_generically_rejected_before_generator( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Hide whether a reviewed dependency input was rejected for being a symlink.""" + preparer = _load_preparer() + dependency_manifest = tmp_path / "reviewed-runtime-dependencies.json" + lock_target = tmp_path / "runtime-lock-target.txt" + runtime_lock = tmp_path / "reviewed-runtime-lock.txt" + shutil.copyfile(MANIFEST_PATH, dependency_manifest) + shutil.copyfile(LOCK_PATH, lock_target) + runtime_lock.symlink_to(lock_target) + generator_loaded = False + + def fail_if_loaded() -> None: + nonlocal generator_loaded + generator_loaded = True + raise AssertionError("generator loaded after symlinked reviewed input") + + monkeypatch.setattr(preparer, "_load_attestable_generator", fail_if_loaded) + + with pytest.raises(SystemExit) as rejected: + _prepare_with_reviewed_inputs( + preparer, + tmp_path, + dependency_manifest, + runtime_lock, + ) + + assert str(rejected.value) == "reviewed input is unreadable or unsafe" + assert not generator_loaded From 1b8ffc93d3e374ac03db564c291384bf988699aa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 02:39:44 +0900 Subject: [PATCH 8/9] fix(release): normalize reviewed input canonical rejection --- scripts/ci/prepare_release_evidence.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/scripts/ci/prepare_release_evidence.py b/scripts/ci/prepare_release_evidence.py index 826c4c3..0a5a953 100644 --- a/scripts/ci/prepare_release_evidence.py +++ b/scripts/ci/prepare_release_evidence.py @@ -436,14 +436,17 @@ def prepare_release_evidence( ) resolved_handoff = _require_handoff_outside_evidence(handoff_path, evidence_root) reviewed_input_label = "reviewed input" - dependency_manifest = _require_canonical_file( - dependency_manifest_path, - label=reviewed_input_label, - ) - runtime_lock = _require_canonical_file( - runtime_lock_path, - label=reviewed_input_label, - ) + try: + dependency_manifest = _require_canonical_file( + dependency_manifest_path, + label=reviewed_input_label, + ) + runtime_lock = _require_canonical_file( + runtime_lock_path, + label=reviewed_input_label, + ) + except SystemExit: + raise SystemExit(REVIEWED_INPUT_REJECTION) from None dependency_manifest_identity = _require_reviewed_input_preflight( dependency_manifest, label=reviewed_input_label, From adfcb652c37f2152cb7744b9d090c88b7ceae64c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 02:44:41 +0900 Subject: [PATCH 9/9] test(release): align symlink rejection with generic boundary --- tests/test_prepare_release_evidence_review_regressions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_prepare_release_evidence_review_regressions.py b/tests/test_prepare_release_evidence_review_regressions.py index 5d01738..2f4fd91 100644 --- a/tests/test_prepare_release_evidence_review_regressions.py +++ b/tests/test_prepare_release_evidence_review_regressions.py @@ -114,7 +114,7 @@ def test_reviewed_dependency_inputs_reject_symlinks( tmp_path: Path, input_kind: str, ) -> None: - """Reject symlinked reviewed dependency inputs before generated output.""" + """Reject symlinked reviewed dependency inputs through the generic boundary.""" preparer = _load_preparer() evidence_dir = tmp_path / "evidence" _write_distributions(evidence_dir) @@ -127,7 +127,7 @@ def test_reviewed_dependency_inputs_reject_symlinks( dependency_manifest = linked_input if input_kind == "dependency manifest" else MANIFEST_PATH runtime_lock = linked_input if input_kind == "runtime lock" else LOCK_PATH - with pytest.raises(SystemExit, match="missing or unsafe"): + with pytest.raises(SystemExit, match="reviewed input is unreadable or unsafe"): _prepare_with_inputs( preparer, evidence_dir,