Packaging & distribution: pinning, pex/shiv & containers
Hash-pinned deps, single-file zipapps, and distroless tool containers.
A tool that only runs on the author's laptop is a liability. The day you hand it to a teammate, drop it into a CI job (continuous integration, the automated pipeline that builds and tests your code on every change), or run it on a production server, every invisible thing your laptop happened to have becomes someone else's outage. A library that was already installed. A Python version that matched by luck. A dependency that resolved to a safe version last Tuesday and a backdoored one today.
Shipping a tool well is three separate jobs. Pin exactly what it depends on so the set of code never drifts. Package it so it installs the same way on every machine. Control the runtime it executes inside so nothing extra is lying around. The target you are aiming at is simple to state and hard to earn: the tool you tested is byte-for-byte the tool that runs, everywhere. Reproducibility is a security property here, not a nicety, because a defender can only reason about software whose exact contents are known.
Pin dependencies by hash, not by hope
A loose version range in a requirements file is like ordering "a bottle of red" instead of naming the vintage: you get whatever the shop has in stock the day you ask. Write click>=8 and two installs a week apart can pull different code. That gap is where a supply-chain attack lives. An attacker who compromises a package, or publishes a typosquat with a name one keystroke off yours, is counting on your install grabbing "whatever is newest" without checking what it actually got.
The fix has two halves. First, pin every dependency, direct and transitive (the dependencies of your dependencies), to one exact version. Second, record a cryptographic hash (a short fingerprint of the file's exact bytes, here a SHA-256, meaning Secure Hash Algorithm 256-bit) for each one, and make the installer refuse anything whose bytes do not match. pip-compile from pip-tools (or uv, or Poetry's lock) reads your high-level wishes and resolves them into a fully pinned, hashed lockfile. You commit the lockfile, and everyone installs from it.
# what you actually want, high levelclick>=8httpx>=0.27
Every package now names its version and carries one hash per acceptable artifact (usually the wheel, the prebuilt install format, and the sdist, the source archive). Install from that file with hash enforcement turned on, and pip becomes a bouncer at the door: it downloads each file, computes the SHA-256, and only lets it in if the fingerprint is on the list.
Now the defender's payoff. Suppose your mirror is compromised and it serves a malicious click that keeps the version string 8.1.7 but changes the code inside. The bytes are different, so the hash is different, so pip stops cold and tells you in blunt language that something is wrong.
That is the whole game. The hash covers the artifact itself, so it protects you even against a fully compromised index or a man-in-the-middle who can forge TLS (Transport Layer Security, the encryption on the connection). TLS protects the pipe; the hash protects the water. A defender watching CI sees this failure as a hard stop and a named package, which is a far better signal than a tool that quietly ran with swapped code.
One thing to know before you lean on this. --require-hashes is all or nothing. The moment you turn it on, every requirement, including every transitive dependency, must be pinned with == and carry at least one hash. A single line missing a hash aborts the whole install with "Hashes are required in --require-hashes mode, but they are missing from some requirements." There is no "mostly pinned." It also means you cannot hand-edit the lockfile to bump one package: change the version and the old hash stops matching, so you re-run pip-compile and let it regenerate the whole set together.
One file that just runs
Handing someone a project as a folder of loose files plus a README that says "make a virtualenv (a self-contained Python environment) and pip install" is like mailing flat-pack furniture with the screws sold separately. Something always goes missing. A zipapp fixes that. It is a plain ZIP archive of your code plus its dependencies, with a shebang line (the #! at the top that tells the shell which interpreter to run) stapled to the front. Python has been able to run a ZIP that holds a __main__.py as if it were a script since version 2.6; the zipapp module and the .pyz naming convention were standardized later in PEP 441 (Python Enhancement Proposal 441, shipped with Python 3.5). So the whole thing runs as one file.
The standard library's own zipapp module bundles only the Python you wrote. pex and shiv go further: they resolve your third-party dependencies and pack those in too. shiv (built and open-sourced by LinkedIn) creates the archive and, on first run, unpacks it into a cache under ~/.shiv so imports are fast on every run after that. Point it at your project, name the console entry point, and name the output file.
pex (Python EXecutable, from the Pants build system) is the other common choice. It can build straight from your pinned requirements file, so the bundled contents are exactly the hashed set you approved, and it lets you fix the shebang so the file finds a Python the same way on every box.
Be clear about what a zipapp is not. It is not a static binary. It still needs a compatible Python interpreter present on the target box, and if you built with C extensions (compiled modules, native code a package ships instead of pure Python), the machine that runs it must match the one that built it closely enough for those to load. What it removes is the "did you install everything" failure mode, so distribution becomes a copy. One caveat worth knowing as an operator: shiv unpacks into a cache directory, so treat that path as executable code at rest and keep it out of world-writable locations, or an attacker who can write there can swap your tool's guts between runs.
Ship the runtime too, in a distroless box
For CI and servers, pinning and zipapps still lean on whatever Python and libraries happen to be on the host. A container removes that assumption by shipping the runtime with the tool. The move that matters for security is the base image you choose. A full operating system image is a fully stocked warehouse: shell, package manager, compilers, dozens of utilities. Every one of those is a tool an attacker inherits for free the moment they get code execution inside. A distroless image is the opposite: a small box holding your app, the language runtime it needs, and almost nothing else. No shell. No apt. No busybox.
Build in two stages. A fat builder installs and compiles everything, then a tiny final image copies only the finished artifact. The builder's mess never ships.
# ---- build stage: has pip, compilers, a shell ----FROM python:3.11-slim AS buildWORKDIR /appCOPY requirements.txt .RUN pip install --require-hashes --no-cache-dir --target=/deps -r requirements.txtCOPY . .# ---- final stage: no shell, no package manager, runs as nonroot ----FROM gcr.io/distroless/python3-debian12:nonrootWORKDIR /appCOPY --from=build /deps /depsCOPY --from=build /app /appENV PYTHONPATH=/depsUSER nonrootENTRYPOINT ["python3", "-m", "sec_tools.cli"]
Check what you actually shipped. The image is small, it runs your CLI (command-line interface), and this is where the design earns its keep: an attacker who lands remote code execution in this container has no /bin/sh to spawn, so the classic first move fails, and the process is not root, so it cannot rewrite system files or bind privileged ports.
The error is the good news. There is no shell to exec, so the container cannot hand an intruder one. The uid 65532 (the numeric user ID of the fixed non-root account baked into distroless nonroot images) confirms the process is unprivileged. Two smaller details are worth naming. distroless gives you a python3 command and no bare python, which is why the entrypoint spells out python3. And the builder, python:3.11-slim, is deliberately the same Python minor version as the distroless runtime: install your dependencies under one minor version and run them under another, and any package carrying a C extension can refuse to load.
Make these checks part of the pipeline instead of a one-time habit. Fail CI if the lockfile install is not run with --require-hashes. Add a test that asserts docker run --entrypoint sh on the shipped image fails and that the process uid is not 0. When those two assertions are green, you have proof (not a hope) that the tool your team reviewed is the exact tool that runs in production.
Try this
Work through “Ship the runtime too, in a distroless box” 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: curl | bash undoes all of this. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.