Dependency hygiene
Lockfiles, pip-audit, and minimal requirements.
When you run pip install requests, you did not order one package. You ordered a pallet. The requests library (a popular Python tool for making web requests) needs other packages to run, those packages need still others, and pip (Python's package installer) fetches the whole stack from PyPI (the Python Package Index, a public warehouse where anyone with an account can put a package on the shelf). Dependency hygiene is the habit of knowing exactly which boxes showed up on your loading dock, checking that the tamper seals are intact, and reading the recall notices before any of it touches production.
Four words carry this lesson. A direct dependency is a package your code imports by name. A transitive dependency is a package pulled in by your dependencies, usually most of what lands on the dock, and invisible unless you go looking. A lockfile is the packing list that records the exact version and a checksum (a short fingerprint computed from the file's exact bytes) for every package in the full tree. A CVE (Common Vulnerabilities and Exposures, the public catalog number given to a known security flaw) is how the industry names one specific hole so everyone points at the same one.
Why unpinned installs are an open door
pip install requests with no version pin tells the resolver one thing: give me the newest release that fits everything else, right now, on this machine. Run it today and again next week and you can get two different environments. Your laptop and your CI runner drift apart without anyone touching the code. That drift is the gap supply-chain attackers aim for, because "whatever is newest" means "whatever an attacker managed to publish most recently."
None of the real incidents needed you to make a typo. In 2022 the hijacked ctx package read your environment variables and shipped your AWS (Amazon Web Services) keys to an attacker's server. Later that same year, PyTorch's nightly build channel got hit by dependency confusion. Attackers uploaded a package named torchtriton to public PyPI, and because pip searches PyPI right alongside PyTorch's own index, the nightly installer pulled the impostor instead of the real internal build. In December 2024, attackers broke into the release pipeline of ultralytics (a computer-vision library with millions of downloads) and shipped a cryptominer inside an official version. Each victim ran an ordinary install and got owned.
Installing is not a passive copy. An sdist (source distribution, the raw package source) can run a build script at install time, historically setup.py, with your user's permissions, before you import a single line. A wheel (a prebuilt, zipped package that pip only has to unpack) runs no such script. Preferring wheels removes a whole category of install-time attack, which is why the CI config later says wheels only.
Compile a lockfile you can verify
The fix has two layers, like a recipe and a receipt. You declare what you want loosely in pyproject.toml (the standard file that describes a Python project), then compile that into a fully pinned lockfile with a hash for every artifact. uv (a fast package tool written in Rust that has largely replaced pip-tools) does it in one command. The older pip-compile from pip-tools does the same core job and also supports --generate-hashes, though its flags differ and the cross-platform --universal resolution is specific to uv.
[project]name = "log-triage"version = "0.1.0"requires-python = ">=3.11"dependencies = ["requests>=2.32", # the only package we actually import]
A hash here is a tamper seal. --generate-hashes writes down the SHA-256 (Secure Hash Algorithm, 256-bit, a fingerprint of the file's exact bytes) of every artifact at compile time. Later, --require-hashes makes pip download each file, recompute its fingerprint, and compare. A tampered mirror, a man-in-the-middle (an attacker sitting between you and the server), a compromised index, an artifact quietly swapped out: any of these changes the bytes, so the fingerprint will not match and pip aborts the entire install. It also fails closed. If even one package in the file carries no hash, pip refuses to install anything, so nothing unverified can ride along.
Audit the frozen tree
Pinning freezes the tree. Auditing tells you when the frozen tree has rotted. pip-audit, maintained by the PyPA (Python Packaging Authority, the group that runs pip and PyPI), checks every installed version against the PyPI advisory database (a public list of known-bad Python releases, also mirrored into OSV, the cross-ecosystem Open Source Vulnerabilities feed). Point it at the lockfile, not at your top-level declaration, or the transitive dependencies where most CVEs actually live never get scanned.
That last command writes an SBOM (Software Bill of Materials, a machine-readable list of every component and version you shipped). Keep those files. When the next Log4Shell-scale flaw lands (the December 2021 bug in a Java logging library that had defenders grepping across every system they owned), the question "are we affected, and where" becomes one search across your SBOM files instead of a week of archaeology.
One more trap, and it is a common one. Chaining package indexes with --extra-index-url quietly hands an attacker your build. pip treats every index you configure as equally trustworthy and installs the highest version it finds across all of them. So if your internal acme-utils lives on a private index and an attacker uploads acme-utils 99.0 to public PyPI, that one extra line loses the race for you. That is dependency confusion, the same class of attack that hit PyTorch. Point pip at a single --index-url backed by a proxying index (Artifactory or devpi mirroring PyPI behind your own gate), or use uv, whose default --index-strategy first-index refuses to fall through to a second index once it has found a package.
What this stack cannot catch
Be honest about the gaps. A vulnerability scanner only knows the vulnerabilities someone has already written up. Advisories can lag disclosure by days or weeks, and a freshly hijacked package is malicious, not formally "vulnerable," so pip-audit gives it a clean bill. Your defenses there are structural. Keep the dependency count small, because every package is a standing liability rather than a one-time cost. Prefer wheels. Add a cooling-off period: uv pip compile --exclude-newer 2026-07-01 resolves only to artifacts published before that date, so a package hijacked yesterday cannot enter today's lock.
Pinning has its own price. A frozen tree goes stale, and stale is eventually vulnerable. Pair the lockfile with automated update pull requests (Renovate or Dependabot open them for you) so upgrades are deliberate, reviewed, and re-audited instead of drifting in on their own. And remember that hashes are per-artifact: a lock compiled on Linux may omit the macOS wheels your teammates need. The --universal flag produces one platform-independent resolution with environment markers (small conditions like "only on Windows"), so a single lock covers every machine.
Make CI enforce it
Hygiene that depends on a person remembering is hygiene you do not have. Put it in CI (continuous integration, the automated pipeline that builds and tests every change). Install only from the lock, forbid sdists with --only-binary :all:, audit the result, and fail the build on any finding. Publish the SBOM as a build artifact so every green build leaves a receipt you can grep later.
jobs:build:runs-on: ubuntu-22.04steps:- uses: actions/checkout@v4- uses: actions/setup-python@v5with:python-version: "3.11"- name: Install exactly what the lock says (wheels only, hashes enforced)run: >python -m pip install--require-hashes --only-binary :all:-r requirements.lock- name: Install the auditor in its own isolated environmentrun: pipx install pip-audit- name: Fail on any vulnerable or unauditable packagerun: pip-audit -r requirements.lock --require-hashes --strict- name: Publish the SBOM as a build artifactrun: pip-audit -r requirements.lock -f cyclonedx-json -o sbom.json- uses: actions/upload-artifact@v4with:name: sbompath: sbom.json
acme-utils from your private index. A teammate adds --extra-index-url https://pypi.org/simple to unblock an unrelated install. What makes that one line dangerous?--require-hashes and stays on however many indexes you list. What changes here is which copy gets picked, not whether the bytes are checked.first-index strategy restores.python -m pip install --require-hashes --only-binary :all: -r requirements.lock. What does --only-binary :all: buy you that the hashes do not?requirements.lock to add one package and forget to paste its --hash line. CI then runs the install step with --require-hashes. What do you get?Turn this on something real today. Pick one untracked script your team leans on, the kind that lives in someone's home directory and breaks the week its author is on leave. Give it a pyproject.toml, compile a hashed lock, and add a pip-audit gate to its pipeline. You have converted tribal automation into an artifact your security program can vouch for, with a packing list, tamper seals, and a recall check on every single build.
Try this
Work through “Make CI enforce it” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.
Takeaway
The trap worth remembering here: a matching hash means unchanged, not safe. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.