Python Monthly Topics

GitHub Actionsでデジタル証明書付きPythonパッケージをリリースする方法

鈴木たかのり@takanoryです。今月の「Python Monthly Topics」では、Pythonのパッケージを公開するときに、デジタル証明書(Digital attestations)を用いてより安全に公開する方法について紹介します。

PEP 740の提案とその背景

この機能はPEP 740によって2024年1月に提案され、2024年7月に採択されました。

多くのPythonのパッケージはPyPI(the Python Package Index)で配布されています。このPEPの内容はパッケージにデジタル署名された証明書と、その証明書を検証するためのメタデータをアップロード、ダウンロードできるようにPyPIのAPIを変更するというものです。

なお、このPEPの採択によってPyPIへのアップロード時のデジタル証明書の添付や、pipコマンドでの証明書の検証が必須になるわけではないことに注意してください。

このPEPの提案の動機(Motivation)としては以下が述べられています。

  • Pythonパッケージには配布元などの情報がメタデータとして含まれているが、認証がされていない。暗号化されたデジタル証明書によって、Pythonパッケージが認証されたリポジトリから作成されたことを確認できるようにする。
  • Pythonパッケージを乗っ取ろうとする攻撃者にとっては、プライベートな署名情報にアクセスする必要があるため難易度が上がる。
  • 現在はPythonパッケージの検証手段はリリースファイルごとのPGP署名しかなく、インデックスページを証明する手段が存在しない。メタデータによってインデックスの証明書の有効性を確認できる。

なお、インデックス証明書の仕様は現在PyPAPython Packaging Authorityによってメンテナンスされています。詳細な仕様については以下のドキュメントを参照してください。

デジタル証明書付きパッケージの例

実際にデジタル証明書が付いているパッケージと付いていないパッケージの例を示します。以下は、筆者が開発しているsphinx-nekochanというライブラリで、Sphinxにネコチャン絵文字を挿入する機能を追加します。このライブラリでは0.1.5のパッケージでは証明書が付いておらず、0.2.0以降は証明書が付いています。具体的に見てみましょう。

sphinx-nekochan 0.1.5のファイル情報は以下のような内容です。ファイルに関する情報はハッシュ(File hashes)などがありますが、配布元などの情報はありません。

デジタル証明書がないsphinx-nekochan 0.1.5のファイル情報
デジタル証明書がないsphinx-nekochan 0.1.5のファイル情報

sphinx-nekochan 0.3.4のファイル情報ではファイルのメタデータ、ハッシュなどは変わらないですが、その下にProvenance(物の起源)という情報が増えています。Provenanceには以下のような情報が表示されています。

  • Publisher:どこからリリースされたか(ここではGitHub Action)
  • Attestations:証明書情報
  • Source repository:どのリポジトリのどのバージョンか、またリポジトリの所有者情報
  • Publication detail:リリース用のトークンはどこで生成された物か、実行されたワークフローのコード

このような情報を出力することによって、正規のリポジトリからリリースされたパッケージであるということを証明しています。また、画面の左側を見てみると、Verified Details(確認済みの詳細情報)のところに0.3.4ではRepositoryが追加されています。これはリポジトリがこのパッケージの提供元としてPyPIによって確認済みであるということを示しています。

デジタル証明書付きのsphinx-nekochan 0.3.4のファイル情報
デジタル証明書付きのsphinx-nekochan 0.3.4のファイル情報

本題とはそれますが、Sphinxについて本連載で以前紹介しているので興味のある方は参照してください。

PEP 740に対応したパッケージの一覧

「Are we PEP 740 yet? 🔏」というWebサイトで、著名なパッケージ(最もダウンロード数が多い360のパッケージ)がPEP 740に対応済みかがまとめられています。

Are we PEP 740 yet? 🔏
https://trailofbits.github.io/are-we-pep740-yet/

現在68/360のパッケージがPEP 740に対応済みのようで、まだまだ対応していないパッケージが多いことがわかります。各パッケージの色の意味は以下の通りです。

  • (19%):PEP 740に対応済み
  • 無色(31%):最新のパッケージは証明書が利用可能になる前にアップロードされた
  • 黄色(44%):証明書がアップロードされていない
  • マゼンタ(6%):証明書に対応していないリポジトリでホストされているもの(後述)

一覧を見てみると、著名なライブラリでも対応していないものがたくさんあることがわかります。これからPEP 740に対応したパッケージが増えることを期待します。

Are we PEP 740 yet? 🔏
Are we PEP 740 yet? 🔏

デジタル証明書付きでパッケージをリリース

では実際にパッケージをデジタル証明書付きでリリースする方法について説明します。ソースコードのリポジトリにGitHubを使用している場合は、GitHub Actionsでリリースを行います。

1. PyPIのプロジェクトにTrusted Publisherを設定する

まずPyPI上のプロジェクトでTrusted Publisher(信頼できる配布元)としてGitHubのリポジトリを登録します。PyPIにログインし、プロジェクト一覧画面で設定対象となるプロジェクトの「Manage」ボタンをクリックします。

プロジェクトの「Manage」をクリック
プロジェクトの「Manage」をクリック

サイドメニューで「Publishing」を選択し、⁠Add a new publisher」に配布元の情報を登録します。ここでは以下の項目を設定します。

  • Owner:リポジトリの所有者であるGitHub OrganizationまたはGitHub username
  • Repository name:リポジトリの名前
  • Workflow name:リリースワークフローのファイル名(このあと作成します)
  • Environment name:GitHub Actionsの環境名(オプション)
新規Publisherを追加
新規Publisherを追加

ここで「Add」ボタンをクリックしてTrusted Publisherの登録は完了です。⁠Trusted Publisher Management」に先ほど入力したPublisherが追加されています。

Trusted Publisher ManagementにPublisherが追加された
Trusted Publisher ManagementにPublisherが追加された

なお、執筆時点ではPyPIではTrusted PublisherとしてGitHub以外にGoogle Cloud、ActiveState、GitLab CI/CDに対応しています。GitHub Actions以外を使用する場合の設定方法については、以下のドキュメントを参照してください。

2. GitHub Actionsにワークフローを設定する

次に先ほどTrusted Publisherとして設定したGitHub Actionsを作成します。先ほどの設定画面では以下の値を入力していました。

  • Ownertakanory
  • Repository namesphinx-nekochan
  • Workflow nameworkflow.yml

この場合はhttps://github.com/takanory/sphinx-nekochanリポジトリの.github/workflows/ディレクトリにworkflow.ymlという名前で、ワークフロー用のファイルを作成します。コードの内容は以下で参照できます。

上記のコードは、以下のページで提供されているコードを元にしています。コメントを追加して解説します。

パッケージのビルド

GitHubにpushすると、GitHub Actionが実行されます。最初のbuildジョブでbuildライブラリをインストールし、このライブラリを使用してパッケージをビルドします。

workflow.ymlのbuild部分
name: build

on: push  # pushすると実行される

jobs:
  build:
    name: Build distribution 📦
    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v4
      with:
        persist-credentials: false
    - name: Set up Python
      uses: actions/setup-python@v5
      with:
        python-version: "3.x"
    - name: Install pypa/build  # build用のライブラリをインストール
      run: >-
        python3 -m
        pip install
        build
        --user
    - name: Build a binary wheel and a source tarball  # パッケージをビルド
      run: python3 -m build
    - name: Store the distribution packages  # ビルドしたパッケージを保存
      uses: actions/upload-artifact@v4
      with:
        name: python-package-distributions
        path: dist/

パッケージのリリース

次のジョブpublish-to-pypiでは、先ほど作成したパッケージをPyPIにリリースします。最後に実行しているpypa/gh-action-pypi-publishのGithub Actionによって、デジタル証明書付きでパッケージがリリースされます。

PyPA(Python Packaging Authority)という、Pythonのパッケージ作成に関するワーキンググループが作成したGitHub Actionを使用することにより、簡単にデジタル証明書付きでのパッケージリリースが可能となっています。

workflow.ymlのpublish-to-pypi部分
  publish-to-pypi:
    name: Publish Python 🐍 distribution 📦 to PyPI
    if: startsWith(github.ref, 'refs/tags/')  # タグがプッシュされたときのみリリースを行う
    needs:
    - build
    runs-on: ubuntu-latest
    environment:
      name: pypi  # PyPIで設定したEnvironment nameと合わせる
      url: https://pypi.org/p/sphinx-nekochan  # 自分のパッケージのURLを設定
    permissions:
      id-token: write  # OpenID Connectトークンを取得し、信頼された公開処理とするために必要な設定
    steps:
    - name: Download all the dists  # さきほど保存したパッケージをダウンロード
      uses: actions/download-artifact@v4
      with:
        name: python-package-distributions
        path: dist/
    - name: Publish distribution 📦 to PyPI  # パッケージをPyPIにリリース
      uses: pypa/gh-action-pypi-publish@release/v1

GitHubリリースの作成

最後のジョブgithub-releaseGitHubのリリースを作成します。リリースにはSigstoreの署名が含まれます。

  github-release:
    name: Sign the Python 🐍 distribution 📦 with Sigstore and upload them to GitHub Release
    needs:
    - publish-to-pypi
    runs-on: ubuntu-latest

    permissions:
      contents: write  # GitHubリリースの作成に必要
      id-token: write  # sigstoreに必要

    steps:
    - name: Download all the dists
      uses: actions/download-artifact@v4
      with:
        name: python-package-distributions
        path: dist/
    - name: Sign the dists with Sigstore  # Sigstoreでパッケージに署名をする
      uses: sigstore/gh-action-sigstore-python@v3.0.0
      with:
        inputs: >-
          ./dist/*.tar.gz
          ./dist/*.whl
    - name: Create GitHub Release  # GitHubリリースを作成
      env:
        GITHUB_TOKEN: ${{ github.token }}
      run: >-
        gh release create
        "$GITHUB_REF_NAME"
        --repo "$GITHUB_REPOSITORY"
        --notes ""
    - name: Upload artifact signatures to GitHub Release  # パッケージと署名をアップロード
      env:
        GITHUB_TOKEN: ${{ github.token }}
      # Upload to GitHub Release using the `gh` CLI.
      # `dist/` contains the built packages, and the
      # sigstore-produced signatures and certificates.
      run: >-
        gh release upload
        "$GITHUB_REF_NAME" dist/**
        --repo "$GITHUB_REPOSITORY"

GitHub以外でのリリース方法

GitHub Actions以外を使用したデジタル証明書付きのパッケージリリース方法については、以下のドキュメントを参照してください。Google Cloud、ActiveState、GitLab CI/CDでのリリース方法が記述してあります。

gh-action-pypi-publishの解説

先述のとおり、デジタル証明書付きのパッケージのリリースはpypa/gh-action-pypi-publishというGitHub Actionによって行われます。ここでは、このGitHub Actionの中でどういった処理が行われているかを解説します。ソースコードは以下のリポジトリで管理されています。

以下で主要なファイルについて説明します。

DockerfileDockerの設定ファイル

設定に従ってDockerイメージが作成されます。

Pythonの環境を構築したあとに各種スクリプトがDocker環境にコピーされ、twine-upload.shが実行されます。

twine-upload.sh処理全体のスクリプト

メインの処理となるスクリプトで、デジタル証明書の作成、PyPIへのアップロードなどを行います。

このコードの中でoidc-exchange.pyスクリプトでPyPIにアップロードするためのトークンを取得し、INPUT_PASSWORD環境変数に保存します。

次にattestations.pyスクリプトでデジタル証明書を作成します。最後に、twine uploadコマンドの引数に--attestationsを追加することで、PyPIにデジタル証明書付きでパッケージをアップロードしています。このときにINPUT_PASSWORD環境変数に保存された一時的なトークンを使用することで、安全にパッケージのアップロードが行われます。

twine-upload.shの一部コードに日本語コメントを付加
if "${TRUSTED_PUBLISHING}" ; then
    # PyPIのトークンを取得してINPUT_PASSWORDに設定する
    INPUT_PASSWORD="$(python /app/oidc-exchange.py)"

if [[ ${INPUT_ATTESTATIONS,,} != "false" ]] ; then
    # デジタル証明書を生成してアップロード
    python /app/attestations.py "${INPUT_PACKAGES_DIR%%/}"
    # --attestations引数を追加
    TWINE_EXTRA_ARGS="--attestations $TWINE_EXTRA_ARGS"

# twine uploadコマンドでPythonパッケージとデジタル証明書をアップロード
exec twine upload ${TWINE_EXTRA_ARGS} ${INPUT_PACKAGES_DIR%%/}/*

oidc-exchange.pyOpenID ConnectでPyPIからトークンを取得

このPythonスクリプトではOIDC(OpenID Connect)を使用して、PyPIにアップロードするためのトークンを取得します。このトークンは毎回生成され、使用後に自動的に期限切れになります。

最初に、idモジュールのdetect_credential()関数により、PyPIとやりとりするためのトークンを取得してoidc_tokenに格納します。内部ではdetect_github()関数が呼ばれており、GitHubのOIDCプロバイダーである$ACTIONS_ID_TOKEN_REQUEST_URLにアクセスしてトークンを取得しています。

次に、PyPIのトークン交換用URLtoken_exchange_urlにトークンoidc_tokenを渡します。PyPIでは受け取ったトークンを検証し、結果を返却します。その返却された値をmint_token_respに代入します。

mint_token_respはJSON形式なのでPythonオブジェクトに変換し、その中にあるtokenキーの値を取得してpypi_tokenに代入します。この値がPyPIにアクセスするための一時的なトークンとなります。

oidc-exchange.pyの一部コードに日本語コメントを付加
try:
    # GitHubのOIDCトークンを取得
    oidc_token = id.detect_credential(audience=oidc_audience)
...

# PyPIのトークン交換用URLにアクセス
mint_token_resp = requests.post(
    token_exchange_url,
    json={'token': oidc_token},
    timeout=5,  # S113 wants a timeout
)

try:
    # レスポンスのJSONを変換
    mint_token_payload = mint_token_resp.json()
...

# PyPIにアップロードするためのトークンを取得
pypi_token = mint_token_payload.get('token')
# PyPIトークンを出力してtwine-upload.shに渡す
print(pypi_token)

attestations.pyデジタル証明書を作成

デジタル証明書ファイルを作成するPythonスクリプトです。

main()関数の中でattest_dist()関数を呼び出して、デジタル証明書ファイルを作成しています。

attest_dist()関数では、pypi-attestationsモジュールのDistribution.from_file()メソッドでファイルからPythonパッケージのディストリビューションを表すインスタンスを生成して、distに代入します。そして、Attestation.sign()メソッドでデジタル証明書を作成し、attestation_pathにJSON形式で書き出します。

このattestation_pathに保存されたJSON形式のファイルがtwine uploadコマンドでパッケージと一緒にアップロードされることで、デジタル証明書付きのリリースとなります。

attestations.pyの一部コードに日本語コメントを付加
from pypi_attestations import Attestation, Distribution


def attest_dist(
        dist_path: Path,
        attestation_path: Path,
        signer: Signer,
) -> None:
    # Pythonディストリビューションファイルのインスタンスを生成
    dist = Distribution.from_file(dist_path)
    # デジタル証明書を作成
    attestation = Attestation.sign(signer, dist)

    attestation_path.write_text(attestation.model_dump_json(), encoding='utf-8')


def main() -> None:
    # attest_dict()関数でデジタル証明書を作成
    with SigningContext.production().signer(identity, cache=True) as signer:
        for dist_path, attestation_path in dist_to_attestation_map.items():
            attest_dist(dist_path, attestation_path, signer)	

まとめ

今回はPEP 740により、デジタル証明書付きでPyPIにパッケージをリリースするという提案と、GitHub Actionsを使用してリリースする方法について紹介しました。デジタル証明書付きのリリースが一般的になれば、より安全にPyPIにアップロードされているパッケージが利用できるようになると思われます。

後半ではgh-action-pypi-publishの中で具体的にどのような処理が行われているかを解説し、トークンを交換する手順やデジタル証明書の作成手順について説明しました。

自身でPythonパッケージをメンテナンスしている方は、ぜひデジタル証明書付きでのリリースに挑戦してみてください。

参考資料

おすすめ記事

記事・ニュース一覧