diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dfaecb..3199052 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,9 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ambiguous or non-positive configuration before network I/O. ### Fixed +- Accept distinct exact SHA-256 artifact hashes for one pinned CI package while + requiring every reviewed runtime SBOM digest to be present in that immutable + set, preserving portable hash-locked tooling without weakening release evidence. - Restore the hourly PR-maintenance calls to the reviewed immutable `ContextualWisdomLab/.github` revision `59505c1d89eb7ea816e921b6da38079c736608c2`. The regression contract now diff --git a/requirements-ci.txt b/requirements-ci.txt index 267acda..fffc84b 100644 --- a/requirements-ci.txt +++ b/requirements-ci.txt @@ -34,8 +34,25 @@ pytest==9.1.1 \ --hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c pytest-asyncio==1.4.0 \ --hash=sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1 -ruff==0.16.1 \ - --hash=sha256:39897739f112253ee4fdd2e8aa9a4f9ded99fb2be367d5f31dfa4ded6025584c +ruff==0.16.3 \ + --hash=sha256:09571e6d1288ed9be475207a3ac04ada404f1cd898104be0f6ab8d7df438575b \ + --hash=sha256:0c5710e247a58a4521e66e124ba9a74655b414f61ba3a2e9e3811e11098f48f7 \ + --hash=sha256:294b95c4ae0cda9388525c2047778aa758d6b8d4bb876fd4e9eaa3ebc92343eb \ + --hash=sha256:2c18c5a101eb540010638cc1ff3c84944d3adb3df62b8d98ca8f22ba484d3413 \ + --hash=sha256:388cdf2166642bd9b13d52b5932d3170f34f8abed7e8d9a855f1d84b83645a0a \ + --hash=sha256:3d0c7c40c87c2a820509c31ba007968da6e1306468c067b2d82fbfdbcd0e8474 \ + --hash=sha256:8457c44f15033c85ddbb77b15d451df9e24e4bd03b628396dd3610cedc3b8f82 \ + --hash=sha256:9e0b1da805eb043654645d74d5de1e5ce2edc686e40790d2b86f56d71cc06a84 \ + --hash=sha256:9f738c0fdfa8eed0b2ce7fb27ee7258208a92a68d7949e62aa15164bc7b389da \ + --hash=sha256:a2d85c02f9b8e165d85e6779184d38c4132de12603dab59c51c28e22584f9e4d \ + --hash=sha256:a37bdea0bbe21780f590bf437d6412c8c4e1b6cd010f91a65c2c40c5e5f5f870 \ + --hash=sha256:b8ca152da82c1acc1fa8d5874b15951935f0eef46f10e6954c83859011b6178a \ + --hash=sha256:c5536e3acfbf9563085aa2be7b13c629c3077e902afc5b941ac44024dbb9f506 \ + --hash=sha256:e2ed719e14aa64d895c2ee922594a90a43c861a93f0575a95ff8c47cdbd13eb9 \ + --hash=sha256:e76d33a347661a84b5be6d043d0347fdc745dfdcf825a8f4fed64b5e26eebdf2 \ + --hash=sha256:e80a7d69ca2a6d1c4d352ec91458cdca6e56c83cdbcabd93e4abe1e53591d948 \ + --hash=sha256:fb785f0be25abe69d320415cd4f833b59e17ba7613d9ba6a958023b6bceb0a50 \ + --hash=sha256:fe155130631a2471fd2e14a7a664a4dfbd7194b8229c3d7b2a40b21178639081 tomli==2.4.1 ; python_version < "3.11" \ --hash=sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe typing-extensions==4.16.0 ; python_version < "3.13" \ diff --git a/scripts/ci/generate_release_sbom.py b/scripts/ci/generate_release_sbom.py index 23654c0..acc89ce 100644 --- a/scripts/ci/generate_release_sbom.py +++ b/scripts/ci/generate_release_sbom.py @@ -783,25 +783,32 @@ def _canonical_marker(value: str | None, context: str) -> str | None: raise SystemExit(f"{context} contains an invalid environment marker") from error -def _load_runtime_lock(path: Path) -> dict[str, dict[str, str | None]]: - """Load exact package versions, markers, and hashes from the CI lock.""" +def _load_runtime_lock( + path: Path, +) -> dict[str, dict[str, str | tuple[str, ...] | None]]: + """Load exact package versions, markers, and allowed hashes from the CI lock.""" try: if path.stat().st_size > MAX_MANIFEST_BYTES: raise SystemExit("runtime lock exceeds the safety bound") content = path.read_text(encoding="utf-8") except (OSError, UnicodeError) as error: raise SystemExit("runtime lock is unreadable") from error - entries: dict[str, dict[str, str | None]] = {} + entries: dict[str, dict[str, str | tuple[str, ...] | None]] = {} for raw_line in content.replace("\\\n", " ").splitlines(): line = raw_line.strip() if not line or line.startswith("#"): continue - if line.count("--hash=sha256:") != 1: - raise SystemExit("runtime lock entries require exactly one SHA-256 hash") - requirement_text, digest = line.split("--hash=sha256:", 1) - digest = digest.strip() - if SHA256.fullmatch(digest) is None: + segments = line.split("--hash=sha256:") + if len(segments) < 2: + raise SystemExit( + "runtime lock entries require at least one SHA-256 hash" + ) + requirement_text = segments[0] + digests = tuple(segment.strip() for segment in segments[1:]) + if any(SHA256.fullmatch(digest) is None for digest in digests): raise SystemExit("runtime lock contains a noncanonical SHA-256 hash") + if len(set(digests)) != len(digests): + raise SystemExit("runtime lock contains a duplicate SHA-256 hash") try: requirement = Requirement(requirement_text.strip()) except InvalidRequirement as error: @@ -821,24 +828,27 @@ def _load_runtime_lock(path: Path) -> dict[str, dict[str, str | None]]: ) entries[name] = { "version": version, - "sha256": digest, + "sha256": digests, "marker": str(requirement.marker) if requirement.marker is not None else None, } return entries def validate_runtime_lock(manifest_path: Path, lock_path: Path) -> None: - """Require every SBOM dependency to equal its executable lock evidence.""" + """Require every SBOM dependency to match one reviewed lock artifact.""" _, components = _load_manifest(manifest_path) lock_entries = _load_runtime_lock(lock_path) for name, component in components.items(): locked = lock_entries.get(name) - expected = { - "version": component["version"], - "sha256": component["sha256"], - "marker": _canonical_marker(component["marker"], f"component {name}"), - } - if locked != expected: + expected_marker = _canonical_marker( + component["marker"], f"component {name}" + ) + if ( + locked is None + or locked["version"] != component["version"] + or locked["marker"] != expected_marker + or component["sha256"] not in locked["sha256"] + ): raise SystemExit( f"component {name!r} does not match the hash-locked runtime subset" ) diff --git a/tests/test_release_lock_hash_sets.py b/tests/test_release_lock_hash_sets.py new file mode 100644 index 0000000..6c4dce9 --- /dev/null +++ b/tests/test_release_lock_hash_sets.py @@ -0,0 +1,112 @@ +"""Regression contracts for portable multi-artifact hash locks.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path + +import pytest + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +GENERATOR_PATH = REPOSITORY_ROOT / "scripts" / "ci" / "generate_release_sbom.py" +MANIFEST_PATH = REPOSITORY_ROOT / "scripts" / "ci" / "release_runtime_dependencies.json" +LOCK_PATH = REPOSITORY_ROOT / "requirements-ci.txt" + + +def _load_generator(): + """Load the repository SBOM generator without packaging it.""" + specification = importlib.util.spec_from_file_location( + "egressweave_release_lock_hash_sets", + GENERATOR_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 test_runtime_lock_accepts_distinct_platform_hashes(tmp_path: Path) -> None: + """Preserve every exact artifact hash attached to one pinned version.""" + generator = _load_generator() + first_digest = "a" * 64 + second_digest = "b" * 64 + lock_path = tmp_path / "requirements-ci.txt" + lock_path.write_text( + "tool==1.2.3 " + f"--hash=sha256:{first_digest} " + f"--hash=sha256:{second_digest}\n", + encoding="utf-8", + ) + + assert generator._load_runtime_lock(lock_path) == { + "tool": { + "version": "1.2.3", + "sha256": (first_digest, second_digest), + "marker": None, + } + } + + +def test_runtime_lock_rejects_duplicate_artifact_hashes(tmp_path: Path) -> None: + """Reject duplicate evidence within one package hash set.""" + generator = _load_generator() + digest = "a" * 64 + lock_path = tmp_path / "requirements-ci.txt" + lock_path.write_text( + f"tool==1.2.3 --hash=sha256:{digest} --hash=sha256:{digest}\n", + encoding="utf-8", + ) + + with pytest.raises(SystemExit, match="duplicate SHA-256"): + generator._load_runtime_lock(lock_path) + + +def test_runtime_manifest_accepts_its_digest_among_platform_hashes( + tmp_path: Path, +) -> None: + """Bind runtime evidence to one reviewed digest in a portable hash set.""" + generator = _load_generator() + lock_text = LOCK_PATH.read_text(encoding="utf-8") + needle = ( + "anyio==4.14.2 \\\n" + " --hash=sha256:" + "9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494" + ) + replacement = needle + " \\\n --hash=sha256:" + ("f" * 64) + assert lock_text.count(needle) == 1 + portable_lock = tmp_path / "requirements-ci.txt" + portable_lock.write_text( + lock_text.replace(needle, replacement, 1), + encoding="utf-8", + ) + + generator.validate_runtime_lock(MANIFEST_PATH, portable_lock) + + +def test_runtime_manifest_rejects_absent_digest_from_platform_hashes( + tmp_path: Path, +) -> None: + """Reject a hash set that omits the exact reviewed runtime artifact digest.""" + generator = _load_generator() + lock_text = LOCK_PATH.read_text(encoding="utf-8") + needle = ( + "anyio==4.14.2 \\\n" + " --hash=sha256:" + "9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494" + ) + replacement = ( + "anyio==4.14.2 \\\n" + " --hash=sha256:" + + ("e" * 64) + + " \\\n --hash=sha256:" + + ("f" * 64) + ) + assert lock_text.count(needle) == 1 + portable_lock = tmp_path / "requirements-ci.txt" + portable_lock.write_text( + lock_text.replace(needle, replacement, 1), + encoding="utf-8", + ) + + with pytest.raises(SystemExit, match="does not match the hash-locked runtime subset"): + generator.validate_runtime_lock(MANIFEST_PATH, portable_lock)