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.
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-auditon 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.
- 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.
- 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.
- 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.
- 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.
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.
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--cooldownoption. 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: writeand 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.
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:
| Threat | Likelihood | Defence in this topic |
|---|---|---|
| Known CVE in a dependency | High | pip-audit in CI and on a schedule |
| Malicious new release of a dependency | Medium | Cooldown before adopting updates |
| Typo-squatted dependency added by mistake | Low–medium | Review of every new dependency |
| Stolen publishing credential | Medium | Trusted publishing, no tokens |
| Compromised CI action | Low–medium | Actions pinned to SHAs, least permissions |
| Tampered download | Low | Hashes, 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-auditagainst 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@mainhands 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-auditin 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.
Related
- Up: Project Setup & Dependency Management
- Down: Auditing dependencies with pip-audit
- Down: Pinning dependencies with hashes
- Down: Publishing attestations and verifying releases
- Down: Generating an SBOM for a Python CLI
- Sideways: CI/CD pipelines for Python CLIs
- Sideways: Secrets and credentials in Python CLIs