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
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.