Dependency hygiene

Lockfiles, pip-audit, and minimal requirements.

Intermediate20 min · lesson 12 of 14

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.

pyproject.toml
[project]
name = "log-triage"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
"requests>=2.32", # the only package we actually import
]
~/secopslog — bash
$ # Read the loose declaration, resolve the full tree, pin and hash every artifact uv pip compile pyproject.toml -o requirements.lock --generate-hashes --universal
Resolved 5 packages in 218ms
$ head -n 12 requirements.lock
# This file was autogenerated by uv via the following command: # uv pip compile pyproject.toml -o requirements.lock --generate-hashes --universal certifi==2025.1.31 \ --hash=sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe \ --hash=sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651 # via requests charset-normalizer==3.4.2 \ --hash=sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63 \ --hash=sha256:8f2b5d1c0e8f3d4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9 # via requests idna==3.10 \ --hash=sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9 \

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.

~/secopslog — bash
$ python -m pip install --require-hashes -r requirements.lock
Collecting certifi==2025.1.31 (from -r requirements.lock (line 3)) Downloading certifi-2025.1.31-py3-none-any.whl (166 kB) Collecting requests==2.32.4 (from -r requirements.lock (line 52)) Downloading requests-2.32.4-py3-none-any.whl (64 kB) ... Successfully installed certifi-2025.1.31 charset-normalizer-3.4.2 idna-3.10 requests-2.32.4 urllib3-2.5.0
$ # Now imagine a mirror serves a requests wheel that differs by one byte python -m pip install --require-hashes -r requirements.lock
ERROR: THESE PACKAGES DO NOT MATCH THE HASHES FROM THE REQUIREMENTS FILE. If you have updated the package versions, please update the hashes. Otherwise, examine the package contents carefully; someone may have tampered with them. requests==2.32.4 from https://files.pythonhosted.org/packages/.../requests-2.32.4-py3-none-any.whl (from -r requirements.lock (line 52)): Expected sha256 6b8281f4d21b64f31e0b7ae8d3b6b7d27b71bd7f9a3f8e1a2b3c4d5e6f7a8b9c0 Got 9f41c672f1a2e8d9c33b9b3c31b6e0e2d5c4aa10b2c3d4e5f60718293a4b5c6d7
A matching hash means unchanged, not safe
Hash verification proves the file is byte-for-byte what you locked. It says nothing about whether that locked version was already malicious. If you compile a lock the day after a package is hijacked, you will faithfully pin, hash, and reinstall the malware on every build. Hashes stop tampering in transit; they do not vet the code you chose to trust.

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.

~/secopslog — bash
$ uv tool install pip-audit # or: pipx install pip-audit # Point it at an older project's lock to see a real finding pip-audit -r legacy.lock --require-hashes
Found 1 known vulnerability in 1 package Name Version ID Fix Versions ------- ------- ------------------- ------------ urllib3 2.2.1 GHSA-34jh-p97f-mpxf 2.2.2
$ # Export a machine-readable parts list for compliance and incident response pip-audit -r requirements.lock -f cyclonedx-json -o sbom.json
No known vulnerabilities found # sbom.json written: every component, version, and hash in CycloneDX format

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.

The dependency hygiene pipeline
1Declare
loose ranges in pyproject.toml
2Compile
pin every version, record SHA-256
3Install
wheels only, verify each hash
4Audit
match the tree against OSV / CVE feeds
5Ship SBOM
leave a signed packing list per build

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.

.github/workflows/ci.yml
jobs:
build:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
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 environment
run: pipx install pip-audit
- name: Fail on any vulnerable or unauditable package
run: pip-audit -r requirements.lock --require-hashes --strict
- name: Publish the SBOM as a build artifact
run: pip-audit -r requirements.lock -f cyclonedx-json -o sbom.json
- uses: actions/upload-artifact@v4
with:
name: sbom
path: sbom.json
Quick check
01Your build pulls the internal package 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?
Incorrect — Hash enforcement comes from --require-hashes and stays on however many indexes you list. What changes here is which copy gets picked, not whether the bytes are checked.
Incorrect — No source outranks another, and resolution does not stop at the first hit. If it did, this attack would fail, which is exactly what uv's first-index strategy restores.
Correct — Version number is the only comparison pip makes across indexes, and trust plays no part in it, so anyone who can publish a bigger number takes the slot.
Incorrect — Transport is unchanged by a second index. The impostor arrives over a perfectly valid TLS connection, which is part of why this is hard to notice in a build log.
02Your CI install step reads python -m pip install --require-hashes --only-binary :all: -r requirements.lock. What does --only-binary :all: buy you that the hashes do not?
Incorrect — Hashes are computed from whatever file pip downloads, in either format. The wheels-only rule removes the code that runs while a source distribution is built, not a verification gap.
Correct — That code runs before anything of yours imports the package, so it gets your runner's permissions for free. Refusing source distributions closes that window.
Incorrect — Neither format arrives signed by the index, so publisher identity is not what you gain. The gain is that unpacking a wheel executes nothing.
Incorrect — Skipping the toolchain is a build-time convenience. The security reason is the install-time code that a source distribution is allowed to run on its own behalf.
03You hand-edit 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?
Correct — Failing closed is the design: one gap means the whole set is unverified. The fix is to recompile the lock rather than patch it by hand.
Incorrect — There is no partial mode. Skipping the entry would leave the build missing a package it declared, and letting the rest through would still hide which one went unchecked.
Incorrect — The audit step is never reached, because the install has already exited non-zero. Downgrading to a warning would defeat the reason you asked for hashes.
Incorrect — A fingerprint taken from the file pip just fetched only proves the file matches itself. It tells you nothing about whether that file is the one you meant to trust.

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.

Related