Skip to content

Commit 784f446

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

File tree

8 files changed

+615
-144
lines changed

8 files changed

+615
-144
lines changed

requirements/dev.txt

+3-1
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

+2
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

+185-143
Large diffs are not rendered by default.

tests/unit/forklift/test_legacy.py

+313
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@
2323
import pretend
2424
import pytest
2525

26+
from pypi_attestation_models import Attestation, VerificationError, VerificationMaterial
2627
from pyramid.httpexceptions import HTTPBadRequest, HTTPForbidden, HTTPTooManyRequests
28+
from sigstore.verify import Verifier
2729
from sqlalchemy import and_, exists
2830
from sqlalchemy.exc import IntegrityError
2931
from sqlalchemy.orm import joinedload
@@ -2385,6 +2387,82 @@ def test_upload_fails_without_oidc_publisher_permission(
23852387
"See /the/help/url/ for more information."
23862388
).format(project.name)
23872389

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

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

tests/unit/oidc/models/test_github.py

+26
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)