Skip to content

Commit af6432f

Browse files
committed
Add support for uploading attestations in legacy API
1 parent c81fac9 commit af6432f

File tree

8 files changed

+612
-144
lines changed

8 files changed

+612
-144
lines changed

requirements/dev.txt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,6 @@ hupper>=1.9
33
pip-tools>=1.0
44
pyramid_debugtoolbar>=2.5
55
pip-api
6-
repository-service-tuf
6+
# TODO: re-add before merging, once repository-service-tuf releases a new version.
7+
# The current version pins tuf==3.1.0, which conflicts with our `sigstore` dependency (`tuf==4.0.0`)
8+
#repository-service-tuf

requirements/main.in

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ redis>=2.8.0,<6.0.0
6262
rfc3986
6363
sentry-sdk
6464
setuptools
65+
sigstore==3.0.0rc2
66+
pypi-attestation-models==0.0.1rc2
6567
sqlalchemy[asyncio]>=2.0,<3.0
6668
stdlib-list
6769
stripe

requirements/main.txt

Lines changed: 185 additions & 143 deletions
Large diffs are not rendered by default.

tests/unit/forklift/test_legacy.py

Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,14 @@
2323
import pretend
2424
import pytest
2525

26+
from pypi_attestation_models import (
27+
Attestation,
28+
InvalidAttestationError,
29+
VerificationError,
30+
VerificationMaterial,
31+
)
2632
from pyramid.httpexceptions import HTTPBadRequest, HTTPForbidden, HTTPTooManyRequests
33+
from sigstore.verify import Verifier
2734
from sqlalchemy import and_, exists
2835
from sqlalchemy.exc import IntegrityError
2936
from sqlalchemy.orm import joinedload
@@ -2385,6 +2392,82 @@ def test_upload_fails_without_oidc_publisher_permission(
23852392
"See /the/help/url/ for more information."
23862393
).format(project.name)
23872394

2395+
def test_upload_attestation_fails_without_oidc_publisher(
2396+
self,
2397+
monkeypatch,
2398+
pyramid_config,
2399+
db_request,
2400+
metrics,
2401+
project_service,
2402+
macaroon_service,
2403+
):
2404+
project = ProjectFactory.create()
2405+
owner = UserFactory.create()
2406+
maintainer = UserFactory.create()
2407+
RoleFactory.create(user=owner, project=project, role_name="Owner")
2408+
RoleFactory.create(user=maintainer, project=project, role_name="Maintainer")
2409+
2410+
EmailFactory.create(user=maintainer)
2411+
db_request.user = maintainer
2412+
raw_macaroon, macaroon = macaroon_service.create_macaroon(
2413+
"fake location",
2414+
"fake description",
2415+
[caveats.RequestUser(user_id=str(maintainer.id))],
2416+
user_id=maintainer.id,
2417+
)
2418+
identity = UserTokenContext(maintainer, macaroon)
2419+
2420+
filename = "{}-{}.tar.gz".format(project.name, "1.0")
2421+
attestation = Attestation(
2422+
version=1,
2423+
verification_material=VerificationMaterial(
2424+
certificate="some_cert", transparency_entries=[dict()]
2425+
),
2426+
message_signature="some_signature",
2427+
)
2428+
2429+
pyramid_config.testing_securitypolicy(identity=identity)
2430+
db_request.POST = MultiDict(
2431+
{
2432+
"metadata_version": "1.2",
2433+
"name": project.name,
2434+
"attestations": f"[{attestation.model_dump_json()}]",
2435+
"version": "1.0",
2436+
"filetype": "sdist",
2437+
"md5_digest": _TAR_GZ_PKG_MD5,
2438+
"content": pretend.stub(
2439+
filename=filename,
2440+
file=io.BytesIO(_TAR_GZ_PKG_TESTDATA),
2441+
type="application/tar",
2442+
),
2443+
}
2444+
)
2445+
2446+
storage_service = pretend.stub(store=lambda path, filepath, meta: None)
2447+
extract_http_macaroon = pretend.call_recorder(lambda r, _: raw_macaroon)
2448+
monkeypatch.setattr(
2449+
security_policy, "_extract_http_macaroon", extract_http_macaroon
2450+
)
2451+
2452+
db_request.find_service = lambda svc, name=None, context=None: {
2453+
IFileStorage: storage_service,
2454+
IMacaroonService: macaroon_service,
2455+
IMetricsService: metrics,
2456+
IProjectService: project_service,
2457+
}.get(svc)
2458+
db_request.user_agent = "warehouse-tests/6.6.6"
2459+
2460+
with pytest.raises(HTTPBadRequest) as excinfo:
2461+
legacy.file_upload(db_request)
2462+
2463+
resp = excinfo.value
2464+
2465+
assert resp.status_code == 400
2466+
assert resp.status == (
2467+
"400 Attestations are currently only supported when using Trusted "
2468+
"Publishing with GitHub Actions."
2469+
)
2470+
23882471
@pytest.mark.parametrize(
23892472
"plat",
23902473
[
@@ -3293,6 +3376,233 @@ def test_upload_succeeds_creates_release(
32933376
),
32943377
]
32953378

3379+
def test_upload_with_valid_attestation_succeeds(
3380+
self,
3381+
monkeypatch,
3382+
pyramid_config,
3383+
db_request,
3384+
metrics,
3385+
):
3386+
from warehouse.events.models import HasEvents
3387+
3388+
project = ProjectFactory.create()
3389+
version = "1.0"
3390+
publisher = GitHubPublisherFactory.create(projects=[project])
3391+
claims = {
3392+
"sha": "somesha",
3393+
"repository": f"{publisher.repository_owner}/{publisher.repository_name}",
3394+
"workflow": "workflow_name",
3395+
}
3396+
identity = PublisherTokenContext(publisher, SignedClaims(claims))
3397+
db_request.oidc_publisher = identity.publisher
3398+
db_request.oidc_claims = identity.claims
3399+
3400+
db_request.db.add(Classifier(classifier="Environment :: Other Environment"))
3401+
db_request.db.add(Classifier(classifier="Programming Language :: Python"))
3402+
3403+
filename = "{}-{}.tar.gz".format(project.name, "1.0")
3404+
attestation = Attestation(
3405+
version=1,
3406+
verification_material=VerificationMaterial(
3407+
certificate="somebase64string", transparency_entries=[dict()]
3408+
),
3409+
message_signature="somebase64string",
3410+
)
3411+
3412+
pyramid_config.testing_securitypolicy(identity=identity)
3413+
db_request.user = None
3414+
db_request.user_agent = "warehouse-tests/6.6.6"
3415+
db_request.POST = MultiDict(
3416+
{
3417+
"metadata_version": "1.2",
3418+
"name": project.name,
3419+
"attestations": f"[{attestation.model_dump_json()}]",
3420+
"version": version,
3421+
"summary": "This is my summary!",
3422+
"filetype": "sdist",
3423+
"md5_digest": _TAR_GZ_PKG_MD5,
3424+
"content": pretend.stub(
3425+
filename=filename,
3426+
file=io.BytesIO(_TAR_GZ_PKG_TESTDATA),
3427+
type="application/tar",
3428+
),
3429+
}
3430+
)
3431+
3432+
storage_service = pretend.stub(store=lambda path, filepath, meta: None)
3433+
db_request.find_service = lambda svc, name=None, context=None: {
3434+
IFileStorage: storage_service,
3435+
IMetricsService: metrics,
3436+
}.get(svc)
3437+
3438+
record_event = pretend.call_recorder(
3439+
lambda self, *, tag, request=None, additional: None
3440+
)
3441+
monkeypatch.setattr(HasEvents, "record_event", record_event)
3442+
3443+
verify = pretend.call_recorder(lambda _self, _verifier, _policy, _dist: None)
3444+
monkeypatch.setattr(Attestation, "verify", verify)
3445+
monkeypatch.setattr(Verifier, "production", lambda: pretend.stub())
3446+
3447+
resp = legacy.file_upload(db_request)
3448+
3449+
assert resp.status_code == 200
3450+
3451+
assert len(verify.calls) == 1
3452+
3453+
def test_upload_with_malformed_attestation_fails(
3454+
self,
3455+
monkeypatch,
3456+
pyramid_config,
3457+
db_request,
3458+
metrics,
3459+
):
3460+
from warehouse.events.models import HasEvents
3461+
3462+
project = ProjectFactory.create()
3463+
version = "1.0"
3464+
publisher = GitHubPublisherFactory.create(projects=[project])
3465+
claims = {
3466+
"sha": "somesha",
3467+
"repository": f"{publisher.repository_owner}/{publisher.repository_name}",
3468+
"workflow": "workflow_name",
3469+
}
3470+
identity = PublisherTokenContext(publisher, SignedClaims(claims))
3471+
db_request.oidc_publisher = identity.publisher
3472+
db_request.oidc_claims = identity.claims
3473+
3474+
db_request.db.add(Classifier(classifier="Environment :: Other Environment"))
3475+
db_request.db.add(Classifier(classifier="Programming Language :: Python"))
3476+
3477+
filename = "{}-{}.tar.gz".format(project.name, "1.0")
3478+
3479+
pyramid_config.testing_securitypolicy(identity=identity)
3480+
db_request.user = None
3481+
db_request.user_agent = "warehouse-tests/6.6.6"
3482+
db_request.POST = MultiDict(
3483+
{
3484+
"metadata_version": "1.2",
3485+
"name": project.name,
3486+
"attestations": "[{'a_malformed_attestation': 3}]",
3487+
"version": version,
3488+
"summary": "This is my summary!",
3489+
"filetype": "sdist",
3490+
"md5_digest": _TAR_GZ_PKG_MD5,
3491+
"content": pretend.stub(
3492+
filename=filename,
3493+
file=io.BytesIO(_TAR_GZ_PKG_TESTDATA),
3494+
type="application/tar",
3495+
),
3496+
}
3497+
)
3498+
3499+
storage_service = pretend.stub(store=lambda path, filepath, meta: None)
3500+
db_request.find_service = lambda svc, name=None, context=None: {
3501+
IFileStorage: storage_service,
3502+
IMetricsService: metrics,
3503+
}.get(svc)
3504+
3505+
record_event = pretend.call_recorder(
3506+
lambda self, *, tag, request=None, additional: None
3507+
)
3508+
monkeypatch.setattr(HasEvents, "record_event", record_event)
3509+
3510+
def failing_verify(_self, _verifier, _policy, _dist):
3511+
raise InvalidAttestationError
3512+
3513+
monkeypatch.setattr(Attestation, "verify", failing_verify)
3514+
monkeypatch.setattr(Verifier, "production", lambda: pretend.stub())
3515+
3516+
with pytest.raises(HTTPBadRequest) as excinfo:
3517+
legacy.file_upload(db_request)
3518+
3519+
resp = excinfo.value
3520+
3521+
assert resp.status_code == 400
3522+
assert resp.status.startswith(
3523+
"400 Error while decoding the included attestation:"
3524+
)
3525+
3526+
def test_upload_with_failing_attestation_fails(
3527+
self,
3528+
monkeypatch,
3529+
pyramid_config,
3530+
db_request,
3531+
metrics,
3532+
):
3533+
from warehouse.events.models import HasEvents
3534+
3535+
project = ProjectFactory.create()
3536+
version = "1.0"
3537+
publisher = GitHubPublisherFactory.create(projects=[project])
3538+
claims = {
3539+
"sha": "somesha",
3540+
"repository": f"{publisher.repository_owner}/{publisher.repository_name}",
3541+
"workflow": "workflow_name",
3542+
}
3543+
identity = PublisherTokenContext(publisher, SignedClaims(claims))
3544+
db_request.oidc_publisher = identity.publisher
3545+
db_request.oidc_claims = identity.claims
3546+
3547+
db_request.db.add(Classifier(classifier="Environment :: Other Environment"))
3548+
db_request.db.add(Classifier(classifier="Programming Language :: Python"))
3549+
3550+
filename = "{}-{}.tar.gz".format(project.name, "1.0")
3551+
attestation = Attestation(
3552+
version=1,
3553+
verification_material=VerificationMaterial(
3554+
certificate="somebase64string", transparency_entries=[dict()]
3555+
),
3556+
message_signature="somebase64string",
3557+
)
3558+
3559+
pyramid_config.testing_securitypolicy(identity=identity)
3560+
db_request.user = None
3561+
db_request.user_agent = "warehouse-tests/6.6.6"
3562+
db_request.POST = MultiDict(
3563+
{
3564+
"metadata_version": "1.2",
3565+
"name": project.name,
3566+
"attestations": f"[{attestation.model_dump_json()}]",
3567+
"version": version,
3568+
"summary": "This is my summary!",
3569+
"filetype": "sdist",
3570+
"md5_digest": _TAR_GZ_PKG_MD5,
3571+
"content": pretend.stub(
3572+
filename=filename,
3573+
file=io.BytesIO(_TAR_GZ_PKG_TESTDATA),
3574+
type="application/tar",
3575+
),
3576+
}
3577+
)
3578+
3579+
storage_service = pretend.stub(store=lambda path, filepath, meta: None)
3580+
db_request.find_service = lambda svc, name=None, context=None: {
3581+
IFileStorage: storage_service,
3582+
IMetricsService: metrics,
3583+
}.get(svc)
3584+
3585+
record_event = pretend.call_recorder(
3586+
lambda self, *, tag, request=None, additional: None
3587+
)
3588+
monkeypatch.setattr(HasEvents, "record_event", record_event)
3589+
3590+
def failing_verify(_self, _verifier, _policy, _dist):
3591+
raise VerificationError("verification failed")
3592+
3593+
monkeypatch.setattr(Attestation, "verify", failing_verify)
3594+
monkeypatch.setattr(Verifier, "production", lambda: pretend.stub())
3595+
3596+
with pytest.raises(HTTPBadRequest) as excinfo:
3597+
legacy.file_upload(db_request)
3598+
3599+
resp = excinfo.value
3600+
3601+
assert resp.status_code == 400
3602+
assert resp.status.startswith(
3603+
"400 Could not verify the uploaded artifact using the included attestation"
3604+
)
3605+
32963606
@pytest.mark.parametrize(
32973607
"version, expected_version",
32983608
[

tests/unit/oidc/models/test_github.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
from tests.common.db.oidc import GitHubPublisherFactory, PendingGitHubPublisherFactory
1818
from warehouse.oidc import errors
19+
from warehouse.oidc.errors import InvalidPublisherError
1920
from warehouse.oidc.models import _core, github
2021

2122

@@ -470,6 +471,31 @@ def test_github_publisher_environment_claim(self, truth, claim, valid):
470471
check = github.GitHubPublisher.__optional_verifiable_claims__["environment"]
471472
assert check(truth, claim, pretend.stub()) is valid
472473

474+
@pytest.mark.parametrize(
475+
("ref", "sha", "raises"),
476+
[
477+
("ref", "sha", False),
478+
(None, "sha", False),
479+
("ref", None, False),
480+
(None, None, True),
481+
],
482+
)
483+
def test_github_publisher_verification_policy(self, ref, sha, raises):
484+
publisher = github.GitHubPublisher(
485+
repository_name="fakerepo",
486+
repository_owner="fakeowner",
487+
repository_owner_id="fakeid",
488+
workflow_filename="fakeworkflow.yml",
489+
environment="",
490+
)
491+
claims = {"ref": ref, "sha": sha}
492+
493+
if not raises:
494+
publisher.publisher_verification_policy(claims)
495+
else:
496+
with pytest.raises(InvalidPublisherError):
497+
publisher.publisher_verification_policy(claims)
498+
473499
def test_github_publisher_duplicates_cant_be_created(self, db_request):
474500
publisher1 = github.GitHubPublisher(
475501
repository_name="repository_name",

0 commit comments

Comments
 (0)