Skip to content

[Security] Pin build and twine to exact versions in PyPI publish workflow #120

Description

@agaraschuk-sift

Summary

The .github/workflows/publishing2PyPI.yml workflow installs build and twine without pinning to exact versions. Because these tools run at release time and twine uploads directly to PyPI using a production API token, an unpinned install creates a supply chain attack window: a malicious version of either package could be published to PyPI, picked up automatically during the next SDK release, and used to exfiltrate the PYPI_API_TOKEN or inject malicious code into the sift-python package itself.

Since sift-python is a publicly distributed SDK installed by Sift customers in their production environments, a compromised release would affect every downstream consumer.

Current vulnerable lines:

# publishing2PyPI.yml:30
python -m pip install build           # ← unpinned

# publishing2PyPI.yml:37
python -m pip install --user --upgrade twine   # ← unpinned + --upgrade

Remediation

Step 1 — Determine current known-good versions

Run locally to get exact versions:

pip install build twine
pip show build twine
# note the Version: fields

As of mid-2025, the latest stable versions are:

  • build1.2.2.post1
  • twine6.1.0

Step 2 — Generate hashes for pinned deps

pip download build==1.2.2.post1 twine==6.1.0 --no-deps -d /tmp/pkg
sha256sum /tmp/pkg/*.whl /tmp/pkg/*.tar.gz

Or use pip-compile with hashes:

pip install pip-tools
echo -e "build==1.2.2.post1\ntwine==6.1.0" > publish-requirements.in
pip-compile --generate-hashes --output-file publish-requirements.txt publish-requirements.in

Step 3 — Create publish-requirements.txt

Commit a publish-requirements.txt at the repo root containing the pinned, hashed dependencies. Example structure (replace hashes with actual output from step 2):

# Generated by pip-compile. DO NOT EDIT MANUALLY.
build==1.2.2.post1 \
    --hash=sha256:<hash-of-wheel> \
    --hash=sha256:<hash-of-sdist>
twine==6.1.0 \
    --hash=sha256:<hash-of-wheel> \
    --hash=sha256:<hash-of-sdist>
# ...transitive deps with hashes...

Step 4 — Update publishing2PyPI.yml

Replace the two unpinned install steps:

# BEFORE
- name: Create distribution files
  run: |
    python -m pip install build
    python -m build

- name: Upload distribution files
  env:
    TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
    TWINE_USER: ${{ secrets.USER }}
  run: |
    python -m pip install --user --upgrade twine
    ls dist/ | xargs -I % python -m twine upload --repository pypi dist/%
# AFTER
- name: Install publish dependencies
  run: |
    python -m pip install --require-hashes -r publish-requirements.txt

- name: Create distribution files
  run: |
    python -m build

- name: Upload distribution files
  env:
    TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
    TWINE_USER: ${{ secrets.USER }}
  run: |
    ls dist/ | xargs -I % python -m twine upload --repository pypi dist/%

Step 5 — Pin the GitHub Actions themselves

The workflow also uses mutable action tags rather than commit SHAs:

# BEFORE
uses: actions/checkout@v3

# AFTER — pin to a specific commit SHA
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2

Find the current SHA for each action at https://github.com/actions/<name>/tags.


Additional hardening (recommended)

Use PyPI's Trusted Publishers (OIDC) — eliminates the long-lived PYPI_API_TOKEN entirely. With Trusted Publishers, PyPI issues a short-lived token scoped to this specific workflow run. No secret to steal means the twine supply chain attack has no useful credential to exfiltrate.

Setup: https://docs.pypi.org/trusted-publishers/adding-a-publisher/

The updated workflow would look like:

permissions:
  id-token: write  # required for OIDC
  contents: read

- name: Upload distribution files
  uses: pypa/gh-action-pypi-publish@release/v1
  # No TWINE_PASSWORD needed — OIDC token is issued automatically

Priority

🟠 P2 — This sprint. The publish workflow runs on every release. A compromised build or twine version published to PyPI would be picked up on the next sift-python release with no human intervention required, and would affect all customers who install the SDK.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions