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` only from your private index. A teammate adds `--extra-index-url https://pypi.org/simple` to fix an unrelated install. Why does that one flag expose you to dependency confusion?
Correct — Indexes are ranked by version, not by trust, so a higher public version beats your private one.
Incorrect — `--extra-index-url` does not touch hashing; `--require-hashes` still verifies every artifact.
Incorrect — There is no source-priority rule; pip picks the highest version across all indexes.
Incorrect — Multiple indexes do not change TLS behavior; the risk is version-based, not transport-based.
02The continuous integration (CI) job installs with `--only-binary :all:`, which forbids source distributions (sdists) and allows only wheels (prebuilt packages). Why is that a security win?
Correct — Forbidding sdists removes a whole class of install-time code execution, since building a source distribution can execute the package's own code.
Incorrect — Download size is not the point; the risk being removed is code execution at install time, not network exposure.
Incorrect — Neither format is automatically signed by PyPI; the difference is that installing an sdist can run a build step.
Incorrect — --require-hashes verifies both formats; the reason to prefer wheels is avoiding the build-time code that sdists can run.
03You hand-edit requirements.lock to add one new package but forget to include its --hash line, then run `pip install --require-hashes -r requirements.lock`. What happens?
Incorrect — pip does not partially proceed; --require-hashes fails closed so nothing unverified can ride along.
Incorrect — --require-hashes never installs an unhashed package and does not downgrade to a warning.
Correct — One missing hash aborts the entire install; the flag fails closed by design so unverified packages cannot sneak in.
Incorrect — pip will not invent a hash for you; a hash you never recorded proves nothing, so it aborts instead.

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.

Related