diff --git a/CHANGELOG.md b/CHANGELOG.md index cff2c39..1f94234 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- The `GitLabPublisher` policy now takes the workflow file path in order to + verify attestations, rathen than assuming it will always be `gitlab-ci.yml` + ([#71](https://github.com/trailofbits/pypi-attestations/pull/71)). +- The `GitLabPublisher` now longer expects claims being passed during construction, + rather the `ref` and `sha` claims are extracted from the certificate's extensions, + similar to `GitHubPublisher`'s behavior + ([#71](https://github.com/trailofbits/pypi-attestations/pull/71)). + ## [0.0.16] ### Added diff --git a/src/pypi_attestations/_impl.py b/src/pypi_attestations/_impl.py index da34820..a6089ae 100644 --- a/src/pypi_attestations/_impl.py +++ b/src/pypi_attestations/_impl.py @@ -342,6 +342,11 @@ class Envelope(BaseModel): """ +def _der_decode_utf8string(der: bytes) -> str: + """Decode a DER-encoded UTF8String.""" + return der_decode(der, UTF8String)[0].decode() # type: ignore[no-any-return] + + def _ultranormalize_dist_filename(dist: str) -> str: """Return an "ultranormalized" form of the given distribution filename. @@ -424,11 +429,6 @@ def __init__(self, repository: str, workflow: str) -> None: ] ) - @classmethod - def _der_decode_utf8string(cls, der: bytes) -> str: - """Decode a DER-encoded UTF8String.""" - return der_decode(der, UTF8String)[0].decode() # type: ignore[no-any-return] - def verify(self, cert: Certificate) -> None: """Verify the certificate against the Trusted Publisher identity.""" self._subpolicy.verify(cert) @@ -452,18 +452,18 @@ def verify(self, cert: Certificate) -> None: # where OWNER/REPO and WORKFLOW are controlled by the TP identity, # and REF is controlled by the certificate's own claims. build_config_uri = cert.extensions.get_extension_for_oid(policy._OIDC_BUILD_CONFIG_URI_OID) # noqa: SLF001 - raw_build_config_uri = self._der_decode_utf8string(build_config_uri.value.public_bytes()) + raw_build_config_uri = _der_decode_utf8string(build_config_uri.value.public_bytes()) # (2) Extract the source repo digest and ref. source_repo_digest = cert.extensions.get_extension_for_oid( policy._OIDC_SOURCE_REPOSITORY_DIGEST_OID # noqa: SLF001 ) - sha = self._der_decode_utf8string(source_repo_digest.value.public_bytes()) + sha = _der_decode_utf8string(source_repo_digest.value.public_bytes()) source_repo_ref = cert.extensions.get_extension_for_oid( policy._OIDC_SOURCE_REPOSITORY_REF_OID # noqa: SLF001 ) - ref = self._der_decode_utf8string(source_repo_ref.value.public_bytes()) + ref = _der_decode_utf8string(source_repo_ref.value.public_bytes()) # (3)-(4): Build the expected URIs and compare them for suffix in [sha, ref]: @@ -507,6 +507,71 @@ def _as_policy(self) -> VerificationPolicy: return _GitHubTrustedPublisherPolicy(self.repository, self.workflow) +class _GitLabTrustedPublisherPolicy: + """A custom sigstore-python policy for verifying against a GitLab-based Trusted Publisher.""" + + def __init__(self, repository: str, workflow_filepath: str) -> None: + self._repository = repository + self._workflow_filepath = workflow_filepath + # This policy must also satisfy some baseline underlying policies: + # the issuer must be GitLab, and the repo must be the one + # we expect. + self._subpolicy = policy.AllOf( + [ + policy.OIDCIssuerV2("https://gitlab.com"), + policy.OIDCSourceRepositoryURI(f"https://gitlab.com/{self._repository}"), + ] + ) + + def verify(self, cert: Certificate) -> None: + """Verify the certificate against the Trusted Publisher identity.""" + self._subpolicy.verify(cert) + + # This process has a few annoying steps, since a Trusted Publisher + # isn't aware of the commit or ref it runs on, while Sigstore's + # leaf certificate claims (like GitLab CI/CD's OIDC claims) only + # ever encode the workflow filename (which we need to check) next + # to the ref/sha (which we can't check). + # + # To get around this, we: + # (1) extract the `Build Config URI` extension; + # (2) extract the `Source Repository Digest` and + # `Source Repository Ref` extensions; + # (3) build the *expected* URI with the user-controlled + # Trusted Publisher identity *with* (2) + # (4) compare (1) with (3) + + # (1) Extract the build config URI, which looks like this: + # https://gitlab.com/NAMESPACE/PROJECT//WORKFLOW_FILEPATH@REF + # where NAMESPACE/PROJECT and WORKFLOW_FILEPATH are controlled by the TP identity, + # and REF is controlled by the certificate's own claims. + build_config_uri = cert.extensions.get_extension_for_oid(policy._OIDC_BUILD_CONFIG_URI_OID) # noqa: SLF001 + raw_build_config_uri = _der_decode_utf8string(build_config_uri.value.public_bytes()) + + # (2) Extract the source repo digest and ref. + source_repo_digest = cert.extensions.get_extension_for_oid( + policy._OIDC_SOURCE_REPOSITORY_DIGEST_OID # noqa: SLF001 + ) + sha = _der_decode_utf8string(source_repo_digest.value.public_bytes()) + + source_repo_ref = cert.extensions.get_extension_for_oid( + policy._OIDC_SOURCE_REPOSITORY_REF_OID # noqa: SLF001 + ) + ref = _der_decode_utf8string(source_repo_ref.value.public_bytes()) + + # (3)-(4): Build the expected URIs and compare them + for suffix in [sha, ref]: + expected = f"https://gitlab.com/{self._repository}//{self._workflow_filepath}@{suffix}" + if raw_build_config_uri == expected: + return + + # If none of the expected URIs matched, the policy fails. + raise sigstore.errors.VerificationError( + f"Certificate's Build Config URI ({build_config_uri}) does not match expected " + f"Trusted Publisher ({self._workflow_filepath} @ {self._repository})" + ) + + class GitLabPublisher(_PublisherBase): """A GitLab-based Trusted Publisher.""" @@ -519,30 +584,19 @@ class GitLabPublisher(_PublisherBase): `bar` owned by group `foo` and subgroup `baz`. """ + workflow_filepath: str + """ + The path for the CI/CD configuration file. This is usually ".gitlab-ci.yml", + but can be customized. + """ + environment: Optional[str] = None """ The optional environment that the publishing action was performed from. """ def _as_policy(self) -> VerificationPolicy: - policies: list[VerificationPolicy] = [ - policy.OIDCIssuerV2("https://gitlab.com"), - policy.OIDCSourceRepositoryURI(f"https://gitlab.com/{self.repository}"), - ] - - if not self.claims: - raise VerificationError("refusing to build a policy without claims") - - if ref := self.claims.get("ref"): - policies.append( - policy.OIDCBuildConfigURI( - f"https://gitlab.com/{self.repository}//.gitlab-ci.yml@{ref}" - ) - ) - else: - raise VerificationError("refusing to build a policy without a ref claim") - - return policy.AllOf(policies) + return _GitLabTrustedPublisherPolicy(self.repository, self.workflow_filepath) _Publisher = Union[GitHubPublisher, GitLabPublisher] diff --git a/test/assets/gitlab_oidc_project-0.0.3.tar.gz b/test/assets/gitlab_oidc_project-0.0.3.tar.gz new file mode 100644 index 0000000..fdcb6fb Binary files /dev/null and b/test/assets/gitlab_oidc_project-0.0.3.tar.gz differ diff --git a/test/assets/gitlab_oidc_project-0.0.3.tar.gz.publish.attestation b/test/assets/gitlab_oidc_project-0.0.3.tar.gz.publish.attestation new file mode 100644 index 0000000..e6b5a62 --- /dev/null +++ b/test/assets/gitlab_oidc_project-0.0.3.tar.gz.publish.attestation @@ -0,0 +1 @@ +{"version":1,"verification_material":{"certificate":"MIIF1DCCBVugAwIBAgIUHl3MO/hxGtgk/PZ8bUcn9V1/Ql4wCgYIKoZIzj0EAwMwNzEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MR4wHAYDVQQDExVzaWdzdG9yZS1pbnRlcm1lZGlhdGUwHhcNMjQxMTE5MTczNjU5WhcNMjQxMTE5MTc0NjU5WjAAMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEPDlszbJKMqT8XFwsOTP0BFSjBRQr8BG00z416LHErJLwqt+xFdFxWgEX0wbqzY/zarEHAStTP56YmrEvRJTdQ6OCBHowggR2MA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzAdBgNVHQ4EFgQUAEYvjlQjVNj1tQNLDw1v+jjRT3MwHwYDVR0jBBgwFoAU39Ppz1YkEZb5qNjpKFWixi4YZD8wXwYDVR0RAQH/BFUwU4ZRaHR0cHM6Ly9naXRsYWIuY29tL2ZhY3V0dWVzY2EvZ2l0bGFiLW9pZGMtcHJvamVjdC8vLmdpdGxhYi1jaS55bWxAcmVmcy9oZWFkcy9tYWluMCAGCisGAQQBg78wAQEEEmh0dHBzOi8vZ2l0bGFiLmNvbTAiBgorBgEEAYO/MAEIBBQMEmh0dHBzOi8vZ2l0bGFiLmNvbTBhBgorBgEEAYO/MAEJBFMMUWh0dHBzOi8vZ2l0bGFiLmNvbS9mYWN1dHVlc2NhL2dpdGxhYi1vaWRjLXByb2plY3QvLy5naXRsYWItY2kueW1sQHJlZnMvaGVhZHMvbWFpbjA4BgorBgEEAYO/MAEKBCoMKDcyZjdjNjNiNzVlYjU1ZWE4MDg2NDk2MmYwZTY0NWM5MzQxNGRhMzQwHQYKKwYBBAGDvzABCwQPDA1naXRsYWItaG9zdGVkMEEGCisGAQQBg78wAQwEMwwxaHR0cHM6Ly9naXRsYWIuY29tL2ZhY3V0dWVzY2EvZ2l0bGFiLW9pZGMtcHJvamVjdDA4BgorBgEEAYO/MAENBCoMKDcyZjdjNjNiNzVlYjU1ZWE4MDg2NDk2MmYwZTY0NWM5MzQxNGRhMzQwHwYKKwYBBAGDvzABDgQRDA9yZWZzL2hlYWRzL21haW4wGAYKKwYBBAGDvzABDwQKDAg1NTIzNTY2NDAtBgorBgEEAYO/MAEQBB8MHWh0dHBzOi8vZ2l0bGFiLmNvbS9mYWN1dHVlc2NhMBgGCisGAQQBg78wAREECgwIMTI4ODU4MDEwYQYKKwYBBAGDvzABEgRTDFFodHRwczovL2dpdGxhYi5jb20vZmFjdXR1ZXNjYS9naXRsYWItb2lkYy1wcm9qZWN0Ly8uZ2l0bGFiLWNpLnltbEByZWZzL2hlYWRzL21haW4wOAYKKwYBBAGDvzABEwQqDCg3MmY3YzYzYjc1ZWI1NWVhODA4NjQ5NjJmMGU2NDVjOTM0MTRkYTM0MBQGCisGAQQBg78wARQEBgwEcHVzaDBTBgorBgEEAYO/MAEVBEUMQ2h0dHBzOi8vZ2l0bGFiLmNvbS9mYWN1dHVlc2NhL2dpdGxhYi1vaWRjLXByb2plY3QvLS9qb2JzLzg0MTU3NTQ5NDkwFwYKKwYBBAGDvzABFgQJDAdwcml2YXRlMIGJBgorBgEEAdZ5AgQCBHsEeQB3AHUA3T0wasbHETJjGR4cmWc3AqJKXrjePK3/h4pygC8p7o4AAAGTRX+9KQAABAMARjBEAiAmmQwhQX2nP9vsK12F+RQxv0x+iTRuB7uF3BDmQ0r1YwIgPVcLb14rgXdmj1JXamu1qCYNvlWbJeaJDpJE/3JtiEEwCgYIKoZIzj0EAwMDZwAwZAIwKvO/AAywpJGaBdJ2SZ2nJYcCi36MeID4BpBq5mN7shyCUzjiB6EVqhYxZaERGNOgAjAOgMyByDiuIa4CqUCXa32E639XEULbNfMAnMdEumsFC6RJ+aEldYS7Rq4tZ+GDjAo=","transparency_entries":[{"logIndex":"149945774","logId":{"keyId":"wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0="},"kindVersion":{"kind":"dsse","version":"0.0.1"},"integratedTime":"1732037820","inclusionPromise":{"signedEntryTimestamp":"MEYCIQDjqijkErZKeE5/se4xQqFQ5kP675QB4CtWuWPjH2MBNAIhAICIsptracjrLUF0kvySUhAiWRkbfCdpC0V5ZYx8Bzhg"},"inclusionProof":{"logIndex":"28041512","rootHash":"XLhUYuDAJiP6uPyhU+vxECyT4VAbtpWYAVdp/r/0IJQ=","treeSize":"28041513","hashes":["ou7HYOC/wjAzYv/5XVk7Dj7rwfJxmoXLKanK5kVbXB8=","uS1ExhqHy/LavONWCe+3ZkJtTZsogfnEHW/N2QS8APo=","0MOwvYwuDyGm8t9NOhLozfWwxieRm1cinUfykuBwUZs=","yByu3shb/r+bv1TUvtb0o+GWnleoOVD3w5ypLetYvFA=","ybI3cJVONWy02Pl1TrgP7ah1Sx5rY4F4mKKfpxjeWoY=","FdHKii91opeRT8Tzx7nKjZO/OA9UgHPPVOicwvp939o=","DpDOpGhoNhkb7mHanMmlbtfD7xzzhh5AU5MwyWOGLDI=","yHaIr00Cr8UwHagETV3qXuwei/B6CgyHrzcxTpFYwpA=","HdjiYX8LA9CwwDDzSy7LwCMkXLwAYQdvYIbxEn7wwOs=","E2rLOYPJFKiizYiyu07QLqkMVTVL7i2ZgXiQywdI9KQ=","4lUF0YOu9XkIDXKXA0wMSzd6VeDY3TZAgmoOeWmS2+Y=","gf+9m552B3PnkWnO0o4KdVvjcT3WVHLrCbf1DoVYKFw="],"checkpoint":{"envelope":"rekor.sigstore.dev - 1193050959916656506\n28041513\nXLhUYuDAJiP6uPyhU+vxECyT4VAbtpWYAVdp/r/0IJQ=\n\n— rekor.sigstore.dev wNI9ajBEAiB+O4uYsXgc7d0dkLvz/Suu0QszPI4cBCf3SwGYfFyjnwIgA1J8mDSavgv1sadUfe410TumS/LJlvx65+QNJ7VBvoc=\n"}},"canonicalizedBody":"eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiZHNzZSIsInNwZWMiOnsiZW52ZWxvcGVIYXNoIjp7ImFsZ29yaXRobSI6InNoYTI1NiIsInZhbHVlIjoiMzU0ODZhM2Y1MjRjM2IxNTk1YTYzNjBiMzQ4NDAwM2FjNDYyODgyM2U0ZTIxYTU3NGE5ZDg4YTY3NjAwNDY5MCJ9LCJwYXlsb2FkSGFzaCI6eyJhbGdvcml0aG0iOiJzaGEyNTYiLCJ2YWx1ZSI6IjMyMTFiNDYyNmFhNjU1YWQ0NDZmMzQxN2UxM2UwODcxMThjYTc5YmViZWQyMTc5NWZiOWNmMzJjOGQyODg0NGEifSwic2lnbmF0dXJlcyI6W3sic2lnbmF0dXJlIjoiTUVRQ0lDT29HT0lPTU1RQVNUdVFJbnVEdWltR3p6NGhReUhJWHlmNHR0c1hoY0lzQWlCK0JFSnNLbjJZMGNuTzdKZnUrYWpHU1dzenVGQytBenFZM1psZERwUXZWZz09IiwidmVyaWZpZXIiOiJMUzB0TFMxQ1JVZEpUaUJEUlZKVVNVWkpRMEZVUlMwdExTMHRDazFKU1VZeFJFTkRRbFoxWjBGM1NVSkJaMGxWU0d3elRVOHZhSGhIZEdkckwxQmFPR0pWWTI0NVZqRXZVV3cwZDBObldVbExiMXBKZW1vd1JVRjNUWGNLVG5wRlZrMUNUVWRCTVZWRlEyaE5UV015Ykc1ak0xSjJZMjFWZFZwSFZqSk5ValIzU0VGWlJGWlJVVVJGZUZaNllWZGtlbVJIT1hsYVV6RndZbTVTYkFwamJURnNXa2RzYUdSSFZYZElhR05PVFdwUmVFMVVSVFZOVkdONlRtcFZOVmRvWTA1TmFsRjRUVlJGTlUxVVl6Qk9hbFUxVjJwQlFVMUdhM2RGZDFsSUNrdHZXa2w2YWpCRFFWRlpTVXR2V2tsNmFqQkVRVkZqUkZGblFVVlFSR3h6ZW1KS1MwMXhWRGhZUm5kelQxUlFNRUpHVTJwQ1VsRnlPRUpITURCNk5ERUtOa3hJUlhKS1RIZHhkQ3Q0Um1SR2VGZG5SVmd3ZDJKeGVsa3ZlbUZ5UlVoQlUzUlVVRFUyV1cxeVJYWlNTbFJrVVRaUFEwSkliM2RuWjFJeVRVRTBSd3BCTVZWa1JIZEZRaTkzVVVWQmQwbElaMFJCVkVKblRsWklVMVZGUkVSQlMwSm5aM0pDWjBWR1FsRmpSRUY2UVdSQ1owNVdTRkUwUlVablVWVkJSVmwyQ21wc1VXcFdUbW94ZEZGT1RFUjNNWFlyYW1wU1ZETk5kMGgzV1VSV1VqQnFRa0puZDBadlFWVXpPVkJ3ZWpGWmEwVmFZalZ4VG1wd1MwWlhhWGhwTkZrS1drUTRkMWgzV1VSV1VqQlNRVkZJTDBKR1ZYZFZORnBTWVVoU01HTklUVFpNZVRsdVlWaFNjMWxYU1hWWk1qbDBUREphYUZrelZqQmtWMVo2V1RKRmRncGFNbXd3WWtkR2FVeFhPWEJhUjAxMFkwaEtkbUZ0Vm1wa1F6aDJURzFrY0dSSGVHaFphVEZxWVZNMU5XSlhlRUZqYlZadFkzazViMXBYUm10amVUbDBDbGxYYkhWTlEwRkhRMmx6UjBGUlVVSm5OemgzUVZGRlJVVnRhREJrU0VKNlQyazRkbG95YkRCaVIwWnBURzFPZG1KVVFXbENaMjl5UW1kRlJVRlpUeThLVFVGRlNVSkNVVTFGYldnd1pFaENlazlwT0haYU1td3dZa2RHYVV4dFRuWmlWRUpvUW1kdmNrSm5SVVZCV1U4dlRVRkZTa0pHVFUxVlYyZ3daRWhDZWdwUGFUaDJXakpzTUdKSFJtbE1iVTUyWWxNNWJWbFhUakZrU0Zac1l6Sk9hRXd5WkhCa1IzaG9XV2t4ZG1GWFVtcE1XRUo1WWpKd2JGa3pVWFpNZVRWdUNtRllVbk5aVjBsMFdUSnJkV1ZYTVhOUlNFcHNXbTVOZG1GSFZtaGFTRTEyWWxkR2NHSnFRVFJDWjI5eVFtZEZSVUZaVHk5TlFVVkxRa052VFV0RVkza0tXbXBrYWs1cVRtbE9lbFpzV1dwVk1WcFhSVFJOUkdjeVRrUnJNazF0V1hkYVZGa3dUbGROTlUxNlVYaE9SMUpvVFhwUmQwaFJXVXRMZDFsQ1FrRkhSQXAyZWtGQ1EzZFJVRVJCTVc1aFdGSnpXVmRKZEdGSE9YcGtSMVpyVFVWRlIwTnBjMGRCVVZGQ1p6YzRkMEZSZDBWTmQzZDRZVWhTTUdOSVRUWk1lVGx1Q21GWVVuTlpWMGwxV1RJNWRFd3lXbWhaTTFZd1pGZFdlbGt5UlhaYU1td3dZa2RHYVV4WE9YQmFSMDEwWTBoS2RtRnRWbXBrUkVFMFFtZHZja0puUlVVS1FWbFBMMDFCUlU1Q1EyOU5TMFJqZVZwcVpHcE9hazVwVG5wV2JGbHFWVEZhVjBVMFRVUm5NazVFYXpKTmJWbDNXbFJaTUU1WFRUVk5lbEY0VGtkU2FBcE5lbEYzU0hkWlMwdDNXVUpDUVVkRWRucEJRa1JuVVZKRVFUbDVXbGRhZWt3eWFHeFpWMUo2VERJeGFHRlhOSGRIUVZsTFMzZFpRa0pCUjBSMmVrRkNDa1IzVVV0RVFXY3hUbFJKZWs1VVdUSk9SRUYwUW1kdmNrSm5SVVZCV1U4dlRVRkZVVUpDT0UxSVYyZ3daRWhDZWs5cE9IWmFNbXd3WWtkR2FVeHRUbllLWWxNNWJWbFhUakZrU0Zac1l6Sk9hRTFDWjBkRGFYTkhRVkZSUW1jM09IZEJVa1ZGUTJkM1NVMVVTVFJQUkZVMFRVUkZkMWxSV1V0TGQxbENRa0ZIUkFwMmVrRkNSV2RTVkVSR1JtOWtTRkozWTNwdmRrd3laSEJrUjNob1dXazFhbUl5TUhaYWJVWnFaRmhTTVZwWVRtcFpVemx1WVZoU2MxbFhTWFJpTW14ckNsbDVNWGRqYlRseFdsZE9NRXg1T0hWYU1td3dZa2RHYVV4WFRuQk1ibXgwWWtWQ2VWcFhXbnBNTW1oc1dWZFNla3d5TVdoaFZ6UjNUMEZaUzB0M1dVSUtRa0ZIUkhaNlFVSkZkMUZ4UkVObk0wMXRXVE5aZWxsNldXcGpNVnBYU1RGT1YxWm9UMFJCTkU1cVVUVk9ha3B0VFVkVk1rNUVWbXBQVkUwd1RWUlNhd3BaVkUwd1RVSlJSME5wYzBkQlVWRkNaemM0ZDBGU1VVVkNaM2RGWTBoV2VtRkVRbFJDWjI5eVFtZEZSVUZaVHk5TlFVVldRa1ZWVFZFeWFEQmtTRUo2Q2s5cE9IWmFNbXd3WWtkR2FVeHRUblppVXpsdFdWZE9NV1JJVm14ak1rNW9UREprY0dSSGVHaFphVEYyWVZkU2FreFlRbmxpTW5Cc1dUTlJka3hUT1hFS1lqSktla3g2WnpCTlZGVXpUbFJSTlU1RWEzZEdkMWxMUzNkWlFrSkJSMFIyZWtGQ1JtZFJTa1JCWkhkamJXd3lXVmhTYkUxSlIwcENaMjl5UW1kRlJRcEJaRm8xUVdkUlEwSkljMFZsVVVJelFVaFZRVE5VTUhkaGMySklSVlJLYWtkU05HTnRWMk16UVhGS1MxaHlhbVZRU3pNdmFEUndlV2RET0hBM2J6UkJDa0ZCUjFSU1dDczVTMUZCUVVKQlRVRlNha0pGUVdsQmJXMVJkMmhSV0RKdVVEbDJjMHN4TWtZclVsRjRkakI0SzJsVVVuVkNOM1ZHTTBKRWJWRXdjakVLV1hkSloxQldZMHhpTVRSeVoxaGtiV294U2xoaGJYVXhjVU5aVG5ac1YySktaV0ZLUkhCS1JTOHpTblJwUlVWM1EyZFpTVXR2V2tsNmFqQkZRWGROUkFwYWQwRjNXa0ZKZDB0MlR5OUJRWGwzY0VwSFlVSmtTakpUV2pKdVNsbGpRMmt6TmsxbFNVUTBRbkJDY1RWdFRqZHphSGxEVlhwcWFVSTJSVlp4YUZsNENscGhSVkpIVGs5blFXcEJUMmROZVVKNVJHbDFTV0UwUTNGVlExaGhNekpGTmpNNVdFVlZUR0pPWmsxQmJrMWtSWFZ0YzBaRE5sSktLMkZGYkdSWlV6Y0tVbkUwZEZvclIwUnFRVzg5Q2kwdExTMHRSVTVFSUVORlVsUkpSa2xEUVZSRkxTMHRMUzBLIn1dfX0="}]},"envelope":{"statement":"eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEiLCJzdWJqZWN0IjpbeyJuYW1lIjoiZ2l0bGFiX29pZGNfcHJvamVjdC0wLjAuMy50YXIuZ3oiLCJkaWdlc3QiOnsic2hhMjU2IjoiYzFjYTliMGQ4NWRmMTYwNjQ1MTA5ODIzMzAxODUzNDQ5N2JmNTg0MzYyZTEwZTRhOGMyMWRmYWVhOTJjMDJhOCJ9fV0sInByZWRpY2F0ZVR5cGUiOiJodHRwczovL2RvY3MucHlwaS5vcmcvYXR0ZXN0YXRpb25zL3B1Ymxpc2gvdjEiLCJwcmVkaWNhdGUiOm51bGx9","signature":"MEQCICOoGOIOMMQASTuQInuDuimGzz4hQyHIXyf4ttsXhcIsAiB+BEJsKn2Y0cnO7Jfu+ajGSWszuFC+AzqY3ZldDpQvVg=="}} \ No newline at end of file diff --git a/test/test_impl.py b/test/test_impl.py index b9dafc2..e501983 100644 --- a/test/test_impl.py +++ b/test/test_impl.py @@ -35,6 +35,10 @@ gh_signed_dist = impl.Distribution.from_file(gh_signed_dist_path) gh_signed_dist_bundle_path = _ASSETS / "pypi_attestation_models-0.0.4a2.tar.gz.sigstore" +gl_signed_dist_path = _ASSETS / "gitlab_oidc_project-0.0.3.tar.gz" +gl_signed_dist = impl.Distribution.from_file(gl_signed_dist_path) +gl_attestation_path = _ASSETS / "gitlab_oidc_project-0.0.3.tar.gz.publish.attestation" + class TestDistribution: def test_from_file_nonexistent(self, tmp_path: Path) -> None: @@ -147,6 +151,17 @@ def test_verify_from_github_publisher(self, claims: Optional[dict]) -> None: assert predicate_type == "https://docs.pypi.org/attestations/publish/v1" assert predicate == {} + def test_verify_from_gitlab_publisher(self) -> None: + publisher = impl.GitLabPublisher( + repository="facutuesca/gitlab-oidc-project", + workflow_filepath=".gitlab-ci.yml", + ) + + attestation = impl.Attestation.model_validate_json(gl_attestation_path.read_text()) + predicate_type, predicate = attestation.verify(publisher, gl_signed_dist) + assert predicate_type == "https://docs.pypi.org/attestations/publish/v1" + assert predicate is None + def test_verify_from_github_publisher_wrong(self) -> None: publisher = impl.GitHubPublisher( repository="trailofbits/pypi-attestation-models", @@ -159,6 +174,16 @@ def test_verify_from_github_publisher_wrong(self) -> None: with pytest.raises(impl.VerificationError, match=r"Build Config URI .+ does not match"): attestation.verify(publisher, gh_signed_dist) + def test_verify_from_gitlab_publisher_wrong(self) -> None: + publisher = impl.GitLabPublisher( + repository="facutuesca/gitlab-oidc-project", + workflow_filepath="wrong.yml", + ) + + attestation = impl.Attestation.model_validate_json(gl_attestation_path.read_text()) + with pytest.raises(impl.VerificationError, match=r"Build Config URI .+ does not match"): + attestation.verify(publisher, gl_signed_dist) + def test_verify(self) -> None: # Our checked-in asset has this identity. pol = policy.Identity( @@ -541,10 +566,16 @@ def test_discriminator(self) -> None: assert gh.workflow == "publish.yml" assert TypeAdapter(impl.Publisher).validate_json(json.dumps(gh_raw)) == gh - gl_raw = {"kind": "GitLab", "repository": "foo/bar/baz", "environment": "publish"} + gl_raw = { + "kind": "GitLab", + "repository": "foo/bar/baz", + "workflow_filepath": "dir/release.yml", + "environment": "publish", + } gl: impl.Publisher = TypeAdapter(impl.Publisher).validate_python(gl_raw) assert isinstance(gl, impl.GitLabPublisher) assert gl.repository == "foo/bar/baz" + assert gl.workflow_filepath == "dir/release.yml" assert gl.environment == "publish" assert TypeAdapter(impl.Publisher).validate_json(json.dumps(gl_raw)) == gl @@ -573,21 +604,6 @@ def test_claims(self) -> None: } -class TestGitLabPublisher: - def test_as_policy(self) -> None: - publisher = impl.GitLabPublisher(repository="fake/fake", claims={"ref": "refs/heads/main"}) - pol: policy.AllOf = publisher._as_policy() # type: ignore[assignment] - - assert len(pol._children) == 3 - - @pytest.mark.parametrize("claims", [None, {}, {"something": "unrelated"}, {"ref": None}]) - def test_as_policy_invalid(self, claims: Optional[dict]) -> None: - publisher = impl.GitLabPublisher(repository="fake/fake", claims=claims) - - with pytest.raises(impl.VerificationError, match="refusing to build a policy"): - publisher._as_policy() - - class TestProvenance: def test_version(self) -> None: attestation = impl.Attestation.model_validate_json(dist_attestation_path.read_bytes())