From 54c777b90ec17daaf3a1b628fb58c894b738a151 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Mon, 2 Jun 2025 22:21:33 -0400 Subject: [PATCH 1/2] bugfix: compare distribution names by parsed forms This fixes an error introduced with #124 -- that PR correctly dropped our "ultranormalization" of distribution names, but it didn't account for the fact that we were comparing distribution names as strings, rather than as parsed forms. See https://github.com/pypa/gh-action-pypi-publish/issues/365 for more context. Signed-off-by: William Woodruff --- src/pypi_attestations/_impl.py | 31 ++++++++++++++----- ...nylinux2014_x86_64.whl.publish.attestation | 1 + test/test_impl.py | 26 ++++++++++++++++ 3 files changed, 51 insertions(+), 7 deletions(-) create mode 100644 test/assets/spt3g-1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.publish.attestation diff --git a/src/pypi_attestations/_impl.py b/src/pypi_attestations/_impl.py index 4a9b127..61ee13a 100644 --- a/src/pypi_attestations/_impl.py +++ b/src/pypi_attestations/_impl.py @@ -10,6 +10,10 @@ from enum import Enum from typing import TYPE_CHECKING, Annotated, Any, Literal, NewType, Optional, Union, get_args +import packaging +import packaging.tags +import packaging.utils +import packaging.version import sigstore.errors from annotated_types import MinLen # noqa: TCH002 from cryptography import x509 @@ -291,14 +295,17 @@ def verify( # be an exact match for their distribution filename. # See: https://github.com/pypi/warehouse/issues/18128 # See: https://github.com/trailofbits/pypi-attestations/issues/123 - _check_dist_filename(subject.name) - subject_name = subject.name + parsed_subject_name = _check_dist_filename(subject.name) except ValueError as e: raise VerificationError(f"invalid subject: {str(e)}") - if subject_name != dist.name: + # NOTE: Cannot fail, since we validate the `Distribution` name + # on construction. + parsed_dist_name = _check_dist_filename(dist.name) + + if parsed_subject_name != parsed_dist_name: raise VerificationError( - f"subject does not match distribution name: {subject_name} != {dist.name}" + f"subject does not match distribution name: {subject.name} != {dist.name}" ) digest = subject.digest.root.get("sha256") @@ -392,7 +399,17 @@ def _der_decode_utf8string(der: bytes) -> str: return der_decode(der, UTF8String)[0].decode() # type: ignore[no-any-return] -def _check_dist_filename(dist: str) -> None: +_SdistName = tuple[packaging.utils.NormalizedName, packaging.version.Version] +_BdistName = tuple[ + packaging.utils.NormalizedName, + packaging.version.Version, + packaging.utils.BuildTag, + frozenset[packaging.tags.Tag], +] +_DistName = Union[_SdistName, _BdistName] + + +def _check_dist_filename(dist: str) -> _DistName: """Validate a distribution filename for well-formedness. This does **not** fully normalize the filename. For example, @@ -406,10 +423,10 @@ def _check_dist_filename(dist: str) -> None: # already rejects non-lowercase variants. if dist.endswith(".whl"): # `parse_wheel_filename` raises a supertype of ValueError on failure. - parse_wheel_filename(dist) + return parse_wheel_filename(dist) elif dist.endswith((".tar.gz", ".zip")): # `parse_sdist_filename` raises a supertype of ValueError on failure. - parse_sdist_filename(dist) + return parse_sdist_filename(dist) else: raise ValueError(f"unknown distribution format: {dist}") diff --git a/test/assets/spt3g-1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.publish.attestation b/test/assets/spt3g-1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.publish.attestation new file mode 100644 index 0000000..9590156 --- /dev/null +++ b/test/assets/spt3g-1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.publish.attestation @@ -0,0 +1 @@ +{"version":1,"verification_material":{"certificate":"MIIC1DCCAlqgAwIBAgIUI6ApkULorPzdGxrzzFiq5NISDzowCgYIKoZIzj0EAwMwNzEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MR4wHAYDVQQDExVzaWdzdG9yZS1pbnRlcm1lZGlhdGUwHhcNMjUwNjAzMDIxMTQ3WhcNMjUwNjAzMDIyMTQ3WjAAMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEOgbM4XGw276qP0M8QHDbFEBuF/ZHDwsPG9Ufu90fwppINr5B5X72CfWEWQd+xQH2j/n0K3IbuNTX2+abrDyZP6OCAXkwggF1MA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzAdBgNVHQ4EFgQUMkpwWIsig5OJQxqUNOg5yDhP6L0wHwYDVR0jBBgwFoAUcYYwphR8Ym/599b0BRp/X//rb6wwIwYDVR0RAQH/BBkwF4EVd2lsbGlhbUB5b3NzYXJpYW4ubmV0MCwGCisGAQQBg78wAQEEHmh0dHBzOi8vZ2l0aHViLmNvbS9sb2dpbi9vYXV0aDAuBgorBgEEAYO/MAEIBCAMHmh0dHBzOi8vZ2l0aHViLmNvbS9sb2dpbi9vYXV0aDCBigYKKwYBBAHWeQIEAgR8BHoAeAB2ACswvNxoiMni4dgmKV50H0g5MZYC8pwzy15DQP6yrIZ6AAABlzOPIW0AAAQDAEcwRQIgITGONvxxY9K6dDmLOk0MpkNDAksT79ZgGi97hrMwxhYCIQCpEAqFVWjZTxoVOMnrJdOfO32T9nODooxEuZO93NL4WzAKBggqhkjOPQQDAwNoADBlAjAnFj1zpQbOOybxCoCQLpGZXKib2DIKZ2PgWqZPcviHF3hnHyVPOlZsEzoZRmENn0ACMQCYVhunuSXKPwqcEDCizOrPrmuUxMV6QOA5JTfm2FTjCoByHHiJnNg6zaGtCdcH934=","transparency_entries":[{"logIndex":"43047182","logId":{"keyId":"0y8wo8MtY5wrdiIFohx7sHeI5oKDpK5vQhGHI6G+pJY="},"kindVersion":{"kind":"dsse","version":"0.0.1"},"integratedTime":"1748916708","inclusionPromise":{"signedEntryTimestamp":"MEUCIQCfM8ovwc04CEOHgX6fGf5Pzz82+vDTsHslOBVUGezYIAIgGn7dk6tO7J39kmf/qnlxmZPydLBJoncbLNp6E8fvgFM="},"inclusionProof":{"logIndex":"11364770","rootHash":"ENMXdOX33Y4lNWu52ZxECkS2DK3mEFZgkfS/kSRm5oA=","treeSize":"11364771","hashes":["AWheyozzascmgR49/VMzWgWt9UmWTNKIf2rc6PuTugA=","kumo1jNbrVlSyW2vbHZ0ZpHoWT2V1JvhDp24nCXPPC8=","JyRDG0Zq0G+9t/GT8bw4OZT1wp5JTuvBg15t12oQYXw=","xIvGcJZcwnG+yR9P1yHBaTTAyorVGqdeoa5jid5x97c=","V48BwQhJSweCIRk7yVu1rhWZ8oFfO9Qxs3SZbzVJ0kw=","58Noh/WgnaJutPtj9YL2QRs6Kp42XcPEibW2RlJltE8=","hiBsgkAwBBrSUXxyhhIXU+eEpNvBb20KnrGpN0zTnkU=","QIeOgI91ZwOrM9y+4z277AZ241pn7POReCZzUU/gM8k=","kPnAtj3bBHoLjYDGPJ3n/tPr3Zuy0BYmlTJOFsUAiHU=","4X3ZcZ5JupJMfhwYrQk+zbHb+9n5mEEw+A6nkd7pL0g=","3itMWeZPQXMyYki8lnPZVbzzneXD3w/xvhQsm7VP1ck=","OdoqbUqBYHhj2W1RLM8APkQOnM2K9gzGm1KPFmwIIeQ="],"checkpoint":{"envelope":"rekor.sigstage.dev - 8202293616175992157\n11364771\nENMXdOX33Y4lNWu52ZxECkS2DK3mEFZgkfS/kSRm5oA=\n\n— rekor.sigstage.dev 0y8wozBFAiAYfxAWPCarnkqNKYuOWM332aYBjoqbunGVZpRwpWbBrAIhAJSmUeuXeFaS1aWT57W7u0o5hQatvLGlYtjUYXFXZc7y\n"}},"canonicalizedBody":"eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiZHNzZSIsInNwZWMiOnsiZW52ZWxvcGVIYXNoIjp7ImFsZ29yaXRobSI6InNoYTI1NiIsInZhbHVlIjoiZTMyZmQyNzE5ZTgyZTgyYzI3OWRjNGIwMThlMzdkZDk2OTY1MDFjY2E2YzM3ZDIwNjI2MjJlOGM3OTIzZDJmOCJ9LCJwYXlsb2FkSGFzaCI6eyJhbGdvcml0aG0iOiJzaGEyNTYiLCJ2YWx1ZSI6IjA5NTcwNjM4Njk0ZTY4MDJmNzlhOGY3NjJlMGJiYzcxYWQyOTNiZGE1MTQyNDg2NjQ5OTU4OGRiM2M1NDE5YTIifSwic2lnbmF0dXJlcyI6W3sic2lnbmF0dXJlIjoiTUVVQ0lRRDNuZUk1cmxsMjFPSFBhenp1TERHZklGWUVoOTA0WVdkdkhka2oxUlptb3dJZ0t6NVJSazdJMUNCNnhLSGM4b1QxVVh1TE91Mnc2ZzdINFFJN1ZCVGhKUFU9IiwidmVyaWZpZXIiOiJMUzB0TFMxQ1JVZEpUaUJEUlZKVVNVWkpRMEZVUlMwdExTMHRDazFKU1VNeFJFTkRRV3h4WjBGM1NVSkJaMGxWU1RaQmNHdFZURzl5VUhwa1IzaHllbnBHYVhFMVRrbFRSSHB2ZDBObldVbExiMXBKZW1vd1JVRjNUWGNLVG5wRlZrMUNUVWRCTVZWRlEyaE5UV015Ykc1ak0xSjJZMjFWZFZwSFZqSk5ValIzU0VGWlJGWlJVVVJGZUZaNllWZGtlbVJIT1hsYVV6RndZbTVTYkFwamJURnNXa2RzYUdSSFZYZElhR05PVFdwVmQwNXFRWHBOUkVsNFRWUlJNMWRvWTA1TmFsVjNUbXBCZWsxRVNYbE5WRkV6VjJwQlFVMUdhM2RGZDFsSUNrdHZXa2w2YWpCRFFWRlpTVXR2V2tsNmFqQkVRVkZqUkZGblFVVlBaMkpOTkZoSGR6STNObkZRTUUwNFVVaEVZa1pGUW5WR0wxcElSSGR6VUVjNVZXWUtkVGt3Wm5kd2NFbE9jalZDTlZnM01rTm1WMFZYVVdRcmVGRklNbW92YmpCTE0wbGlkVTVVV0RJcllXSnlSSGxhVURaUFEwRllhM2RuWjBZeFRVRTBSd3BCTVZWa1JIZEZRaTkzVVVWQmQwbElaMFJCVkVKblRsWklVMVZGUkVSQlMwSm5aM0pDWjBWR1FsRmpSRUY2UVdSQ1owNVdTRkUwUlVablVWVk5hM0IzQ2xkSmMybG5OVTlLVVhoeFZVNVBaelY1UkdoUU5rd3dkMGgzV1VSV1VqQnFRa0puZDBadlFWVmpXVmwzY0doU09GbHRMelU1T1dJd1FsSndMMWd2TDNJS1lqWjNkMGwzV1VSV1VqQlNRVkZJTDBKQ2EzZEdORVZXWkRKc2MySkhiR2hpVlVJMVlqTk9lbGxZU25CWlZ6UjFZbTFXTUUxRGQwZERhWE5IUVZGUlFncG5OemgzUVZGRlJVaHRhREJrU0VKNlQyazRkbG95YkRCaFNGWnBURzFPZG1KVE9YTmlNbVJ3WW1rNWRsbFlWakJoUkVGMVFtZHZja0puUlVWQldVOHZDazFCUlVsQ1EwRk5TRzFvTUdSSVFucFBhVGgyV2pKc01HRklWbWxNYlU1MllsTTVjMkl5WkhCaWFUbDJXVmhXTUdGRVEwSnBaMWxMUzNkWlFrSkJTRmNLWlZGSlJVRm5VamhDU0c5QlpVRkNNa0ZEYzNkMlRuaHZhVTF1YVRSa1oyMUxWalV3U0RCbk5VMWFXVU00Y0hkNmVURTFSRkZRTm5seVNWbzJRVUZCUWdwc2VrOVFTVmN3UVVGQlVVUkJSV04zVWxGSlowbFVSMDlPZG5oNFdUbExObVJFYlV4UGF6Qk5jR3RPUkVGcmMxUTNPVnBuUjJrNU4yaHlUWGQ0YUZsRENrbFJRM0JGUVhGR1ZsZHFXbFI0YjFaUFRXNXlTbVJQWms4ek1sUTViazlFYjI5NFJYVmFUemt6VGt3MFYzcEJTMEpuWjNGb2EycFBVRkZSUkVGM1RtOEtRVVJDYkVGcVFXNUdhakY2Y0ZGaVQwOTVZbmhEYjBOUlRIQkhXbGhMYVdJeVJFbExXakpRWjFkeFdsQmpkbWxJUmpOb2JraDVWbEJQYkZwelJYcHZXZ3BTYlVWT2JqQkJRMDFSUTFsV2FIVnVkVk5ZUzFCM2NXTkZSRU5wZWs5eVVISnRkVlY0VFZZMlVVOUJOVXBVWm0weVJsUnFRMjlDZVVoSWFVcHVUbWMyQ25waFIzUkRaR05JT1RNMFBRb3RMUzB0TFVWT1JDQkRSVkpVU1VaSlEwRlVSUzB0TFMwdENnPT0ifV19fQ=="}]},"envelope":{"statement":"eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEiLCJzdWJqZWN0IjpbeyJuYW1lIjoic3B0M2ctMS4wLWNwMzEwLWNwMzEwLW1hbnlsaW51eF8yXzE3X3g4Nl82NC5tYW55bGludXgyMDE0X3g4Nl82NC53aGwiLCJkaWdlc3QiOnsic2hhMjU2IjoiZDI3NzJmOWE1MTk5ZjA1ZWQxYmU4ZDlhYTc4Yjg3OWU1MTc3MmUzZWFkOWQ3M2ZlODA1NzI1N2IxYWVjN2NmOCJ9fV0sInByZWRpY2F0ZVR5cGUiOiJodHRwczovL2RvY3MucHlwaS5vcmcvYXR0ZXN0YXRpb25zL3B1Ymxpc2gvdjEiLCJwcmVkaWNhdGUiOm51bGx9","signature":"MEUCIQD3neI5rll21OHPazzuLDGfIFYEh904YWdvHdkj1RZmowIgKz5RRk7I1CB6xKHc8oT1UXuLOu2w6g7H4QI7VBThJPU="}} \ No newline at end of file diff --git a/test/test_impl.py b/test/test_impl.py index 5bd1e9f..82d908d 100644 --- a/test/test_impl.py +++ b/test/test_impl.py @@ -458,6 +458,32 @@ def test_certificate_claims(self) -> None: assert not results ^ set(attestation.certificate_claims.items()) + def test_verify_different_wheel_tag_order(self) -> None: + attestation_path = ( + _ASSETS + / "spt3g-1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.publish.attestation" + ) + + attestation = impl.Attestation.model_validate_json(attestation_path.read_bytes()) + + pol = policy.Identity( + identity="william@yossarian.net", issuer="https://github.com/login/oauth" + ) + + dist = impl.Distribution( + # Distribution intentionally has a different tag order. + name="spt3g-1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", + digest="d2772f9a5199f05ed1be8d9aa78b879e51772e3ead9d73fe8057257b1aec7cf8", + ) + + attestation.verify(pol, dist, staging=True, offline=True) + + # Distribution names are not string equivalent, but do compare + # as equal when parsed. + subject_name = attestation.statement["subject"][0]["name"] + assert impl._check_dist_filename(subject_name) == impl._check_dist_filename(dist.name) + assert subject_name != dist.name + def test_from_bundle_missing_signatures() -> None: bundle = Bundle.from_json(dist_bundle_path.read_bytes()) From 6b1e0cb11965db3293ea4541a84b51b3196cc3e8 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Mon, 2 Jun 2025 22:24:29 -0400 Subject: [PATCH 2/2] ignore long line Signed-off-by: William Woodruff --- test/test_impl.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_impl.py b/test/test_impl.py index 82d908d..19990d9 100644 --- a/test/test_impl.py +++ b/test/test_impl.py @@ -461,7 +461,7 @@ def test_certificate_claims(self) -> None: def test_verify_different_wheel_tag_order(self) -> None: attestation_path = ( _ASSETS - / "spt3g-1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.publish.attestation" + / "spt3g-1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.publish.attestation" # noqa: E501 ) attestation = impl.Attestation.model_validate_json(attestation_path.read_bytes())