Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Store attestations for PEP740 #16302

Merged
merged 41 commits into from
Aug 21, 2024
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
ab7b15a
Store and retrieve attestations
DarkaMaul Jul 18, 2024
1cf746d
Merge branch 'main' into dm/store-attestations
DarkaMaul Jul 18, 2024
cf1359f
Continue to work.
DarkaMaul Jul 18, 2024
ae262d4
Update attestation storage and retrieval
DarkaMaul Jul 18, 2024
e580a73
Update comments
DarkaMaul Jul 18, 2024
fdcd782
Fix import
DarkaMaul Jul 18, 2024
226fd0d
Please linter
DarkaMaul Jul 18, 2024
00ca535
Use the correct event to attach a publisher_url to a `File`
DarkaMaul Jul 18, 2024
600b398
Rename ReleaseFileAttestation to Attestation
DarkaMaul Jul 23, 2024
2bdedb5
Merge branch 'main' into dm/store-attestations
DarkaMaul Jul 23, 2024
4fd0143
Merge branch 'main' into dm/store-attestations
DarkaMaul Jul 25, 2024
9dd8a91
Update names
DarkaMaul Jul 25, 2024
bd042f2
Update table migration
DarkaMaul Jul 26, 2024
1d05571
Update metrics
DarkaMaul Jul 29, 2024
a55a00f
Update attestations storage
DarkaMaul Jul 30, 2024
1e65c5f
Merge branch 'main' into dm/store-attestations
DarkaMaul Aug 7, 2024
d9bd6a8
Update pypi-attestations and sigstore dependencies
DarkaMaul Aug 7, 2024
efefd50
Fix wrong merge.
DarkaMaul Aug 7, 2024
7f29774
Generate Provenance file on upload.
DarkaMaul Aug 8, 2024
2f31749
Generate and store provenance file during upload.
DarkaMaul Aug 9, 2024
fed1c40
Merge branch 'main' into dm/store-attestations
DarkaMaul Aug 12, 2024
0c96751
Improve tests
DarkaMaul Aug 12, 2024
ff338da
Fix test error
DarkaMaul Aug 12, 2024
284c488
Fix test error
DarkaMaul Aug 12, 2024
31f633b
Merge branch 'main' into dm/store-attestations
DarkaMaul Aug 13, 2024
16eb83a
Merge branch 'main' into dm/store-attestations
DarkaMaul Aug 13, 2024
a36bb1a
Merge branch 'main' into dm/store-attestations
DarkaMaul Aug 13, 2024
ba6752d
Fix merge error
DarkaMaul Aug 14, 2024
a26047d
Simplify legacy answer
DarkaMaul Aug 15, 2024
8b99fb7
Introduce AttestationsService
DarkaMaul Aug 15, 2024
342b8bf
Rename AttestationsService to ReleaseVerification service and integra…
DarkaMaul Aug 16, 2024
6e4c91e
Remove useless check
DarkaMaul Aug 16, 2024
80988d0
Integrate generate_and_store_provenance within persist_attestations
DarkaMaul Aug 16, 2024
7a10776
Merge branch 'main' into dm/store-attestations
DarkaMaul Aug 16, 2024
9914be2
Merge branch 'refs/heads/main' into dm/store-attestations
DarkaMaul Aug 16, 2024
eec0b46
Remove file.publisher_url which is no longer used.
DarkaMaul Aug 16, 2024
98d39c4
Rename ReleaseAttestationService to IntegrityService
DarkaMaul Aug 20, 2024
0a013a8
Merge branch 'main' into dm/store-attestations
di Aug 21, 2024
08a4d9d
Merge branch 'main' into dm/store-attestations
di Aug 21, 2024
8d00c87
Linting
di Aug 21, 2024
2ca1918
requirements: bump sigstore, pypi-attestations
woodruffw Aug 21, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions tests/common/db/attestation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import hashlib

import factory

from warehouse.attestations.models import ReleaseFileAttestation

from .base import WarehouseFactory


class ReleaseAttestationsFactory(WarehouseFactory):
DarkaMaul marked this conversation as resolved.
Show resolved Hide resolved
class Meta:
model = ReleaseFileAttestation

file = factory.SubFactory("tests.common.db.packaging.FileFactory")
attestation_file_sha256_digest = factory.LazyAttribute(
lambda o: hashlib.sha256(o.file.filename.encode("utf8")).hexdigest()
)
7 changes: 7 additions & 0 deletions tests/common/db/packaging.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
from warehouse.utils import readme

from .accounts import UserFactory
from .attestation import ReleaseAttestationsFactory
from .base import WarehouseFactory
from .observations import ObserverFactory

Expand Down Expand Up @@ -140,6 +141,12 @@ class Meta:
)
)

attestations = factory.RelatedFactoryList(
ReleaseAttestationsFactory,
factory_related_name="file",
size=1,
)


class FileEventFactory(WarehouseFactory):
class Meta:
Expand Down
11 changes: 11 additions & 0 deletions tests/unit/attestations/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
107 changes: 107 additions & 0 deletions tests/unit/attestations/test_core.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import pathlib

from pathlib import Path

import pretend

from pypi_attestations import Attestation, Envelope, VerificationMaterial

import warehouse.packaging

from tests.common.db.packaging import FileFactory
from warehouse.attestations._core import generate_provenance_file, get_provenance_digest
from warehouse.events.tags import EventTag
from warehouse.packaging import ISimpleStorage

from ...common.db.packaging import FileEventFactory


def test_get_provenance_digest_succeed(db_request, monkeypatch):
file = FileFactory.create()
FileEventFactory.create(
source=file,
tag=EventTag.Project.ReleaseAdd,
additional={"publisher_url": "fake-publisher-url"},
)

generate_provenance_file = pretend.call_recorder(
lambda request, publisher_url, file_: (Path("fake-path"), "deadbeef")
)
monkeypatch.setattr(
warehouse.attestations._core,
"generate_provenance_file",
generate_provenance_file,
)

hex_digest = get_provenance_digest(db_request, file)

assert hex_digest == "deadbeef"


def test_get_provenance_digest_fails_no_attestations(db_request, monkeypatch):
file = FileFactory.create()
monkeypatch.setattr(warehouse.packaging.models.File, "attestations", [])

provenance_hash = get_provenance_digest(db_request, file)
assert provenance_hash is None


def test_get_provenance_digest_fails_no_publisher_url(db_request, monkeypatch):
file = FileFactory.create()

provenance_hash = get_provenance_digest(db_request, file)
assert provenance_hash is None


def test_generate_provenance_file_succeed(db_request, monkeypatch):

def store_function(path, file_path, *, meta=None):
return f"https://files/attestations/{path}.provenance"

storage_service = pretend.stub(store=pretend.call_recorder(store_function))

db_request.find_service = pretend.call_recorder(
lambda svc, name=None, context=None: {
ISimpleStorage: storage_service,
}.get(svc)
)

publisher_url = "x-fake-publisher-url"
file = FileFactory.create()
FileEventFactory.create(
source=file,
tag=EventTag.Project.ReleaseAdd,
additional={"publisher_url": publisher_url},
)

attestation = Attestation(
version=1,
verification_material=VerificationMaterial(
certificate="somebase64string", transparency_entries=[dict()]
),
envelope=Envelope(
statement="somebase64string",
signature="somebase64string",
),
)

read_text = pretend.call_recorder(lambda _: attestation.model_dump_json())

monkeypatch.setattr(pathlib.Path, "read_text", read_text)

provenance_file_path, provenance_hash = generate_provenance_file(
db_request, publisher_url, file
)

assert provenance_hash is not None
80 changes: 80 additions & 0 deletions tests/unit/forklift/test_legacy.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
Project,
ProjectMacaroonWarningAssociation,
Release,
ReleaseFileAttestation,
Role,
)
from warehouse.packaging.tasks import sync_file_to_cache, update_bigquery_release_files
Expand Down Expand Up @@ -3808,6 +3809,85 @@ def failing_verify(_self, _verifier, _policy, _dist):
assert resp.status_code == 400
assert resp.status.startswith(expected_msg)

def test_upload_succeeds_upload_attestation(
self,
monkeypatch,
pyramid_config,
db_request,
metrics,
):

project = ProjectFactory.create()
version = "1.0"
publisher = GitHubPublisherFactory.create(projects=[project])
claims = {
"sha": "somesha",
"repository": f"{publisher.repository_owner}/{publisher.repository_name}",
"workflow": "workflow_name",
}
identity = PublisherTokenContext(publisher, SignedClaims(claims))
db_request.oidc_publisher = identity.publisher
db_request.oidc_claims = identity.claims

db_request.db.add(Classifier(classifier="Environment :: Other Environment"))
db_request.db.add(Classifier(classifier="Programming Language :: Python"))

filename = "{}-{}.tar.gz".format(project.name, "1.0")
attestation = Attestation(
version=1,
verification_material=VerificationMaterial(
certificate="somebase64string", transparency_entries=[dict()]
),
envelope=Envelope(
statement="somebase64string",
signature="somebase64string",
),
)

pyramid_config.testing_securitypolicy(identity=identity)
db_request.user = None
db_request.user_agent = "warehouse-tests/6.6.6"
db_request.POST = MultiDict(
{
"metadata_version": "1.2",
"name": project.name,
"attestations": f"[{attestation.model_dump_json()}]",
"version": version,
"summary": "This is my summary!",
"filetype": "sdist",
"md5_digest": _TAR_GZ_PKG_MD5,
"content": pretend.stub(
filename=filename,
file=io.BytesIO(_TAR_GZ_PKG_TESTDATA),
type="application/tar",
),
}
)

storage_service = pretend.stub(store=lambda path, filepath, meta: None)
db_request.find_service = lambda svc, name=None, context=None: {
IFileStorage: storage_service,
IMetricsService: metrics,
}.get(svc)

process_attestations = pretend.call_recorder(
lambda request, distribution: [attestation]
)

monkeypatch.setattr(legacy, "_process_attestations", process_attestations)

resp = legacy.file_upload(db_request)

assert resp.status_code == 200

attestations_db = (
db_request.db.query(ReleaseFileAttestation)
.join(ReleaseFileAttestation.file)
.filter(File.filename == filename)
.all()
)
assert len(attestations_db) == 1

@pytest.mark.parametrize(
"version, expected_version",
[
Expand Down
11 changes: 11 additions & 0 deletions warehouse/attestations/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
105 changes: 105 additions & 0 deletions warehouse/attestations/_core.py
DarkaMaul marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import hashlib
import tempfile

from pathlib import Path
from typing import Literal

from pydantic import BaseModel, TypeAdapter
from pypi_attestations import Attestation

from warehouse.packaging import File, ISimpleStorage


class Publisher(BaseModel):
kind: str
"""
The kind of Trusted Publisher.
"""

claims: object
"""
Claims specified by the publisher.
"""


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".
"""


def get_provenance_digest(request, file: File) -> str | None:
if not file.attestations:
return None

publisher_url = file.publisher_url
if not publisher_url:
return None # TODO(dm)

provenance_file_path, provenance_digest = generate_provenance_file(
request, publisher_url, file
)
return provenance_digest


def generate_provenance_file(
request, publisher_url: str, file: File
) -> tuple[Path, str]:

storage = request.find_service(ISimpleStorage)
publisher = Publisher(kind=publisher_url, claims={}) # TODO(dm)
DarkaMaul marked this conversation as resolved.
Show resolved Hide resolved

attestation_bundle = AttestationBundle(
publisher=publisher,
attestations=[
TypeAdapter(Attestation).validate_json(
Path(release_attestation.attestation_path).read_text()
)
for release_attestation in file.attestations
],
)

provenance = Provenance(version=1, attestation_bundles=[attestation_bundle])

provenance_file_path = Path(f"{file.path}.provenance")
with tempfile.NamedTemporaryFile() as f:
f.write(provenance.model_dump_json().encode("utf-8"))
f.flush()

storage.store(
provenance_file_path,
f.name,
)

file_digest = hashlib.file_digest(f, "sha256")

return provenance_file_path, file_digest.hexdigest()
Loading
Loading