diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b391983..96e4397a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ All versions prior to 1.0.0 are untracked. - Standardized CLI flags to use hyphens (e.g., `--trust-config` instead of `--trust_config`). Underscore variants are still accepted for backwards compatibility via token normalization. ### Fixed +- Fixed a path traversal during verification with `ignore_unsigned_files`, where a signed manifest whose resource paths contained `..` caused files outside the model directory to be read and hashed. Such paths are now rejected. - Fixed a bug where installing from the sdist produced an empty wheel with zero Python modules. The hatch `packages` directive was scoped to all build targets instead of the wheel target only, causing the sdist's flattened layout to not match the expected `src/` path. ([#636](https://github.com/sigstore/model-transparency/issues/636)) - Fixed a bug where ignored symlinks could raise `ValueError`s if allow_symlinks was unset, even though they were skipped during serialization. ([#550](https://github.com/sigstore/model-transparency/pull/550)) - Fixed a bug where any PEM encoded key could be read during the key-based flows which resulted in a Python exception because the rest of the code only supported elliptic curve keys. ([#573](https://github.com/sigstore/model-transparency/pull/573)) diff --git a/src/model_signing/verifying.py b/src/model_signing/verifying.py index 87303490..e09f9773 100644 --- a/src/model_signing/verifying.py +++ b/src/model_signing/verifying.py @@ -106,10 +106,19 @@ def verify( ) if self._ignore_unsigned_files: - files_to_hash = [ - model_path / rd.identifier - for rd in expected_manifest.resource_descriptors() - ] + model_root = pathlib.Path(model_path) + resolved_root = model_root.resolve() + files_to_hash = [] + for rd in expected_manifest.resource_descriptors(): + file_to_hash = model_root / rd.identifier + try: + file_to_hash.resolve().relative_to(resolved_root) + except ValueError: + raise ValueError( + "Signed manifest references a path outside the model " + f"directory: {rd.identifier}" + ) from None + files_to_hash.append(file_to_hash) else: files_to_hash = None diff --git a/tests/api_test.py b/tests/api_test.py index c65e7322..069fb20a 100644 --- a/tests/api_test.py +++ b/tests/api_test.py @@ -29,8 +29,13 @@ import pytest from model_signing import hashing +from model_signing import manifest from model_signing import signing from model_signing import verifying +from model_signing._hashing import hashing as _hashing +from model_signing._hashing import memory +from model_signing._signing import sign_ec_key +from model_signing._signing import signing as _signing # Directory with testdata for this test @@ -417,3 +422,65 @@ def test_sign_and_verify_sharded(self, base_path, populate_tmpdir): signature, ignore_git_paths, ["model.sig", "ignored"] ) assert get_model_name(signature) == os.path.basename(model_path) + + +class TestIgnoreUnsignedFilesTraversal: + def _sha256(self, path: Path) -> _hashing.Digest: + hasher = memory.SHA256() + hasher.update(path.read_bytes()) + digest = hasher.compute() + return _hashing.Digest("sha256", digest.digest_value) + + def _sign_manifest( + self, mani: manifest.Manifest, private_key: Path, out: Path + ) -> None: + signer = sign_ec_key.Signer(private_key) + signature = signer.sign(_signing.Payload(mani)) + signature.write(out) + + def test_verify_rejects_path_outside_model(self, tmp_path): + model = tmp_path / "model" + model.mkdir() + (model / "weights.bin").write_bytes(b"legit weights") + secret = tmp_path / "secret.txt" + secret.write_bytes(b"outside the model root") + + items = [ + manifest.FileManifestItem( + path=Path("weights.bin"), + digest=self._sha256(model / "weights.bin"), + ), + manifest.FileManifestItem( + path=Path("../secret.txt"), + digest=self._sha256(secret), + ), + ] + serialization = manifest._FileSerialization("sha256") + mani = manifest.Manifest("model", items, serialization) + + private_key = Path(TESTDATA / "keys/certificate/signing-key.pem") + public_key = Path(TESTDATA / "keys/certificate/signing-key-pub.pem") + signature = tmp_path / "model.sig" + self._sign_manifest(mani, private_key, signature) + + with pytest.raises(ValueError, match="outside the model directory"): + verifying.Config().use_elliptic_key_verifier( + public_key=public_key + ).set_ignore_unsigned_files(True).verify(model, signature) + + def test_verify_accepts_in_model_paths(self, tmp_path): + model = tmp_path / "model" + model.mkdir() + (model / "weights.bin").write_bytes(b"legit weights") + + private_key = Path(TESTDATA / "keys/certificate/signing-key.pem") + public_key = Path(TESTDATA / "keys/certificate/signing-key-pub.pem") + signature = tmp_path / "model.sig" + + signing.Config().use_elliptic_key_signer( + private_key=private_key + ).sign(model, signature) + + verifying.Config().use_elliptic_key_verifier( + public_key=public_key + ).set_ignore_unsigned_files(True).verify(model, signature)