From 35c31495f041b6980a4c6b003424eb8816ae2148 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 12:42:11 +0900 Subject: [PATCH 01/10] test: expose noncanonical public forbidden root --- ..._sealed_release_evidence_forbidden_root.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 tests/test_sealed_release_evidence_forbidden_root.py diff --git a/tests/test_sealed_release_evidence_forbidden_root.py b/tests/test_sealed_release_evidence_forbidden_root.py new file mode 100644 index 0000000..c547d95 --- /dev/null +++ b/tests/test_sealed_release_evidence_forbidden_root.py @@ -0,0 +1,31 @@ +"""Regression tests for the public manifest writer's forbidden-root boundary.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from test_sealed_release_evidence_output_boundary import MANIFEST + +from egressweave import release_evidence + + +def test_public_writer_rejects_symlinked_forbidden_root(tmp_path: Path) -> None: + """Reject a symlink alias before writing inside its canonical target.""" + real_evidence_root = tmp_path / "real-evidence" + real_evidence_root.mkdir() + forbidden_root_alias = tmp_path / "evidence-alias" + try: + forbidden_root_alias.symlink_to(real_evidence_root, target_is_directory=True) + except OSError: + pytest.skip("directory symbolic links are unavailable on this platform") + output_path = real_evidence_root / "manifest.json" + + with pytest.raises(SystemExit, match="missing or unsafe"): + release_evidence.write_evidence_manifest( + MANIFEST, + output_path, + forbidden_root=forbidden_root_alias, + ) + + assert not output_path.exists() From 44fb75dfb21dda4b2e35266a83b507c9c52d9279 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 12:46:36 +0900 Subject: [PATCH 02/10] test: define complete forbidden-root precondition --- ..._sealed_release_evidence_forbidden_root.py | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/tests/test_sealed_release_evidence_forbidden_root.py b/tests/test_sealed_release_evidence_forbidden_root.py index c547d95..0178f0c 100644 --- a/tests/test_sealed_release_evidence_forbidden_root.py +++ b/tests/test_sealed_release_evidence_forbidden_root.py @@ -29,3 +29,64 @@ def test_public_writer_rejects_symlinked_forbidden_root(tmp_path: Path) -> None: ) assert not output_path.exists() + + +def test_public_writer_rejects_missing_forbidden_root_before_parent_creation( + tmp_path: Path, +) -> None: + """Reject a missing exclusion root before creating the output directory.""" + output_path = tmp_path / "new-parent" / "manifest.json" + + with pytest.raises(SystemExit, match="missing or unsafe"): + release_evidence.write_evidence_manifest( + MANIFEST, + output_path, + forbidden_root=tmp_path / "missing-evidence", + ) + + assert not output_path.parent.exists() + + +def test_public_writer_rejects_file_forbidden_root_before_parent_creation( + tmp_path: Path, +) -> None: + """Reject a non-directory exclusion root before creating output storage.""" + forbidden_root = tmp_path / "not-a-directory" + forbidden_root.write_text("not an evidence directory", encoding="utf-8") + output_path = tmp_path / "new-parent" / "manifest.json" + + with pytest.raises(SystemExit, match="missing or unsafe"): + release_evidence.write_evidence_manifest( + MANIFEST, + output_path, + forbidden_root=forbidden_root, + ) + + assert not output_path.parent.exists() + + +def test_public_writer_normalizes_forbidden_root_resolution_failure( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Normalize a canonicalization failure before creating output storage.""" + forbidden_root = tmp_path / "evidence" + forbidden_root.mkdir() + output_path = tmp_path / "new-parent" / "manifest.json" + original_resolve = Path.resolve + + def fail_forbidden_root(path: Path, *args, **kwargs): + if path == forbidden_root: + raise OSError("blocked") + return original_resolve(path, *args, **kwargs) + + monkeypatch.setattr(Path, "resolve", fail_forbidden_root) + + with pytest.raises(SystemExit, match="missing or unsafe"): + release_evidence.write_evidence_manifest( + MANIFEST, + output_path, + forbidden_root=forbidden_root, + ) + + assert not output_path.parent.exists() From 0f535f777642baf36b51dbbd378cc88806345c3c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 14:07:33 +0900 Subject: [PATCH 03/10] security: canonicalize public forbidden root --- src/egressweave/release_evidence.py | 46 ++++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 7 deletions(-) diff --git a/src/egressweave/release_evidence.py b/src/egressweave/release_evidence.py index 7f3365d..60a4a3f 100644 --- a/src/egressweave/release_evidence.py +++ b/src/egressweave/release_evidence.py @@ -626,20 +626,46 @@ def _require_output_outside_verified_set( raise SystemExit("evidence manifest output must remain outside the verified set") +def _require_canonical_forbidden_root(forbidden_root: Path) -> Path: + """Return one canonical excluded directory or raise one stable public error. + + Public callers receive a dedicated non-leaking failure while this helper + reuses the verifier's stricter existing-directory and no-symlink contract. + The returned path is the only authority used by the writer afterward. + """ + try: + return _require_canonical_evidence_root(forbidden_root) + except SystemExit: + raise SystemExit( + "evidence manifest forbidden root is missing or unsafe" + ) from None + + def write_evidence_manifest( manifest: dict[str, Any], output_path: Path, *, forbidden_root: Path | None = None, ) -> None: - """Create one private manifest and optionally exclude one verified directory.""" + """Create one private manifest while optionally excluding one real directory. + + When supplied, ``forbidden_root`` must name an existing real directory through + a lexical path with no symbolic-link component. The writer validates it before + creating the output parent, stores the canonical result once, and reuses that + same authority for every pre-write, descriptor-bound, and post-sync check. + """ payload = _encode_evidence_manifest(manifest) + canonical_forbidden_root = ( + _require_canonical_forbidden_root(forbidden_root) + if forbidden_root is not None + else None + ) try: output_path.parent.mkdir(parents=True, exist_ok=True) except OSError as error: raise SystemExit("evidence manifest parent directory is unavailable") from error - if forbidden_root is not None: - _require_output_outside_verified_set(output_path, forbidden_root) + if canonical_forbidden_root is not None: + _require_output_outside_verified_set(output_path, canonical_forbidden_root) try: with open(output_path, "xb", opener=_open_exclusive_manifest) as stream: @@ -648,8 +674,11 @@ def write_evidence_manifest( stream, label="evidence manifest output", ) - if forbidden_root is not None: - _require_output_outside_verified_set(output_path, forbidden_root) + if canonical_forbidden_root is not None: + _require_output_outside_verified_set( + output_path, + canonical_forbidden_root, + ) stream.write(payload) stream.flush() os.fsync(stream.fileno()) @@ -658,8 +687,11 @@ def write_evidence_manifest( stream, label="evidence manifest output", ) - if forbidden_root is not None: - _require_output_outside_verified_set(output_path, forbidden_root) + if canonical_forbidden_root is not None: + _require_output_outside_verified_set( + output_path, + canonical_forbidden_root, + ) except FileExistsError: raise SystemExit("evidence manifest output already exists") from None except OSError as error: From d0b6305c18b0cafccbe161184b7f39b7af554db8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 14:08:39 +0900 Subject: [PATCH 04/10] docs: define public forbidden-root boundary --- docs/sealed-release-evidence.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/sealed-release-evidence.md b/docs/sealed-release-evidence.md index e5ad7e9..57e4f79 100644 --- a/docs/sealed-release-evidence.md +++ b/docs/sealed-release-evidence.md @@ -128,6 +128,18 @@ any path whose lexical absolute form differs from its strict filesystem-resolved form. This makes the directory authority used for payload verification identical to the root excluded from manifest output. +Direct public API callers using +`write_evidence_manifest(..., forbidden_root=...)` have the same fail-closed +precondition. The optional root must be one existing real directory reached +through a lexical path with no symbolic-link component. It is validated before +the output parent is created or the output path is touched; a missing, non- +directory, symlinked, unresolvable, or otherwise noncanonical value raises the +stable error `evidence manifest forbidden root is missing or unsafe`. The writer +stores the resulting canonical path once and reuses that exact authority for the +pre-open, descriptor-bound, and post-`fsync` containment checks. The CLI already +supplies its previously canonicalized evidence root, so this public-API guard does +not broaden or weaken the command-line contract. + The evidence directory should already be sealed against concurrent writes by the build system or artifact service. Descriptor and repeated-digest checks are a fail-closed verification boundary, not a substitute for immutable storage or an From 13cd59b5d0307f1a4dc1e4ffe777ed31b878c657 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 14:09:49 +0900 Subject: [PATCH 05/10] docs: record forbidden-root hardening --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ae7996..a1e9dc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,11 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). without changing the centrally managed review-agent credential contract. ### Security +- 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 + stable non-leaking error, and every pre-write, descriptor-bound, and post-sync + containment check reuses the same resolved directory authority. - Revalidate the complete canonical evidence set and the closed owner-only manifest after publication but before reporting success. A second independent bounded evidence pass must reproduce the exact strict manifest bytes, while a From 6a8f57c29c9ebb561c771829ba75501ba9a91eb8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 21:33:39 +0900 Subject: [PATCH 06/10] test: make strict-JSON recursion coverage deterministic --- .../test_sealed_release_evidence_resource_limits.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/test_sealed_release_evidence_resource_limits.py b/tests/test_sealed_release_evidence_resource_limits.py index 541bd43..fed3f38 100644 --- a/tests/test_sealed_release_evidence_resource_limits.py +++ b/tests/test_sealed_release_evidence_resource_limits.py @@ -72,12 +72,19 @@ def fail_open(path: Path, *args, **kwargs): ) -def test_deeply_nested_json_is_masked_by_the_strict_evidence_boundary( +def test_json_parser_recursion_failure_is_masked_by_the_strict_evidence_boundary( tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: """Normalize parser recursion failure instead of leaking an exception.""" - sbom = tmp_path / "deep.cdx.json" - sbom.write_text("[" * 10_000 + "0" + "]" * 10_000, encoding="utf-8") + sbom = tmp_path / "recursive.cdx.json" + sbom.write_text("{}", encoding="utf-8") + + def fail_with_recursion_error(*args: object, **kwargs: object) -> object: + """Model a parser recursion failure independent of interpreter depth.""" + raise RecursionError("synthetic parser recursion failure") + + monkeypatch.setattr(release_evidence.json, "loads", fail_with_recursion_error) with pytest.raises(SystemExit, match="not strict JSON"): release_evidence._load_strict_json(sbom) From 2705fc419893ac5bd4e9b70d5b03ab6a88de61de Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 05:27:15 +0900 Subject: [PATCH 07/10] test: expose hidden forbidden-root symlink traversal --- ..._sealed_release_evidence_forbidden_root.py | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/tests/test_sealed_release_evidence_forbidden_root.py b/tests/test_sealed_release_evidence_forbidden_root.py index 0178f0c..4723a33 100644 --- a/tests/test_sealed_release_evidence_forbidden_root.py +++ b/tests/test_sealed_release_evidence_forbidden_root.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json from pathlib import Path import pytest @@ -31,6 +32,62 @@ def test_public_writer_rejects_symlinked_forbidden_root(tmp_path: Path) -> None: assert not output_path.exists() +def test_public_writer_rejects_intermediate_symlink_erased_by_parent_traversal( + tmp_path: Path, +) -> None: + """Reject a lexical symlink component even when a later ``..`` hides it.""" + real_evidence_root = tmp_path / "real-evidence" + child = real_evidence_root / "child" + child.mkdir(parents=True) + alias = real_evidence_root / "alias" + try: + alias.symlink_to(child, target_is_directory=True) + except OSError: + pytest.skip("directory symbolic links are unavailable on this platform") + forbidden_root = alias / ".." + output_path = tmp_path / "new-parent" / "manifest.json" + + with pytest.raises(SystemExit, match="missing or unsafe"): + release_evidence.write_evidence_manifest( + MANIFEST, + output_path, + forbidden_root=forbidden_root, + ) + + assert not output_path.parent.exists() + + +def test_public_writer_accepts_real_forbidden_root_outside_output( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Keep the documented safe-root success path and all three checks working.""" + forbidden_root = tmp_path / "real-evidence" + forbidden_root.mkdir() + output_path = tmp_path / "manifest-parent" / "manifest.json" + original_check = release_evidence._require_output_outside_verified_set + observed_roots: list[Path] = [] + + def record_containment_check(path: Path, verified_root: Path) -> None: + observed_roots.append(verified_root) + original_check(path, verified_root) + + monkeypatch.setattr( + release_evidence, + "_require_output_outside_verified_set", + record_containment_check, + ) + + release_evidence.write_evidence_manifest( + MANIFEST, + output_path, + forbidden_root=forbidden_root, + ) + + assert json.loads(output_path.read_text(encoding="utf-8")) == MANIFEST + assert observed_roots == [forbidden_root.resolve()] * 3 + + def test_public_writer_rejects_missing_forbidden_root_before_parent_creation( tmp_path: Path, ) -> None: From bec151fb697fff94215dbe91834769ad8526b69b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 05:34:53 +0900 Subject: [PATCH 08/10] security: reject hidden forbidden-root symlink components --- src/egressweave/release_evidence.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/egressweave/release_evidence.py b/src/egressweave/release_evidence.py index 60a4a3f..3c4e016 100644 --- a/src/egressweave/release_evidence.py +++ b/src/egressweave/release_evidence.py @@ -629,11 +629,19 @@ def _require_output_outside_verified_set( def _require_canonical_forbidden_root(forbidden_root: Path) -> Path: """Return one canonical excluded directory or raise one stable public error. - Public callers receive a dedicated non-leaking failure while this helper - reuses the verifier's stricter existing-directory and no-symlink contract. - The returned path is the only authority used by the writer afterward. + Every named lexical component is inspected before any ``..`` normalization, + so a symbolic link cannot be hidden by later parent traversal. Public callers + receive one dedicated non-leaking failure, then the existing canonical-root + contract supplies the single authority reused by the writer afterward. """ try: + current = Path(forbidden_root.anchor) + for component in forbidden_root.parts[len(current.parts) :]: + current /= component + if component != ".." and current.is_symlink(): + raise SystemExit( + "release evidence directory path must not traverse symlinks" + ) return _require_canonical_evidence_root(forbidden_root) except SystemExit: raise SystemExit( @@ -770,4 +778,4 @@ def main() -> int: if __name__ == "__main__": - raise SystemExit(main()) + raise SystemExit(main()) \ No newline at end of file From 57284e2299e94457bcb03bbc8f231f6002929936 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 05:35:51 +0900 Subject: [PATCH 09/10] test: preserve safe forbidden-root parent traversal --- ...t_sealed_release_evidence_forbidden_root.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/test_sealed_release_evidence_forbidden_root.py b/tests/test_sealed_release_evidence_forbidden_root.py index 4723a33..3144da6 100644 --- a/tests/test_sealed_release_evidence_forbidden_root.py +++ b/tests/test_sealed_release_evidence_forbidden_root.py @@ -88,6 +88,24 @@ def record_containment_check(path: Path, verified_root: Path) -> None: assert observed_roots == [forbidden_root.resolve()] * 3 +def test_public_writer_accepts_real_parent_traversal_without_symlinks( + tmp_path: Path, +) -> None: + """Permit lexical parent traversal when every named component is a real path.""" + forbidden_root = tmp_path / "real-evidence" + child = forbidden_root / "child" + child.mkdir(parents=True) + output_path = tmp_path / "manifest-parent" / "manifest.json" + + release_evidence.write_evidence_manifest( + MANIFEST, + output_path, + forbidden_root=child / "..", + ) + + assert json.loads(output_path.read_text(encoding="utf-8")) == MANIFEST + + def test_public_writer_rejects_missing_forbidden_root_before_parent_creation( tmp_path: Path, ) -> None: From 18dbf66dcf1e1b336daa76403a687d513bebefaa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 05:38:35 +0900 Subject: [PATCH 10/10] docs: explain lexical symlink preflight --- docs/sealed-release-evidence.md | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/docs/sealed-release-evidence.md b/docs/sealed-release-evidence.md index 57e4f79..bbba050 100644 --- a/docs/sealed-release-evidence.md +++ b/docs/sealed-release-evidence.md @@ -131,14 +131,18 @@ to the root excluded from manifest output. Direct public API callers using `write_evidence_manifest(..., forbidden_root=...)` have the same fail-closed precondition. The optional root must be one existing real directory reached -through a lexical path with no symbolic-link component. It is validated before -the output parent is created or the output path is touched; a missing, non- -directory, symlinked, unresolvable, or otherwise noncanonical value raises the -stable error `evidence manifest forbidden root is missing or unsafe`. The writer -stores the resulting canonical path once and reuses that exact authority for the -pre-open, descriptor-bound, and post-`fsync` containment checks. The CLI already -supplies its previously canonicalized evidence root, so this public-API guard does -not broaden or weaken the command-line contract. +through a lexical path with no symbolic-link component. Each named lexical +component is inspected before parent traversal is normalized, so an intermediate +symbolic link cannot be hidden by a later `..`; a `..` segment through only real +non-symlink components remains valid when the resulting canonical directory +satisfies the same root contract. Validation finishes before the output parent is +created or the output path is touched; a missing, non-directory, symlinked, +unresolvable, or otherwise noncanonical value raises the stable error +`evidence manifest forbidden root is missing or unsafe`. The writer stores the +resulting canonical path once and reuses that exact authority for the pre-open, +descriptor-bound, and post-`fsync` containment checks. The CLI already supplies +its previously canonicalized evidence root, so this public-API guard does not +broaden or weaken the command-line contract. The evidence directory should already be sealed against concurrent writes by the build system or artifact service. Descriptor and repeated-digest checks are a