From ce896351b10550ae406f043045a60b3b3677fb7c Mon Sep 17 00:00:00 2001 From: Alexis Date: Fri, 26 Jul 2024 17:21:47 +0200 Subject: [PATCH 01/14] Add the PEP 740 related objects Provenance, AttestationsBundle and Publisher --- src/pypi_attestations/__init__.py | 10 +++- src/pypi_attestations/_impl.py | 93 +++++++++++++++++++++++++++++-- test/test_impl.py | 46 ++++++++++++--- 3 files changed, 135 insertions(+), 14 deletions(-) diff --git a/src/pypi_attestations/__init__.py b/src/pypi_attestations/__init__.py index 3a56175..847cb70 100644 --- a/src/pypi_attestations/__init__.py +++ b/src/pypi_attestations/__init__.py @@ -4,23 +4,31 @@ from ._impl import ( Attestation, + AttestationBundle, AttestationError, AttestationType, ConversionError, Distribution, Envelope, + Provenance, + Publisher, TransparencyLogEntry, VerificationError, VerificationMaterial, + construct_simple_provenance_object, ) __all__ = [ "Attestation", + "AttestationBundle", "AttestationError", "AttestationType", - "Envelope", "ConversionError", + "construct_simple_provenance_object", "Distribution", + "Envelope", + "Provenance", + "Publisher", "TransparencyLogEntry", "VerificationError", "VerificationMaterial", diff --git a/src/pypi_attestations/_impl.py b/src/pypi_attestations/_impl.py index d253898..b992a9d 100644 --- a/src/pypi_attestations/_impl.py +++ b/src/pypi_attestations/_impl.py @@ -14,7 +14,7 @@ from cryptography import x509 from cryptography.hazmat.primitives import serialization from packaging.utils import parse_sdist_filename, parse_wheel_filename -from pydantic import Base64Bytes, BaseModel, field_validator +from pydantic import Base64Bytes, BaseModel, TypeAdapter, field_validator from pydantic_core import ValidationError from sigstore._utils import _sha256_streaming from sigstore.dsse import Envelope as DsseEnvelope @@ -154,10 +154,10 @@ def sign(cls, signer: Signer, dist: Distribution) -> Attestation: raise AttestationError(str(e)) def verify( - self, - verifier: Verifier, - policy: VerificationPolicy, - dist: Distribution, + self, + verifier: Verifier, + policy: VerificationPolicy, + dist: Distribution, ) -> tuple[str, dict[str, Any] | None]: """Verify against an existing Python distribution. @@ -331,3 +331,86 @@ def _ultranormalize_dist_filename(dist: str) -> str: return f"{name}-{ver}.tar.gz" else: raise ValueError(f"unknown distribution format: {dist}") + + +class Publisher(BaseModel): + kind: str + """ + The kind of Trusted Publisher. + """ + + claims: object | None + """ + Claims specified by the publisher. + """ + + @classmethod + def from_kind(cls, kind: str) -> Publisher: + """Construct a Publisher from a kind.""" + return Publisher( + kind=kind, + claims=None + ) + + +class AttestationBundle(BaseModel): + publisher: Publisher + """ + The publisher associated with this set of attestations. + """ + + attestations: list[Attestation] + """ + The list of attestations included in this bundle. + """ + + +class Provenance(BaseModel): + version: Literal[1] + """ + The provenance object's version, which is always 1. + """ + + attestation_bundles: list[AttestationBundle] + """ + One or more attestation "bundles". + """ + + +class ProvenanceError(AttestationError): + """The Provenance object was not constructed.""" + + def __init__(self: ProvenanceError, msg: str) -> None: + """Initialize an `ProvenanceError`.""" + super().__init__(f"Provenance object not constructed: {msg}") + + +def construct_simple_provenance_object(kind: str, attestations: list[str | bytes]) -> Provenance: + """Construct a provenance object. + + The Provenance object is constructed using: + - a publisher kind + - a list of attestations + + This provenance object only accept one publisher. + """ + if not kind: + raise ProvenanceError("Missing Publisher kind.") + + if not attestations: + raise ProvenanceError("Missing attestations.") + + publisher = Publisher.from_kind(kind=kind) + + attestation_bundle = AttestationBundle( + publisher=publisher, + attestations=[ + TypeAdapter(Attestation).validate_json(attestation) + for attestation in attestations + ], + ) + + return Provenance( + version=1, + attestation_bundles=[attestation_bundle] + ) diff --git a/test/test_impl.py b/test/test_impl.py index e6c2180..5db89c4 100644 --- a/test/test_impl.py +++ b/test/test_impl.py @@ -83,7 +83,7 @@ def dummy_predicate(self_: _StatementBuilder, _: str) -> _StatementBuilder: @online def test_expired_certificate( - self, id_token: IdentityToken, monkeypatch: pytest.MonkeyPatch + self, id_token: IdentityToken, monkeypatch: pytest.MonkeyPatch ) -> None: def in_validity_period(_: IdentityToken) -> bool: return False @@ -97,7 +97,7 @@ def in_validity_period(_: IdentityToken) -> bool: @online def test_multiple_signatures( - self, id_token: IdentityToken, monkeypatch: pytest.MonkeyPatch + self, id_token: IdentityToken, monkeypatch: pytest.MonkeyPatch ) -> None: def get_bundle(*_) -> Bundle: # noqa: ANN002 # Duplicate the signature to trigger a Conversion error @@ -164,7 +164,7 @@ def test_verify_digest_mismatch(self, tmp_path: Path) -> None: # attestation has the correct filename, but a mismatching digest. with pytest.raises( - impl.VerificationError, match="subject does not match distribution digest" + impl.VerificationError, match="subject does not match distribution digest" ): attestation.verify(verifier, pol, modified_dist) @@ -184,7 +184,7 @@ def test_verify_filename_mismatch(self, tmp_path: Path) -> None: # attestation has the correct digest, but a mismatching filename. with pytest.raises( - impl.VerificationError, match="subject does not match distribution name" + impl.VerificationError, match="subject does not match distribution name" ): attestation.verify(verifier, pol, different_name_dist) @@ -403,12 +403,12 @@ def test_exception_types(self) -> None: # wheel: compressed tag sets are sorted, even when conflicting or nonsense ("foo-1.0-py3.py2-none-any.whl", "foo-1.0-py2.py3-none-any.whl"), ( - "foo-1.0-py3.py2-none.abi3.cp37-any.whl", - "foo-1.0-py2.py3-abi3.cp37.none-any.whl", + "foo-1.0-py3.py2-none.abi3.cp37-any.whl", + "foo-1.0-py2.py3-abi3.cp37.none-any.whl", ), ( - "foo-1.0-py3.py2-none.abi3.cp37-linux_x86_64.any.whl", - "foo-1.0-py2.py3-abi3.cp37.none-any.linux_x86_64.whl", + "foo-1.0-py3.py2-none.abi3.cp37-linux_x86_64.any.whl", + "foo-1.0-py2.py3-abi3.cp37.none-any.linux_x86_64.whl", ), # wheel: verbose compressed tag sets are re-compressed ("foo-1.0-py3.py2.py3-none-any.whl", "foo-1.0-py2.py3-none-any.whl"), @@ -462,3 +462,33 @@ def test_ultranormalize_dist_filename(input: str, normalized: str) -> None: def test_ultranormalize_dist_filename_invalid(input: str) -> None: with pytest.raises(ValueError): impl._ultranormalize_dist_filename(input) + + +def test_construct_provenance() -> None: + attestation_bytes = dist_attestation_path.read_bytes() + + provenance = impl.construct_simple_provenance_object( + kind="simple-publisher-url", + attestations=[ + attestation_bytes + ] + ) + + assert provenance.version == 1 + assert len(provenance.attestation_bundles) == 1 + + bundle = provenance.attestation_bundles[0] + assert bundle.publisher.claims is None + assert bundle.publisher.kind == "simple-publisher-url" + + assert bundle.attestations == [impl.Attestation.model_validate_json(attestation_bytes)] + + +def test_construct_provenance_fails() -> None: + with pytest.raises(impl.ProvenanceError): + impl.construct_simple_provenance_object(kind="", + attestations=[dist_attestation_path.read_bytes()]) + + with pytest.raises(impl.ProvenanceError): + impl.construct_simple_provenance_object(kind="simple-publisher-url", + attestations=[]) From 06d7854fba5e28514eb4b54bc9344709fcfa2191 Mon Sep 17 00:00:00 2001 From: Alexis Date: Fri, 26 Jul 2024 17:23:05 +0200 Subject: [PATCH 02/14] Lint code --- src/pypi_attestations/_impl.py | 21 +++++++-------------- test/test_impl.py | 29 +++++++++++++---------------- 2 files changed, 20 insertions(+), 30 deletions(-) diff --git a/src/pypi_attestations/_impl.py b/src/pypi_attestations/_impl.py index b992a9d..74ef478 100644 --- a/src/pypi_attestations/_impl.py +++ b/src/pypi_attestations/_impl.py @@ -154,10 +154,10 @@ def sign(cls, signer: Signer, dist: Distribution) -> Attestation: raise AttestationError(str(e)) def verify( - self, - verifier: Verifier, - policy: VerificationPolicy, - dist: Distribution, + self, + verifier: Verifier, + policy: VerificationPolicy, + dist: Distribution, ) -> tuple[str, dict[str, Any] | None]: """Verify against an existing Python distribution. @@ -347,10 +347,7 @@ class Publisher(BaseModel): @classmethod def from_kind(cls, kind: str) -> Publisher: """Construct a Publisher from a kind.""" - return Publisher( - kind=kind, - claims=None - ) + return Publisher(kind=kind, claims=None) class AttestationBundle(BaseModel): @@ -405,12 +402,8 @@ def construct_simple_provenance_object(kind: str, attestations: list[str | bytes attestation_bundle = AttestationBundle( publisher=publisher, attestations=[ - TypeAdapter(Attestation).validate_json(attestation) - for attestation in attestations + TypeAdapter(Attestation).validate_json(attestation) for attestation in attestations ], ) - return Provenance( - version=1, - attestation_bundles=[attestation_bundle] - ) + return Provenance(version=1, attestation_bundles=[attestation_bundle]) diff --git a/test/test_impl.py b/test/test_impl.py index 5db89c4..9869599 100644 --- a/test/test_impl.py +++ b/test/test_impl.py @@ -83,7 +83,7 @@ def dummy_predicate(self_: _StatementBuilder, _: str) -> _StatementBuilder: @online def test_expired_certificate( - self, id_token: IdentityToken, monkeypatch: pytest.MonkeyPatch + self, id_token: IdentityToken, monkeypatch: pytest.MonkeyPatch ) -> None: def in_validity_period(_: IdentityToken) -> bool: return False @@ -97,7 +97,7 @@ def in_validity_period(_: IdentityToken) -> bool: @online def test_multiple_signatures( - self, id_token: IdentityToken, monkeypatch: pytest.MonkeyPatch + self, id_token: IdentityToken, monkeypatch: pytest.MonkeyPatch ) -> None: def get_bundle(*_) -> Bundle: # noqa: ANN002 # Duplicate the signature to trigger a Conversion error @@ -164,7 +164,7 @@ def test_verify_digest_mismatch(self, tmp_path: Path) -> None: # attestation has the correct filename, but a mismatching digest. with pytest.raises( - impl.VerificationError, match="subject does not match distribution digest" + impl.VerificationError, match="subject does not match distribution digest" ): attestation.verify(verifier, pol, modified_dist) @@ -184,7 +184,7 @@ def test_verify_filename_mismatch(self, tmp_path: Path) -> None: # attestation has the correct digest, but a mismatching filename. with pytest.raises( - impl.VerificationError, match="subject does not match distribution name" + impl.VerificationError, match="subject does not match distribution name" ): attestation.verify(verifier, pol, different_name_dist) @@ -403,12 +403,12 @@ def test_exception_types(self) -> None: # wheel: compressed tag sets are sorted, even when conflicting or nonsense ("foo-1.0-py3.py2-none-any.whl", "foo-1.0-py2.py3-none-any.whl"), ( - "foo-1.0-py3.py2-none.abi3.cp37-any.whl", - "foo-1.0-py2.py3-abi3.cp37.none-any.whl", + "foo-1.0-py3.py2-none.abi3.cp37-any.whl", + "foo-1.0-py2.py3-abi3.cp37.none-any.whl", ), ( - "foo-1.0-py3.py2-none.abi3.cp37-linux_x86_64.any.whl", - "foo-1.0-py2.py3-abi3.cp37.none-any.linux_x86_64.whl", + "foo-1.0-py3.py2-none.abi3.cp37-linux_x86_64.any.whl", + "foo-1.0-py2.py3-abi3.cp37.none-any.linux_x86_64.whl", ), # wheel: verbose compressed tag sets are re-compressed ("foo-1.0-py3.py2.py3-none-any.whl", "foo-1.0-py2.py3-none-any.whl"), @@ -468,10 +468,7 @@ def test_construct_provenance() -> None: attestation_bytes = dist_attestation_path.read_bytes() provenance = impl.construct_simple_provenance_object( - kind="simple-publisher-url", - attestations=[ - attestation_bytes - ] + kind="simple-publisher-url", attestations=[attestation_bytes] ) assert provenance.version == 1 @@ -486,9 +483,9 @@ def test_construct_provenance() -> None: def test_construct_provenance_fails() -> None: with pytest.raises(impl.ProvenanceError): - impl.construct_simple_provenance_object(kind="", - attestations=[dist_attestation_path.read_bytes()]) + impl.construct_simple_provenance_object( + kind="", attestations=[dist_attestation_path.read_bytes()] + ) with pytest.raises(impl.ProvenanceError): - impl.construct_simple_provenance_object(kind="simple-publisher-url", - attestations=[]) + impl.construct_simple_provenance_object(kind="simple-publisher-url", attestations=[]) From 952b05c00822a482829a0182ae1d58ac93348778 Mon Sep 17 00:00:00 2001 From: Alexis Date: Fri, 26 Jul 2024 17:29:31 +0200 Subject: [PATCH 03/14] Update CHANGELOG.md --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4638930..104b595 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- The `Provenance`, `Publisher`, `AttestationBundle` type have been added + ([#36](https://github.com/trailofbits/pypi-attestations/pull/36)). + +- The `construct_simple_provenance_object` function has been added, allowing + to construct a Provenance object from a single publisher kind and a list of + attestation from this publisher ([#36](https://github.com/trailofbits/pypi-attestations/pull/36)). + ## [0.0.9] ### Added From 03aa1376e6872e1d2f05b89a3b4b2b4b92a5a90d Mon Sep 17 00:00:00 2001 From: Alexis Date: Fri, 26 Jul 2024 18:02:42 +0200 Subject: [PATCH 04/14] Fix linting --- src/pypi_attestations/_impl.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/pypi_attestations/_impl.py b/src/pypi_attestations/_impl.py index 74ef478..541ec90 100644 --- a/src/pypi_attestations/_impl.py +++ b/src/pypi_attestations/_impl.py @@ -7,14 +7,14 @@ import base64 from enum import Enum -from typing import TYPE_CHECKING, Annotated, Any, Literal, NewType +from typing import TYPE_CHECKING, Annotated, Any, Literal, NewType, Union import sigstore.errors from annotated_types import MinLen # noqa: TCH002 from cryptography import x509 from cryptography.hazmat.primitives import serialization from packaging.utils import parse_sdist_filename, parse_wheel_filename -from pydantic import Base64Bytes, BaseModel, TypeAdapter, field_validator +from pydantic import Base64Bytes, BaseModel, Field, TypeAdapter, field_validator from pydantic_core import ValidationError from sigstore._utils import _sha256_streaming from sigstore.dsse import Envelope as DsseEnvelope @@ -154,10 +154,10 @@ def sign(cls, signer: Signer, dist: Distribution) -> Attestation: raise AttestationError(str(e)) def verify( - self, - verifier: Verifier, - policy: VerificationPolicy, - dist: Distribution, + self, + verifier: Verifier, + policy: VerificationPolicy, + dist: Distribution, ) -> tuple[str, dict[str, Any] | None]: """Verify against an existing Python distribution. @@ -334,12 +334,14 @@ def _ultranormalize_dist_filename(dist: str) -> str: class Publisher(BaseModel): + """Publisher as defined in PEP 740.""" + kind: str """ The kind of Trusted Publisher. """ - claims: object | None + claims: Union[object, None] = Field(...) # noqa: UP007 """ Claims specified by the publisher. """ @@ -351,6 +353,8 @@ def from_kind(cls, kind: str) -> Publisher: class AttestationBundle(BaseModel): + """AttestationBundle object as defined in PEP 740.""" + publisher: Publisher """ The publisher associated with this set of attestations. @@ -363,6 +367,8 @@ class AttestationBundle(BaseModel): class Provenance(BaseModel): + """Provenance object as defined in PEP 740.""" + version: Literal[1] """ The provenance object's version, which is always 1. From 426659fa8c018455a4624644add817aff68ef116 Mon Sep 17 00:00:00 2001 From: dm Date: Mon, 29 Jul 2024 14:58:08 +0200 Subject: [PATCH 05/14] Apply suggestions from code review Fix typos and other problems in commit messages Co-authored-by: Facundo Tuesca --- CHANGELOG.md | 4 ++-- src/pypi_attestations/_impl.py | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 104b595..2942b58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,12 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- The `Provenance`, `Publisher`, `AttestationBundle` type have been added +- The `Provenance`, `Publisher`, `AttestationBundle` types have been added ([#36](https://github.com/trailofbits/pypi-attestations/pull/36)). - The `construct_simple_provenance_object` function has been added, allowing to construct a Provenance object from a single publisher kind and a list of - attestation from this publisher ([#36](https://github.com/trailofbits/pypi-attestations/pull/36)). + attestations from this publisher ([#36](https://github.com/trailofbits/pypi-attestations/pull/36)). ## [0.0.9] diff --git a/src/pypi_attestations/_impl.py b/src/pypi_attestations/_impl.py index 541ec90..e01c04c 100644 --- a/src/pypi_attestations/_impl.py +++ b/src/pypi_attestations/_impl.py @@ -154,10 +154,10 @@ def sign(cls, signer: Signer, dist: Distribution) -> Attestation: raise AttestationError(str(e)) def verify( - self, - verifier: Verifier, - policy: VerificationPolicy, - dist: Distribution, + self, + verifier: Verifier, + policy: VerificationPolicy, + dist: Distribution, ) -> tuple[str, dict[str, Any] | None]: """Verify against an existing Python distribution. @@ -395,7 +395,7 @@ def construct_simple_provenance_object(kind: str, attestations: list[str | bytes - a publisher kind - a list of attestations - This provenance object only accept one publisher. + This provenance object only accepts one publisher. """ if not kind: raise ProvenanceError("Missing Publisher kind.") From b6230477b4ec5671c86867d774deac4125886a44 Mon Sep 17 00:00:00 2001 From: Alexis Date: Mon, 29 Jul 2024 15:20:43 +0200 Subject: [PATCH 06/14] Move construct_simple_provenance to Provenance class. --- src/pypi_attestations/__init__.py | 2 -- src/pypi_attestations/_impl.py | 49 ++++++------------------------- test/test_impl.py | 19 +++--------- 3 files changed, 13 insertions(+), 57 deletions(-) diff --git a/src/pypi_attestations/__init__.py b/src/pypi_attestations/__init__.py index 847cb70..3c43bbd 100644 --- a/src/pypi_attestations/__init__.py +++ b/src/pypi_attestations/__init__.py @@ -15,7 +15,6 @@ TransparencyLogEntry, VerificationError, VerificationMaterial, - construct_simple_provenance_object, ) __all__ = [ @@ -24,7 +23,6 @@ "AttestationError", "AttestationType", "ConversionError", - "construct_simple_provenance_object", "Distribution", "Envelope", "Provenance", diff --git a/src/pypi_attestations/_impl.py b/src/pypi_attestations/_impl.py index e01c04c..15a35c3 100644 --- a/src/pypi_attestations/_impl.py +++ b/src/pypi_attestations/_impl.py @@ -14,7 +14,7 @@ from cryptography import x509 from cryptography.hazmat.primitives import serialization from packaging.utils import parse_sdist_filename, parse_wheel_filename -from pydantic import Base64Bytes, BaseModel, Field, TypeAdapter, field_validator +from pydantic import Base64Bytes, BaseModel, Field, field_validator from pydantic_core import ValidationError from sigstore._utils import _sha256_streaming from sigstore.dsse import Envelope as DsseEnvelope @@ -346,11 +346,6 @@ class Publisher(BaseModel): Claims specified by the publisher. """ - @classmethod - def from_kind(cls, kind: str) -> Publisher: - """Construct a Publisher from a kind.""" - return Publisher(kind=kind, claims=None) - class AttestationBundle(BaseModel): """AttestationBundle object as defined in PEP 740.""" @@ -379,37 +374,11 @@ class Provenance(BaseModel): One or more attestation "bundles". """ - -class ProvenanceError(AttestationError): - """The Provenance object was not constructed.""" - - def __init__(self: ProvenanceError, msg: str) -> None: - """Initialize an `ProvenanceError`.""" - super().__init__(f"Provenance object not constructed: {msg}") - - -def construct_simple_provenance_object(kind: str, attestations: list[str | bytes]) -> Provenance: - """Construct a provenance object. - - The Provenance object is constructed using: - - a publisher kind - - a list of attestations - - This provenance object only accepts one publisher. - """ - if not kind: - raise ProvenanceError("Missing Publisher kind.") - - if not attestations: - raise ProvenanceError("Missing attestations.") - - publisher = Publisher.from_kind(kind=kind) - - attestation_bundle = AttestationBundle( - publisher=publisher, - attestations=[ - TypeAdapter(Attestation).validate_json(attestation) for attestation in attestations - ], - ) - - return Provenance(version=1, attestation_bundles=[attestation_bundle]) + @classmethod + def construct_simple(cls, publisher: Publisher, attestations: list[Attestation]) -> Provenance: + """Construct a simple Provenance object.""" + attestation_bundle = AttestationBundle( + publisher=publisher, + attestations=attestations, + ) + return cls(version=1, attestation_bundles=[attestation_bundle]) diff --git a/test/test_impl.py b/test/test_impl.py index 9869599..84d78ec 100644 --- a/test/test_impl.py +++ b/test/test_impl.py @@ -465,11 +465,10 @@ def test_ultranormalize_dist_filename_invalid(input: str) -> None: def test_construct_provenance() -> None: - attestation_bytes = dist_attestation_path.read_bytes() + attestation = impl.Attestation.model_validate_json(dist_attestation_path.read_bytes()) - provenance = impl.construct_simple_provenance_object( - kind="simple-publisher-url", attestations=[attestation_bytes] - ) + publisher = impl.Publisher(kind="simple-publisher-url", claims=None) + provenance = impl.Provenance.construct_simple(publisher=publisher, attestations=[attestation]) assert provenance.version == 1 assert len(provenance.attestation_bundles) == 1 @@ -478,14 +477,4 @@ def test_construct_provenance() -> None: assert bundle.publisher.claims is None assert bundle.publisher.kind == "simple-publisher-url" - assert bundle.attestations == [impl.Attestation.model_validate_json(attestation_bytes)] - - -def test_construct_provenance_fails() -> None: - with pytest.raises(impl.ProvenanceError): - impl.construct_simple_provenance_object( - kind="", attestations=[dist_attestation_path.read_bytes()] - ) - - with pytest.raises(impl.ProvenanceError): - impl.construct_simple_provenance_object(kind="simple-publisher-url", attestations=[]) + assert bundle.attestations == [attestation] From 37d8a2a136dca9a59a96f51a4073b4d45fb8cd25 Mon Sep 17 00:00:00 2001 From: Alexis Date: Mon, 29 Jul 2024 16:54:55 +0200 Subject: [PATCH 07/14] Leverage new dependency on Python 3.11 --- src/pypi_attestations/_impl.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/pypi_attestations/_impl.py b/src/pypi_attestations/_impl.py index 15a35c3..6d5f767 100644 --- a/src/pypi_attestations/_impl.py +++ b/src/pypi_attestations/_impl.py @@ -7,14 +7,14 @@ import base64 from enum import Enum -from typing import TYPE_CHECKING, Annotated, Any, Literal, NewType, Union +from typing import TYPE_CHECKING, Annotated, Any, Literal, NewType import sigstore.errors from annotated_types import MinLen # noqa: TCH002 from cryptography import x509 from cryptography.hazmat.primitives import serialization from packaging.utils import parse_sdist_filename, parse_wheel_filename -from pydantic import Base64Bytes, BaseModel, Field, field_validator +from pydantic import Base64Bytes, BaseModel, field_validator from pydantic_core import ValidationError from sigstore._utils import _sha256_streaming from sigstore.dsse import Envelope as DsseEnvelope @@ -154,10 +154,10 @@ def sign(cls, signer: Signer, dist: Distribution) -> Attestation: raise AttestationError(str(e)) def verify( - self, - verifier: Verifier, - policy: VerificationPolicy, - dist: Distribution, + self, + verifier: Verifier, + policy: VerificationPolicy, + dist: Distribution, ) -> tuple[str, dict[str, Any] | None]: """Verify against an existing Python distribution. @@ -341,7 +341,7 @@ class Publisher(BaseModel): The kind of Trusted Publisher. """ - claims: Union[object, None] = Field(...) # noqa: UP007 + claims: object | None """ Claims specified by the publisher. """ From 66657c3cd06adeac4c5587f4c5c88e574b119d9f Mon Sep 17 00:00:00 2001 From: Alexis Date: Mon, 29 Jul 2024 16:55:17 +0200 Subject: [PATCH 08/14] Update CHANGELOG.md --- CHANGELOG.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a17c23..635b977 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,9 +15,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The `Provenance`, `Publisher`, `AttestationBundle` types have been added ([#36](https://github.com/trailofbits/pypi-attestations/pull/36)). -- The `construct_simple_provenance_object` function has been added, allowing - to construct a Provenance object from a single publisher kind and a list of - attestations from this publisher ([#36](https://github.com/trailofbits/pypi-attestations/pull/36)). ## [0.0.9] From b2ca1d7371a2524dc26c449783df7503d619a244 Mon Sep 17 00:00:00 2001 From: Alexis Date: Mon, 29 Jul 2024 16:56:03 +0200 Subject: [PATCH 09/14] Please linter. --- src/pypi_attestations/_impl.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/pypi_attestations/_impl.py b/src/pypi_attestations/_impl.py index 6d5f767..bfb5ac0 100644 --- a/src/pypi_attestations/_impl.py +++ b/src/pypi_attestations/_impl.py @@ -154,10 +154,10 @@ def sign(cls, signer: Signer, dist: Distribution) -> Attestation: raise AttestationError(str(e)) def verify( - self, - verifier: Verifier, - policy: VerificationPolicy, - dist: Distribution, + self, + verifier: Verifier, + policy: VerificationPolicy, + dist: Distribution, ) -> tuple[str, dict[str, Any] | None]: """Verify against an existing Python distribution. From ba82097b5d990ea086e62623ecb656ecc98942d9 Mon Sep 17 00:00:00 2001 From: dm Date: Tue, 30 Jul 2024 09:34:56 +0200 Subject: [PATCH 10/14] Apply suggestions from code review Co-authored-by: William Woodruff --- src/pypi_attestations/_impl.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pypi_attestations/_impl.py b/src/pypi_attestations/_impl.py index bfb5ac0..021d044 100644 --- a/src/pypi_attestations/_impl.py +++ b/src/pypi_attestations/_impl.py @@ -341,7 +341,7 @@ class Publisher(BaseModel): The kind of Trusted Publisher. """ - claims: object | None + claims: dict[str, Any] | None """ Claims specified by the publisher. """ From c03bb0fc444c5e99b38a1cc75e8ccc9293844060 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Wed, 31 Jul 2024 14:04:37 -0400 Subject: [PATCH 11/14] Make `Publisher` a discriminated union (#38) --- Makefile | 4 +- src/pypi_attestations/__init__.py | 4 ++ src/pypi_attestations/_impl.py | 62 +++++++++++++++++++++++-------- test/test_impl.py | 60 ++++++++++++++++++++++++------ 4 files changed, 100 insertions(+), 30 deletions(-) diff --git a/Makefile b/Makefile index 02f3b54..0f4cccd 100644 --- a/Makefile +++ b/Makefile @@ -46,9 +46,7 @@ dev: $(VENV)/pyvenv.cfg $(VENV)/pyvenv.cfg: pyproject.toml # Create our Python 3 virtual environment python3 -m venv env - # NOTE(ekilmer): interrogate v1.5.0 needs setuptools when using Python 3.12+. - # This should be fixed when the next release is made - $(VENV_BIN)/python -m pip install --upgrade pip setuptools + $(VENV_BIN)/python -m pip install --upgrade pip $(VENV_BIN)/python -m pip install -e .[$(INSTALL_EXTRA)] .PHONY: lint diff --git a/src/pypi_attestations/__init__.py b/src/pypi_attestations/__init__.py index 3c43bbd..a57fbae 100644 --- a/src/pypi_attestations/__init__.py +++ b/src/pypi_attestations/__init__.py @@ -10,6 +10,8 @@ ConversionError, Distribution, Envelope, + GitHubPublisher, + GitLabPublisher, Provenance, Publisher, TransparencyLogEntry, @@ -25,6 +27,8 @@ "ConversionError", "Distribution", "Envelope", + "GitHubPublisher", + "GitLabPublisher", "Provenance", "Publisher", "TransparencyLogEntry", diff --git a/src/pypi_attestations/_impl.py b/src/pypi_attestations/_impl.py index 021d044..b84d9df 100644 --- a/src/pypi_attestations/_impl.py +++ b/src/pypi_attestations/_impl.py @@ -14,7 +14,8 @@ from cryptography import x509 from cryptography.hazmat.primitives import serialization from packaging.utils import parse_sdist_filename, parse_wheel_filename -from pydantic import Base64Bytes, BaseModel, field_validator +from pydantic import Base64Bytes, BaseModel, ConfigDict, Field, field_validator +from pydantic.alias_generators import to_snake from pydantic_core import ValidationError from sigstore._utils import _sha256_streaming from sigstore.dsse import Envelope as DsseEnvelope @@ -333,20 +334,58 @@ def _ultranormalize_dist_filename(dist: str) -> str: raise ValueError(f"unknown distribution format: {dist}") -class Publisher(BaseModel): - """Publisher as defined in PEP 740.""" +class _PublisherBase(BaseModel): + model_config = ConfigDict(alias_generator=to_snake) kind: str + claims: dict[str, Any] | None = None + + +class GitHubPublisher(_PublisherBase): + """A GitHub-based Trusted Publisher.""" + + kind: Literal["GitHub"] = "GitHub" + + repository: str + """ + The fully qualified publishing repository slug, e.g. `foo/bar` for + repository `bar` owned by `foo`. + """ + + workflow: str + """ + The filename of the GitHub Actions workflow that performed the publishing + action. + """ + + environment: str | None = None + """ + The optional name GitHub Actions environment that the publishing + action was performed from. """ - The kind of Trusted Publisher. + + +class GitLabPublisher(_PublisherBase): + """A GitLab-based Trusted Publisher.""" + + kind: Literal["GitLab"] = "GitLab" + + repository: str + """ + The fully qualified publishing repository slug, e.g. `foo/bar` for + repository `bar` owned by `foo` or `foo/baz/bar` for repository + `bar` owned by group `foo` and subgroup `baz`. """ - claims: dict[str, Any] | None + environment: str | None = None """ - Claims specified by the publisher. + The optional environment that the publishing action was performed from. """ +Publisher = Annotated[GitHubPublisher | GitLabPublisher, Field(discriminator="kind")] + + class AttestationBundle(BaseModel): """AttestationBundle object as defined in PEP 740.""" @@ -364,7 +403,7 @@ class AttestationBundle(BaseModel): class Provenance(BaseModel): """Provenance object as defined in PEP 740.""" - version: Literal[1] + version: Literal[1] = 1 """ The provenance object's version, which is always 1. """ @@ -373,12 +412,3 @@ class Provenance(BaseModel): """ One or more attestation "bundles". """ - - @classmethod - def construct_simple(cls, publisher: Publisher, attestations: list[Attestation]) -> Provenance: - """Construct a simple Provenance object.""" - attestation_bundle = AttestationBundle( - publisher=publisher, - attestations=attestations, - ) - return cls(version=1, attestation_bundles=[attestation_bundle]) diff --git a/test/test_impl.py b/test/test_impl.py index 84d78ec..5157285 100644 --- a/test/test_impl.py +++ b/test/test_impl.py @@ -1,5 +1,6 @@ """Internal implementation tests.""" +import json import os from hashlib import sha256 from pathlib import Path @@ -8,7 +9,7 @@ import pypi_attestations._impl as impl import pytest import sigstore -from pydantic import ValidationError +from pydantic import TypeAdapter, ValidationError from sigstore.dsse import _DigestSet, _StatementBuilder, _Subject from sigstore.models import Bundle from sigstore.oidc import IdentityToken @@ -464,17 +465,54 @@ def test_ultranormalize_dist_filename_invalid(input: str) -> None: impl._ultranormalize_dist_filename(input) -def test_construct_provenance() -> None: - attestation = impl.Attestation.model_validate_json(dist_attestation_path.read_bytes()) +class TestPublisher: + def test_discriminator(self) -> None: + gh_raw = {"kind": "GitHub", "repository": "foo/bar", "workflow": "publish.yml"} + gh = TypeAdapter(impl.Publisher).validate_python(gh_raw) + + assert isinstance(gh, impl.GitHubPublisher) + assert gh.repository == "foo/bar" + assert gh.workflow == "publish.yml" + assert TypeAdapter(impl.Publisher).validate_json(json.dumps(gh_raw)) == gh - publisher = impl.Publisher(kind="simple-publisher-url", claims=None) - provenance = impl.Provenance.construct_simple(publisher=publisher, attestations=[attestation]) + gl_raw = {"kind": "GitLab", "repository": "foo/bar/baz", "environment": "publish"} + gl = TypeAdapter(impl.Publisher).validate_python(gl_raw) + assert isinstance(gl, impl.GitLabPublisher) + assert gl.repository == "foo/bar/baz" + assert gl.environment == "publish" + assert TypeAdapter(impl.Publisher).validate_json(json.dumps(gl_raw)) == gl - assert provenance.version == 1 - assert len(provenance.attestation_bundles) == 1 + def test_wrong_kind(self) -> None: + with pytest.raises(ValueError, match="Input should be 'GitHub'"): + impl.GitHubPublisher(kind="wrong", repository="foo/bar", workflow="publish.yml") - bundle = provenance.attestation_bundles[0] - assert bundle.publisher.claims is None - assert bundle.publisher.kind == "simple-publisher-url" + with pytest.raises(ValueError, match="Input should be 'GitLab'"): + impl.GitLabPublisher(kind="GitHub", repository="foo/bar") - assert bundle.attestations == [attestation] + +class TestProvenance: + def test_version(self) -> None: + attestation = impl.Attestation.model_validate_json(dist_attestation_path.read_bytes()) + provenance = impl.Provenance( + attestation_bundles=[ + impl.AttestationBundle( + publisher=impl.GitHubPublisher(repository="foo/bar", workflow="publish.yml"), + attestations=[attestation], + ) + ] + ) + assert provenance.version == 1 + + # Setting any other version doesn't work. + with pytest.raises(ValueError): + provenance = impl.Provenance( + version=2, + attestation_bundles=[ + impl.AttestationBundle( + publisher=impl.GitHubPublisher( + repository="foo/bar", workflow="publish.yml" + ), + attestations=[attestation], + ) + ], + ) From 2784d1d94de22f8e69ec134c5b8abd59a174fbbc Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Wed, 31 Jul 2024 14:12:08 -0400 Subject: [PATCH 12/14] CHANGELOG: update Signed-off-by: William Woodruff --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 635b977..28d909e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,10 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The minimum Python version required has been bumped to `3.11` ([#37](https://github.com/trailofbits/pypi-attestations/pull/37)) -- The `Provenance`, `Publisher`, `AttestationBundle` types have been added +- The `Provenance`, `Publisher`, `GitHubPublisher`, `GitLabPublisher`, and + `AttestationBundle` types have been added ([#36](https://github.com/trailofbits/pypi-attestations/pull/36)). - ## [0.0.9] ### Added From 50379ec1edc5610191dd4680fe1f190f8e0f2fb7 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Wed, 31 Jul 2024 17:42:05 -0400 Subject: [PATCH 13/14] test_impl: claims test Signed-off-by: William Woodruff --- test/test_impl.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/test/test_impl.py b/test/test_impl.py index 59ade91..4a2d245 100644 --- a/test/test_impl.py +++ b/test/test_impl.py @@ -489,6 +489,23 @@ def test_wrong_kind(self) -> None: with pytest.raises(ValueError, match="Input should be 'GitLab'"): impl.GitLabPublisher(kind="GitHub", repository="foo/bar") + def test_claims(self) -> None: + raw = { + "kind": "GitHub", + "repository": "foo/bar", + "workflow": "publish.yml", + "claims": { + "this": "is-preserved", + "this-too": 123, + } + } + pub = TypeAdapter(impl.Publisher).validate_python(raw) + + assert pub.claims == { + "this": "is-preserved", + "this-too": 123, + } + class TestProvenance: def test_version(self) -> None: From 8434723b9880fd07109cb7ef22706b32c0b20815 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Wed, 31 Jul 2024 17:52:48 -0400 Subject: [PATCH 14/14] lintage 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 4a2d245..538df1c 100644 --- a/test/test_impl.py +++ b/test/test_impl.py @@ -497,7 +497,7 @@ def test_claims(self) -> None: "claims": { "this": "is-preserved", "this-too": 123, - } + }, } pub = TypeAdapter(impl.Publisher).validate_python(raw)