> Audit the Deployed Set, Not the Lockfile
Scanning requirements.txt found nothing, because it could not even install. Scanning what the running container actually had installed found 117 CVEs across 15 packages — and the file being scanned was the wrong file anyway.
// TABLE_OF_CONTENTS
Audit the Deployed Set, Not the Lockfile¶
The obvious first move when auditing dependencies:
pip-audit -r requirements.txt
On this project it failed outright. pip-audit resolves a requirements file by
attempting installation into a temp environment, and psycopg2 needs pg_config
to build from source. No PostgreSQL headers on the machine, no audit.
The easy reaction is to install the build dependency and move on. That would have audited the wrong thing.
Two ways the file lies¶
1. The file is not what gets installed. The Dockerfile line was:
RUN pip install --no-cache-dir -r requirements_dev.txt
Not requirements.txt. Both files existed, both looked plausible, and the one
I would naturally have audited — and edited — had no effect on the image at all.
I only caught this because a rebuild produced identical package versions and I
checked pip freeze before swapping the live container.
2. A requirements file is a request, not a result. Ranges resolve to whatever was newest at build time. Transitive dependencies do not appear at all. An image built three months ago contains what the resolver picked then, which no file in the repo records.
Audit the artifact¶
# ask the running container what it actually has
docker exec mycms_web pip freeze > /tmp/deployed.txt
# --no-deps: these are resolved, pinned versions - do not re-resolve them
pip-audit -r /tmp/deployed.txt --no-deps
--no-deps is what makes this work. Every line is already an exact pin from a
real environment, so there is nothing to resolve and nothing to build. No
pg_config, no compiler, no network resolution.
Result on this site: 117 known CVEs across 15 packages. Django, Pillow, cryptography, urllib3, requests — the ordinary long tail of an image nobody had rebuilt in a while.
Verify the fix against the artifact too¶
After bumping pins, the same principle applies in reverse. Do not trust that the build did what you asked:
docker build -t mycms_web:candidate .
docker run --rm --entrypoint pip mycms_web:candidate freeze | grep -E '^(Django|Pillow)='
Check before swapping the live container. My first rebuild was a complete
no-op — I had edited requirements.txt, the image installs requirements_dev.txt,
and every version came back unchanged. Had I not checked the candidate image
first, I would have restarted the service, seen it come up healthy, and recorded
the CVEs as fixed.
The general principle¶
| Question | Wrong source | Right source |
|---|---|---|
| What versions am I running? | requirements.txt |
pip freeze in the container |
| Is DEBUG off? | docker-compose.yml |
settings.DEBUG in the process |
| Are security headers set? | nginx.conf |
curl -I against each path |
Config files record intent. Audit the artifact. Every finding in this audit came from the right-hand column; the left-hand column is what made each bug survive as long as it did.
Related: the typo that shipped DEBUG=True · nginx add_header does not inherit