From 5a64600989b640385994a01c6a78b9a5a9340782 Mon Sep 17 00:00:00 2001 From: Itecz Solution Date: Wed, 15 Jul 2026 17:08:04 +0530 Subject: [PATCH] reject signed manifest paths that escape the model directory With ignore_unsigned_files enabled, verifying.Config.verify builds the list of files to hash by joining each resource identifier from the signed manifest onto the model path, with no check that the result stays inside the model directory. A manifest whose resource name is ../secret.txt (or ../../etc/hosts) makes the verifier open and hash a file outside the model root. Anyone whose signature the configuration accepts (for example a keyless identity verified against) can therefore steer reads outside the intended directory during verification. Resolve each joined path and reject any that escape the resolved model root before hashing, matching the containment approach already used for ignore-paths in _cli._resolve_ignore_paths. Keeping the check in the verifier means every signing method benefits without callers needing their own guard. Add a key-signed regression test that crafts a manifest with a .. path and confirms verification now raises, plus a positive case that in-model paths still verify. Signed-off-by: Itecz Solution --- CHANGELOG.md | 1 + src/model_signing/verifying.py | 17 +++++++-- tests/api_test.py | 67 ++++++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 4 deletions(-) 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)