Project Setup

Securing the Supply Chain of a Python CLI

Protect a Python CLI and its users: audit dependencies, pin with hashes, delay brand-new releases, publish with attestations and ship an SBOM.

Updated

A command-line tool runs with everything its user has: their shell environment, their cloud credentials, their SSH keys, their source code. In CI it often runs with deployment secrets. That makes a CLI an attractive target, and the easiest way to attack it is not through its own code but through the chain of things that produce and deliver it — the dependencies it pulls in, the build that packages it, the account that publishes it and the installer that fetches it. Malicious packages uploaded under near-identical names, compromised maintainer accounts publishing a poisoned release, a CI workflow that leaks its publishing token: each of these has happened to real Python projects.

This topic covers the defences that are practical for a small team maintaining a CLI. It sits in the Project Setup & Dependency Management section because most of the work happens in the project's configuration and CI, alongside CI/CD pipelines for Python CLIs and packaging Python CLIs for distribution.

What this topic covers The supply chain topic covers auditing dependencies, pinning with hashes, publishing attestations and generating an SBOM. What this topic covers A trustworthy release from lock file to user install Audit pip-audit in CI Hashes the right files only Attestations provable origin SBOM a published inventory each branch has its own in-depth guide A CLI runs with its users’ credentials, so its supply chain is part of their security.

TL;DR

  • Know what you ship. Lock every dependency, and generate a software bill of materials (SBOM) from the lock file for each release.
  • Scan for known vulnerabilities with pip-audit on every pull request and on a schedule, and decide in advance how you triage findings.
  • Pin with hashes where you install in CI and in any environment you control, so a replaced file on the index cannot slip in.
  • Do not adopt brand-new releases instantly. A short cooldown before upgrading dependencies avoids most malicious releases, which are typically caught within days.
  • Publish with trusted publishing, never a long-lived API token, from an isolated release job — and let PyPI attach attestations users can verify.
  • Give users a way to check what they installed: attestations for wheels, checksums and signatures for binaries.

Where the risks are

A CLI's supply chain has four links, and each has its own failure mode.

The four links of the chain A command line tool’s supply chain: dependencies are locked, built in CI, published to PyPI and installed by users, each with its own risk. The four links of the chain Dependencies CVEs, takeovers, typos Build untrusted CI input Publish stolen credentials Install ranges resolved fresh uv.lock wheel PyPI The publishing credential is the most valuable thing to steal — trusted publishing removes it.
  1. Dependencies. A package you depend on — or one of its dependencies — has a known vulnerability, is taken over by an attacker, or is impersonated by a typo-squatted name. Most CLIs pull in 20–80 transitive packages, few of which anyone on the team has looked at.
  2. Build. The wheel is built in CI. If the build job can be influenced by an untrusted pull request, or a build tool is compromised, the artefact differs from the source.
  3. Publish. Whoever holds the publishing credential can release anything under your name. A long-lived PyPI token in a CI secret, a maintainer's laptop or a password manager is the single most valuable thing to steal.
  4. Install. Users install from PyPI with pipx, uv or pip, which resolve your dependency ranges at install time — so users may get different, newer dependencies than you tested with.

The rest of this topic takes those links in order.

Knowing what you depend on

You cannot secure what you cannot list. A lock file — uv.lock or poetry.lock — records the exact version and hashes of every package in the resolved environment, including transitive ones. It is the foundation for everything else: vulnerability scans read it, SBOMs are generated from it, and CI installs from it. Locking and syncing CLI dependencies with uv covers how to keep it accurate.

Two habits keep the dependency list healthy. Review additions: a new dependency is a new party with commit access to your users' machines; check that the name is exactly right (typo-squats such as reqeusts exist), that the project is maintained, and that a standard-library or existing-dependency alternative does not already cover it. Prune regularly: tools such as deptry find declared dependencies that are no longer imported, as described in finding unused code and dependencies. Every package removed is a risk removed — and, as a bonus, faster startup, as reducing CLI dependency weight shows.

An SBOM turns that list into a standard document — CycloneDX or SPDX — that users and their security teams can feed into their own tooling. For a CLI distributed to companies, publishing one with each release answers the first question a security review asks. Generating an SBOM for a Python CLI shows how to produce one from uv.lock and attach it to releases.

Finding known vulnerabilities

Known vulnerabilities in dependencies are the most common supply-chain issue, and the cheapest to catch. pip-audit, maintained by the Python Packaging Authority, checks a set of requirements against the Python Packaging Advisory Database and OSV:

uv export --frozen --no-emit-project --format requirements.txt -o requirements.txt
uvx pip-audit -r requirements.txt --disable-pip

Exporting from the lock file and auditing with --disable-pip checks exactly what you ship, without resolving anything again. The command exits non-zero when it finds a vulnerability, which makes it a natural CI gate. Auditing dependencies with pip-audit covers running it on pull requests and on a schedule, reading its output, and the part that matters most in practice: triage.

Triage for an advisory A decision guide for handling a dependency vulnerability: upgrade when a fix exists, document an expiring exception when unreachable, act urgently when untrusted input is involved. Triage for an advisory Does the vulnerable code run in your CLI? Yes, and a fix exists Upgrade uv lock --upgrade-package No — unreachable path Exception reason + expiry date Yes, on untrusted input Urgent patch, replace or disable Decide the policy before the first alert, not during it.

Not every advisory affects every user of a package. A vulnerability in a web framework's request parser does not matter if your CLI only uses its template engine. Decide on a policy before the first alert: upgrade when a fix exists and the upgrade is safe, ignore with a written justification and an expiry date when the vulnerable code is unreachable, and treat anything in code that handles untrusted input — downloaded files, API responses, user-provided templates — as urgent.

Pinning, hashes and cooldowns

Version pins stop unexpected upgrades. Hashes go further: they stop a different file with the same version number from being installed. PyPI does not allow files to be replaced, but mirrors, caches and private indexes can be misconfigured or compromised, and a hash check turns any substitution into an install failure.

Where you control the installation — CI jobs, Docker images, a team's internal deployment — install from the lock file with hashes enforced. uv sync --locked does that by default from uv.lock; an exported requirements.txt with --hash lines enforces it for pip. Pinning dependencies with hashes walks through both, plus building a Docker image that can only contain locked packages.

How tightly each install is pinned Levels of dependency pinning from published version ranges to hash-locked installs, and where each applies. How tightly each install is pinned Version ranges users pyproject.toml metadata — what pipx and uv tool resolve for users Ranges + cooldown cautious users exclude-newer or --cooldown skips brand-new releases Exact pins CI uv.lock versions — what you test against Pins + hashes CI, images require-hashes: the exact files, or nothing Hashes belong wherever you control the install; ranges keep fixes flowing to users.

End users are a harder case. A CLI published on PyPI declares dependency ranges in pyproject.toml, and pipx or uv tool install resolve them fresh at install time. That is normally good — users get security fixes without you releasing — but it also means a malicious release of a dependency reaches users the moment it is published. Two mitigations are practical:

  • Cooldowns. Installers increasingly support ignoring releases newer than some age: uv's --exclude-newer (and its relative forms in recent versions) and pipx's --cooldown option. Recommend them in your install docs, and use the same idea for your own upgrades — let new dependency releases age a few days before your bot proposes them. Malicious releases are usually detected and removed quickly; a short delay avoids most of them.
  • Locked distributions. For users who need reproducibility, ship a fully pinned artefact — a standalone binary, a container image, or a published constraints file they can pass to the installer.

Building and publishing safely

The publishing credential is the crown jewel. Trusted publishing removes it: PyPI is configured to trust a specific GitHub Actions (or GitLab, Google Cloud, ActiveState) workflow in a specific repository, and the workflow exchanges a short-lived OIDC token for a single-use upload credential at release time. There is no secret to leak. Publishing to PyPI with trusted publishing sets it up.

The release workflow itself needs a few guard rails:

  • Separate build and publish jobs. Build the wheel and sdist in one job with no special permissions, upload them as artefacts, and publish from a second job that has id-token: write and nothing else. Code from the build cannot reach the publishing permission.
  • Protect the release environment. A GitHub environment with required reviewers means a tag push alone cannot publish.
  • Pin your actions to full commit SHAs, not moving tags such as @v4, so a compromised action release cannot change your pipeline silently.
  • Never run release jobs on pull requests from forks, and never check out untrusted code in a job that has secrets.
Isolating the publishing permission A release workflow split into an unprivileged build job and a protected publish job that alone holds the identity token permission. Isolating the publishing permission release.yml on tag v* contents: read build no secrets, no id-token artifact dist/ handed over publish id-token: write only Protected environment Actions pinned by SHA Never on fork PRs Code that runs during the build can never reach the permission that signs and uploads.

When you publish with trusted publishing through the PyPA publish action, PyPI also stores attestations for each file (PEP 740): signed statements, recorded in the Sigstore transparency log, that this exact file was built by that workflow in that repository. They are what make verification possible.

Letting users verify what they installed

Attestations are only useful if someone checks them. Tell users how, in your security or installation docs:

uvx pypi-attestations verify pypi \
  --repository https://github.com/acme/mytool \
  pypi:mytool-1.6.0-py3-none-any.whl

The command fetches the file and its attestation from PyPI and confirms both the signature and that the publisher was the named repository. Security-conscious organisations can run it before approving a version for internal use. For binaries you publish on GitHub releases, GitHub's own artifact attestations (gh attestation verify) and published SHA-256 checksums serve the same purpose. Publishing attestations and verifying releases covers both, end to end.

Keeping dependencies current

Security is not only about blocking bad releases but also adopting good ones. An automated updater — Dependabot or Renovate — that opens pull requests for lock-file updates, with your test suite and pip-audit running on each, keeps the gap between "fix released" and "fix shipped" short. Configure it with a short minimum release age (both tools support one) to get the cooldown benefit, group minor updates to reduce noise, and keep security updates ungrouped so they are not delayed. Automating releases from git tags then makes shipping the result cheap.

A starting workflow

Putting the pieces together does not take much configuration. This workflow audits the locked dependencies on every pull request that touches them, every week (new advisories appear for old versions), and on demand:

# .github/workflows/supply-chain.yml
name: supply-chain
on:
  pull_request:
    paths: ["pyproject.toml", "uv.lock"]
  schedule:
    - cron: "17 6 * * 1"          # Mondays, 06:17 UTC
  workflow_dispatch:

permissions:
  contents: read

jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@<commit-sha>          # v4
      - uses: astral-sh/setup-uv@<commit-sha>        # v6
      - name: Export the locked requirements
        run: uv export --frozen --no-emit-project --format requirements.txt -o requirements.txt
      - name: Audit
        run: uvx pip-audit -r requirements.txt --disable-pip --desc on
      - name: SBOM
        if: always()
        run: uv export --frozen --format cyclonedx1.5 -o sbom.cdx.json
      - uses: actions/upload-artifact@<commit-sha>   # v4
        if: always()
        with:
          name: sbom
          path: sbom.cdx.json

Replace each <commit-sha> with the full 40-character commit of the release you adopt — copy it from the action's release page — so every action is pinned to an immutable commit, with the human-readable version kept in a comment. Dependabot understands this format and will propose SHA updates with the new version in the comment. permissions: contents: read gives the job the least access that works; it needs no secrets at all. Pair this with the release workflow from publishing to PyPI with trusted publishing and a Dependabot or Renovate configuration with a minimum release age, and the four links of the chain each have a guard.

For local development, the same audit runs as a pre-commit hook on changes to uv.lock, so a vulnerable upgrade is flagged before it is even pushed. Keep it out of the per-commit path otherwise — it needs the network and takes a few seconds.

A one-page threat model

Writing down what you are defending against keeps the effort proportionate. For a typical open-source CLI, a short table in SECURITY.md is enough:

ThreatLikelihoodDefence in this topic
Known CVE in a dependencyHighpip-audit in CI and on a schedule
Malicious new release of a dependencyMediumCooldown before adopting updates
Typo-squatted dependency added by mistakeLow–mediumReview of every new dependency
Stolen publishing credentialMediumTrusted publishing, no tokens
Compromised CI actionLow–mediumActions pinned to SHAs, least permissions
Tampered downloadLowHashes, attestations, checksums

Revisit it when the tool's audience changes — a CLI that starts being used inside banks deserves a stricter column than a hobby project.

Common pitfalls

  • A long-lived PyPI token in CI secrets. Replace it with trusted publishing and delete the token.
  • Auditing the wrong thing. Running pip-audit against the current developer environment instead of the lock file checks what happens to be installed, not what you ship.
  • Ignoring advisories forever. An ignore without an expiry date becomes permanent by accident.
  • Unpinned actions in the release workflow. uses: some/action@main hands that repository control of your release.
  • Upgrading the moment a release appears. Immediate auto-merge of dependency updates is exactly the path a malicious release takes.
  • Assuming users get your lock file. They do not; package installs resolve ranges. Plan for that explicitly.

Key takeaways

  • A CLI inherits its users' privileges, so its supply chain is part of their security.
  • Lock dependencies, generate an SBOM per release, and audit the lock file with pip-audit in CI.
  • Enforce hashes wherever you install, and let new dependency releases age before adopting them.
  • Publish with trusted publishing from an isolated, protected job with pinned actions; PyPI will attach attestations.
  • Document how users verify releases, and keep dependencies current with an automated, cooldown-aware updater.

Frequently asked questions

Is all of this necessary for a small internal tool?

The cheap parts are: a lock file, pip-audit in CI, and no long-lived publishing tokens. Attestation verification and SBOMs matter most when other organisations install your tool or when the tool runs with privileged credentials.

Should a CLI pin exact dependency versions in pyproject.toml?

Generally no. Exact pins in published metadata stop users from receiving security fixes and cause conflicts when the CLI is installed alongside other packages. Use lower bounds (and upper bounds only for known incompatibilities) in metadata, and exact pins in the lock file you test and build with.

How do I report a vulnerability in my own CLI?

Publish a SECURITY.md with a private contact method — GitHub's private vulnerability reporting is the easiest — and use GitHub Security Advisories to coordinate a fix and request a CVE. Advisories you publish there flow into the databases pip-audit reads.

Do I need to sign my releases myself?

Not for packages on PyPI published with trusted publishing; the attestations are generated for you. For binaries and other artefacts you host elsewhere, publish checksums and use GitHub artifact attestations or Sigstore signing.

What about malicious code in my own repository?

Branch protection, required reviews, and signed commits for maintainers reduce the risk. The release-job separation above limits the damage a single compromised contributor can do, because publishing requires the protected environment.

Are build-time dependencies part of the supply chain too?

Yes. The build backend (hatchling, setuptools, uv_build) and any build plugins run code while your wheel is produced. Pin them with lower and upper bounds in [build-system] requires, build in an isolated environment (the default for uv build and python -m build), and treat a change of build backend with the same review as a new runtime dependency — see choosing a build backend.