Skip to content

Commit

Permalink
requirements: Add initial support for uploading PEP 740 attestations
Browse files Browse the repository at this point in the history
Signed-off-by: William Woodruff <[email protected]>
  • Loading branch information
woodruffw authored and facutuesca committed May 16, 2024
1 parent 699cd61 commit b526ff8
Show file tree
Hide file tree
Showing 7 changed files with 239 additions and 12 deletions.
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ COPY LICENSE.md .
COPY twine-upload.sh .
COPY print-hash.py .
COPY oidc-exchange.py .
COPY attestations.py .

RUN chmod +x twine-upload.sh
ENTRYPOINT ["/app/twine-upload.sh"]
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,25 @@ for example. See [Creating & using secrets]. While still secure,
[trusted publishing] is now encouraged over API tokens as a best practice
on supported platforms (like GitHub).

### Generating and uploading attestations (EXPERIMENTAL)

> [!NOTE]
> Support for generating and uploading [PEP 740 attestations] is currently
> experimental and limited only to Trusted Publishing flows using PyPI or TestPyPI.

You can generate signed [PEP 740 attestations] for all the distribution files and
upload them all together by enabling the `attestations` setting:

```yml
with:
attestations: true
```

This will use `sigstore` to create attestation objects for each distribution package,
signing them with the identity provided by the GitHub's OIDC token associated with the
current workflow. This means both the trusted publishing authentication and the
attestations are tied to the same identity.

## License

The Dockerfile and associated scripts and documentation in this project
Expand Down Expand Up @@ -287,3 +306,5 @@ https://github.com/vshymanskyy/StandWithUkraine/blob/main/docs/README.md
[configured on PyPI]: https://docs.pypi.org/trusted-publishers/adding-a-publisher/

[how to specify username and password]: #specifying-a-different-username

[PEP 740 attestations]: https://peps.python.org/pep-0740/
8 changes: 8 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,13 @@ inputs:
Use `print-hash` instead.
required: false
default: 'false'
attestations:
description: >-
[EXPERIMENTAL]
Enable experimental support for PEP 740 attestations.
Only works with PyPI and TestPyPI via Trusted Publishing.
required: false
default: 'false'
branding:
color: yellow
icon: upload-cloud
Expand All @@ -95,3 +102,4 @@ runs:
- ${{ inputs.skip-existing }}
- ${{ inputs.verbose }}
- ${{ inputs.print-hash }}
- ${{ inputs.attestations }}
102 changes: 102 additions & 0 deletions attestations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import logging
import os
import sys
from pathlib import Path
from typing import NoReturn

from pypi_attestation_models import AttestationPayload
from sigstore.oidc import IdentityError, IdentityToken, detect_credential
from sigstore.sign import Signer, SigningContext

# Be very verbose.
sigstore_logger = logging.getLogger("sigstore")
sigstore_logger.setLevel(logging.DEBUG)
sigstore_logger.addHandler(logging.StreamHandler())

_GITHUB_STEP_SUMMARY = Path(os.getenv("GITHUB_STEP_SUMMARY"))

# The top-level error message that gets rendered.
# This message wraps one of the other templates/messages defined below.
_ERROR_SUMMARY_MESSAGE = """
Attestation generation failure:
{message}
You're seeing this because the action attempted to generated PEP 740
attestations for its inputs, but failed to do so.
"""

# Rendered if OIDC identity token retrieval fails for any reason.
_TOKEN_RETRIEVAL_FAILED_MESSAGE = """
OpenID Connect token retrieval failed: {identity_error}
This generally indicates a workflow configuration error, such as insufficient
permissions. Make sure that your workflow has `id-token: write` configured
at the job level, e.g.:
```yaml
permissions:
id-token: write
```
Learn more at https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect#adding-permissions-settings.
"""


def die(msg: str) -> NoReturn:
with _GITHUB_STEP_SUMMARY.open("a", encoding="utf-8") as io:
print(_ERROR_SUMMARY_MESSAGE.format(message=msg), file=io)

# HACK: GitHub Actions' annotations don't work across multiple lines naively;
# translating `\n` into `%0A` (i.e., HTML percent-encoding) is known to work.
# See: https://github.com/actions/toolkit/issues/193
msg = msg.replace("\n", "%0A")
print(f"::error::Attestation generation failure: {msg}", file=sys.stderr)
sys.exit(1)


def debug(msg: str):
print(f"::debug::{msg}", file=sys.stderr)


# pylint: disable=redefined-outer-name
def attest_dist(dist: Path, signer: Signer) -> None:
# We are the publishing step, so there should be no pre-existing publish
# attestation. The presence of one indicates user confusion.
attestation_path = Path(f"{dist}.publish.attestation")
if attestation_path.is_file():
die(f"{dist} already has a publish attestation: {attestation_path}")

payload = AttestationPayload.from_dist(dist)
attestation = payload.sign(signer)

attestation_path.write_text(attestation.model_dump_json(), encoding="utf-8")
debug(f"saved publish attestation: {dist=} {attestation_path=}")


packages_dir = Path(sys.argv[1])

try:
# NOTE: audience is always sigstore.
oidc_token = detect_credential()
identity = IdentityToken(oidc_token)
except IdentityError as identity_error:
# NOTE: We only perform attestations in trusted publishing flows, so we
# don't need to re-check for the "PR from fork" error mode, only
# generic token retrieval errors.
cause = _TOKEN_RETRIEVAL_FAILED_MESSAGE.format(identity_error=identity_error)
die(cause)

# Collect all sdists and wheels.
dists = [sdist.absolute() for sdist in packages_dir.glob("*.tar.gz")]
dists.extend(whl.absolute() for whl in packages_dir.glob("*.whl"))

with SigningContext.production().signer(identity, cache=True) as signer:
for dist in dists:
# This should never really happen, but some versions of GitHub's
# download-artifact will create a subdirectory with the same name
# as the artifact being downloaded, e.g. `dist/foo.whl/foo.whl`.
if not dist.is_file():
die(f"Path looks like a distribution but is not a file: {dist}")

attest_dist(dist, signer)
7 changes: 6 additions & 1 deletion requirements/runtime.in
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
twine

# NOTE: Used to detect an ambient OIDC credential for OIDC publishing.
# NOTE: Used to detect an ambient OIDC credential for OIDC publishing,
# as well as PEP 740 attestations.
id ~= 1.0

# NOTE: This is pulled in transitively through `twine`, but we also declare
# NOTE: it explicitly here because `oidc-exchange.py` uses it.
# Ref: https://github.com/di/id
requests

# NOTE: Used to generate attestations.
pypi-attestation-models == 0.0.2
sigstore ~= 3.0.0
75 changes: 64 additions & 11 deletions requirements/runtime.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,41 @@
#
annotated-types==0.6.0
# via pydantic
betterproto==2.0.0b6
# via sigstore-protobuf-specs
certifi==2024.2.2
# via requests
cffi==1.16.0
# via cryptography
charset-normalizer==3.3.2
# via requests
cryptography==42.0.7
# via secretstorage
# via
# pyopenssl
# pypi-attestation-models
# sigstore
dnspython==2.6.1
# via email-validator
docutils==0.21.2
# via readme-renderer
email-validator==2.1.1
# via pydantic
grpclib==0.4.7
# via betterproto
h2==4.1.0
# via grpclib
hpack==4.0.0
# via h2
hyperframe==6.0.1
# via h2
id==1.4.0
# via -r runtime.in
# via
# -r runtime.in
# sigstore
idna==3.7
# via requests
# via
# email-validator
# requests
importlib-metadata==7.1.0
# via twine
jaraco-classes==3.4.0
Expand All @@ -28,10 +49,6 @@ jaraco-context==5.3.0
# via keyring
jaraco-functools==4.0.1
# via keyring
jeepney==0.8.0
# via
# keyring
# secretstorage
keyring==25.2.1
# via twine
markdown-it-py==3.0.0
Expand All @@ -42,36 +59,72 @@ more-itertools==10.2.0
# via
# jaraco-classes
# jaraco-functools
multidict==6.0.5
# via grpclib
nh3==0.2.17
# via readme-renderer
pkginfo==1.10.0
# via twine
platformdirs==4.2.2
# via sigstore
pyasn1==0.6.0
# via sigstore
pycparser==2.22
# via cffi
pydantic==2.7.1
# via id
# via
# id
# pypi-attestation-models
# sigstore
# sigstore-rekor-types
pydantic-core==2.18.2
# via pydantic
pygments==2.18.0
# via
# readme-renderer
# rich
pyjwt==2.8.0
# via sigstore
pyopenssl==24.1.0
# via sigstore
pypi-attestation-models==0.0.2
# via -r runtime.in
python-dateutil==2.9.0.post0
# via betterproto
readme-renderer==43.0
# via twine
requests==2.31.0
# via
# -r runtime.in
# id
# requests-toolbelt
# sigstore
# tuf
# twine
requests-toolbelt==1.0.0
# via twine
rfc3986==2.0.0
# via twine
rfc8785==0.1.2
# via sigstore
rich==13.7.1
# via twine
secretstorage==3.3.3
# via keyring
# via
# sigstore
# twine
securesystemslib==1.0.0
# via tuf
sigstore==3.0.0
# via
# -r runtime.in
# pypi-attestation-models
sigstore-protobuf-specs==0.3.2
# via sigstore
sigstore-rekor-types==0.0.13
# via sigstore
six==1.16.0
# via python-dateutil
tuf==5.0.0
# via sigstore
twine==5.1.0
# via -r runtime.in
typing-extensions==4.11.0
Expand Down
37 changes: 37 additions & 0 deletions twine-upload.sh
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ INPUT_PACKAGES_DIR="$(get-normalized-input 'packages-dir')"
INPUT_VERIFY_METADATA="$(get-normalized-input 'verify-metadata')"
INPUT_SKIP_EXISTING="$(get-normalized-input 'skip-existing')"
INPUT_PRINT_HASH="$(get-normalized-input 'print-hash')"
INPUT_ATTESTATIONS="$(get-normalized-input 'attestations')"

PASSWORD_DEPRECATION_NUDGE="::error title=Password-based uploads disabled::\
As of 2024, PyPI requires all users to enable Two-Factor \
Expand All @@ -53,6 +54,33 @@ environments like GitHub Actions without needing to use username/password \
combinations or API tokens to authenticate with PyPI. Read more: \
https://docs.pypi.org/trusted-publishers"

ATTESTATIONS_WITHOUT_TP_WARNING="::warning title=attestations setting ignored::\
The workflow was run with 'attestations: true', but an explicit password was \
also supplied, disabling Trusted Publishing. As a result, the attestations \
setting is ignored."

ATTESTATIONS_WRONG_INDEX_WARNING="::warning title=attestations setting ignored::\
The workflow was run with 'attestations: true', but the specified repository URL \
does not support PEP 740 attestations. As a result, the attestations setting \
is ignored."

if [[ "${INPUT_ATTESTATIONS}" != "false" ]] ; then
# Setting `attestations: true` and explicitly passing a password indicates
# user confusion, since attestations (currently) require Trusted Publishing.
if [[ -n "${INPUT_PASSWORD}" ]] ; then
echo "${ATTESTATIONS_WITHOUT_TP_WARNING}"
INPUT_ATTESTATIONS="false"
fi

# Setting `attestations: true` with an index other than PyPI or TestPyPI
# indicates user confusion, since attestations are not supported on other
# indices presently.
if [[ ! "${INPUT_REPOSITORY_URL}" =~ pypi\.org ]] ; then
echo "${ATTESTATIONS_WRONG_INDEX_WARNING}"
INPUT_ATTESTATIONS="false"
fi
fi

if [[ "${INPUT_USER}" == "__token__" && -z "${INPUT_PASSWORD}" ]] ; then
# No password supplied by the user implies that we're in the OIDC flow;
# retrieve the OIDC credential and exchange it for a PyPI API token.
Expand Down Expand Up @@ -130,6 +158,15 @@ if [[ ${INPUT_VERBOSE,,} != "false" ]] ; then
TWINE_EXTRA_ARGS="--verbose $TWINE_EXTRA_ARGS"
fi

if [[ ${INPUT_ATTESTATIONS,,} != "false" ]] ; then
# NOTE: Intentionally placed after `twine check`, to prevent attestation
# generation on distributions with invalid metadata.
echo "::debug::Generating and uploading PEP 740 attestations"
python /app/attestations.py "${INPUT_PACKAGES_DIR%%/}"

TWINE_EXTRA_ARGS="--attestations $TWINE_EXTRA_ARGS"
fi

if [[ ${INPUT_PRINT_HASH,,} != "false" || ${INPUT_VERBOSE,,} != "false" ]] ; then
python /app/print-hash.py ${INPUT_PACKAGES_DIR%%/}
fi
Expand Down

0 comments on commit b526ff8

Please sign in to comment.