Skip to content

Commit fb8c11d

Browse files
DarkaMaulwoodruffw
andauthored
Prevent third party exceptions to leak from Attestation.sign. (#28)
* Prevent third party exceptions to leak from `Attestation.sign`. * Update CHANGELOG.md * Better handling of OS related errors when opening a file. * Update comments to better understand the tests. * Update CHANGELOG.md --------- Co-authored-by: William Woodruff <william@trailofbits.com>
1 parent b5920de commit fb8c11d

File tree

3 files changed

+107
-21
lines changed

3 files changed

+107
-21
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- `Attestation.sign` now only returns `AttestationError` when failing to sign a distribution file ([#28](https://github.com/trailofbits/pypi-attestations/pull/28))
13+
1014
## [0.0.6]
1115

1216
### Added

src/pypi_attestations/_impl.py

Lines changed: 41 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,20 @@
1212
from annotated_types import MinLen # noqa: TCH002
1313
from cryptography import x509
1414
from cryptography.hazmat.primitives import serialization
15-
from packaging.utils import parse_sdist_filename, parse_wheel_filename
15+
from packaging.utils import (
16+
InvalidSdistFilename,
17+
InvalidWheelFilename,
18+
parse_sdist_filename,
19+
parse_wheel_filename,
20+
)
1621
from pydantic import Base64Bytes, BaseModel
1722
from pydantic_core import ValidationError
1823
from sigstore._utils import _sha256_streaming
1924
from sigstore.dsse import Envelope as DsseEnvelope
25+
from sigstore.dsse import Error as DsseError
2026
from sigstore.dsse import _DigestSet, _Statement, _StatementBuilder, _Subject
2127
from sigstore.models import Bundle, LogEntry
28+
from sigstore.sign import ExpiredCertificate, ExpiredIdentity
2229
from sigstore_protobuf_specs.io.intoto import Envelope as _Envelope
2330
from sigstore_protobuf_specs.io.intoto import Signature as _Signature
2431

@@ -86,34 +93,47 @@ class Attestation(BaseModel):
8693
def sign(cls, signer: Signer, dist: Path) -> Attestation:
8794
"""Create an envelope, with signature, from a distribution file.
8895
89-
On failure, raises `AttestationError` or an appropriate subclass.
96+
On failure, raises `AttestationError`.
9097
"""
91-
with dist.open(mode="rb", buffering=0) as io:
92-
# Replace this with `hashlib.file_digest()` once
93-
# our minimum supported Python is >=3.11
94-
digest = _sha256_streaming(io).hex()
98+
try:
99+
with dist.open(mode="rb", buffering=0) as io:
100+
# Replace this with `hashlib.file_digest()` once
101+
# our minimum supported Python is >=3.11
102+
digest = _sha256_streaming(io).hex()
103+
except OSError as e:
104+
raise AttestationError(str(e))
95105

96106
try:
97107
name = _ultranormalize_dist_filename(dist.name)
98-
except ValueError as e:
108+
except (ValueError, InvalidWheelFilename, InvalidSdistFilename) as e:
99109
raise AttestationError(str(e))
100110

101-
stmt = (
102-
_StatementBuilder()
103-
.subjects(
104-
[
105-
_Subject(
106-
name=name,
107-
digest=_DigestSet(root={"sha256": digest}),
108-
)
109-
]
111+
try:
112+
stmt = (
113+
_StatementBuilder()
114+
.subjects(
115+
[
116+
_Subject(
117+
name=name,
118+
digest=_DigestSet(root={"sha256": digest}),
119+
)
120+
]
121+
)
122+
.predicate_type("https://docs.pypi.org/attestations/publish/v1")
123+
.build()
110124
)
111-
.predicate_type("https://docs.pypi.org/attestations/publish/v1")
112-
.build()
113-
)
114-
bundle = signer.sign_dsse(stmt)
125+
except DsseError as e:
126+
raise AttestationError(str(e))
115127

116-
return Attestation.from_bundle(bundle)
128+
try:
129+
bundle = signer.sign_dsse(stmt)
130+
except (ExpiredCertificate, ExpiredIdentity) as e:
131+
raise AttestationError(str(e))
132+
133+
try:
134+
return Attestation.from_bundle(bundle)
135+
except ConversionError as e:
136+
raise AttestationError(str(e))
117137

118138
def verify(
119139
self, verifier: Verifier, policy: VerificationPolicy, dist: Path

test/test_impl.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import pretend
77
import pypi_attestations._impl as impl
88
import pytest
9+
import sigstore
910
from sigstore.dsse import _DigestSet, _StatementBuilder, _Subject
1011
from sigstore.models import Bundle
1112
from sigstore.oidc import IdentityToken
@@ -57,6 +58,67 @@ def test_sign_invalid_dist_filename(self, tmp_path: Path) -> None:
5758
):
5859
impl.Attestation.sign(pretend.stub(), bad_dist)
5960

61+
def test_sign_raises_attestation_exception(
62+
self, id_token: IdentityToken, tmp_path: Path
63+
) -> None:
64+
non_existing_file = tmp_path / "invalid-name.tar.gz"
65+
with pytest.raises(impl.AttestationError, match="No such file"):
66+
impl.Attestation.sign(pretend.stub(), non_existing_file)
67+
68+
bad_wheel_filename = tmp_path / "invalid-name.whl"
69+
bad_wheel_filename.write_bytes(b"junk")
70+
71+
with pytest.raises(impl.AttestationError, match="Invalid wheel filename"):
72+
impl.Attestation.sign(pretend.stub(), bad_wheel_filename)
73+
74+
bad_sdist_filename = tmp_path / "invalid_name.tar.gz"
75+
bad_sdist_filename.write_bytes(b"junk")
76+
77+
with pytest.raises(impl.AttestationError, match="Invalid sdist filename"):
78+
impl.Attestation.sign(pretend.stub(), bad_sdist_filename)
79+
80+
def test_wrong_predicate_raises_exception(
81+
self, id_token: IdentityToken, monkeypatch: pytest.MonkeyPatch
82+
) -> None:
83+
def dummy_predicate(self_: _StatementBuilder, _: str) -> _StatementBuilder:
84+
# wrong type here to have a validation error
85+
self_._predicate_type = False
86+
return self_
87+
88+
monkeypatch.setattr(sigstore.dsse._StatementBuilder, "predicate_type", dummy_predicate)
89+
with pytest.raises(impl.AttestationError, match="invalid statement"):
90+
impl.Attestation.sign(pretend.stub(), artifact_path)
91+
92+
def test_expired_certificate(
93+
self, id_token: IdentityToken, monkeypatch: pytest.MonkeyPatch
94+
) -> None:
95+
def in_validity_period(_: IdentityToken) -> bool:
96+
return False
97+
98+
monkeypatch.setattr(IdentityToken, "in_validity_period", in_validity_period)
99+
100+
sign_ctx = SigningContext.staging()
101+
with sign_ctx.signer(id_token, cache=False) as signer:
102+
with pytest.raises(impl.AttestationError):
103+
impl.Attestation.sign(signer, artifact_path)
104+
105+
def test_multiple_signatures(
106+
self, id_token: IdentityToken, monkeypatch: pytest.MonkeyPatch
107+
) -> None:
108+
def get_bundle(*_) -> Bundle: # noqa: ANN002
109+
# Duplicate the signature to trigger a Conversion error
110+
bundle = Bundle.from_json(gh_signed_bundle_path.read_bytes())
111+
bundle._inner.dsse_envelope.signatures.append(bundle._inner.dsse_envelope.signatures[0])
112+
return bundle
113+
114+
monkeypatch.setattr(sigstore.sign.Signer, "sign_dsse", get_bundle)
115+
116+
sign_ctx = SigningContext.staging()
117+
118+
with pytest.raises(impl.AttestationError):
119+
with sign_ctx.signer(id_token) as signer:
120+
impl.Attestation.sign(signer, artifact_path)
121+
60122
def test_verify_github_attested(self) -> None:
61123
verifier = Verifier.production()
62124
pol = policy.AllOf(

0 commit comments

Comments
 (0)