CoursesAdvanced scripting for DevSecOpsPackaging & distribution: pinning, pex/shiv & containers

Packaging & distribution: pinning, pex/shiv & containers

Hash-pinned deps, single-file zipapps, and distroless tool containers.

Advanced35 min · lesson 13 of 15

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.

requirements.in
# what you actually want, high level
click>=8
httpx>=0.27
~/secopslog — bash
$ pip-compile --generate-hashes -o requirements.txt requirements.in head -n 20 requirements.txt
# # This file is autogenerated by pip-compile with Python 3.11 # by the following command: # # pip-compile --generate-hashes --output-file=requirements.txt requirements.in # anyio==4.4.0 \ --hash=sha256:... \ --hash=sha256:... # via httpx certifi==2024.7.4 \ --hash=sha256:... # via # httpcore # httpx click==8.1.7 \ --hash=sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28 \ --hash=sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de # via -r requirements.in

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.

~/secopslog — bash
$ pip install --require-hashes -r requirements.txt
Collecting click==8.1.7 (from -r requirements.txt (line 15)) Downloading click-8.1.7-py3-none-any.whl (97 kB) Collecting httpx==0.27.0 (from -r requirements.txt (line 30)) Downloading httpx-0.27.0-py3-none-any.whl (75 kB) Installing collected packages: sniffio, idna, h11, certifi, click, anyio, httpcore, httpx Successfully installed anyio-4.4.0 certifi-2024.7.4 click-8.1.7 h11-0.14.0 httpcore-1.0.5 httpx-0.27.0 idna-3.7 sniffio-1.3.1

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.

~/secopslog — bash
$ pip install --require-hashes -r requirements.txt
Collecting click==8.1.7 (from -r requirements.txt (line 15)) Downloading click-8.1.7-py3-none-any.whl (97 kB) 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. click==8.1.7 from https://pypi.mirror.internal/click-8.1.7-py3-none-any.whl (from -r requirements.txt (line 15)): Expected sha256 ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28 Got 7c1e9f04b0d2a6c3f8b1e0a95d24c7f0aa3b6e18d9c40f21ab7e55d0c6f1329a

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.

~/secopslog — bash
$ shiv -c sec-rotate -o sec-rotate.pyz . ls -lh sec-rotate.pyz ./sec-rotate.pyz --help
-rwxr-xr-x 1 you you 9.2M Jul 17 10:14 sec-rotate.pyz Usage: sec-rotate [OPTIONS] COMMAND [ARGS]... Rotate and audit service credentials. Options: --dry-run Show what would change without writing. --help Show this message and exit.

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.

~/secopslog — bash
$ pex . -r requirements.txt -c sec-rotate -o sec-rotate.pex --python-shebang='/usr/bin/env python3' ls -lh sec-rotate.pex
-rwxr-xr-x 1 you you 8.7M Jul 17 10:16 sec-rotate.pex

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.

Dockerfile
# ---- build stage: has pip, compilers, a shell ----
FROM python:3.11-slim AS build
WORKDIR /app
COPY requirements.txt .
RUN pip install --require-hashes --no-cache-dir --target=/deps -r requirements.txt
COPY . .
# ---- final stage: no shell, no package manager, runs as nonroot ----
FROM gcr.io/distroless/python3-debian12:nonroot
WORKDIR /app
COPY --from=build /deps /deps
COPY --from=build /app /app
ENV PYTHONPATH=/deps
USER nonroot
ENTRYPOINT ["python3", "-m", "sec_tools.cli"]
~/secopslog — bash
$ docker build -t sec-tools:1.0 .
[+] Building 12.3s (14/14) FINISHED => [internal] load build definition from Dockerfile => [internal] load metadata for gcr.io/distroless/python3-debian12:nonroot => [internal] load metadata for docker.io/library/python:3.11-slim => [build 3/5] COPY requirements.txt . => [build 4/5] RUN pip install --require-hashes --no-cache-dir --target=/deps -r requirements.txt => [build 5/5] COPY . . => [stage-1 3/4] COPY --from=build /deps /deps => [stage-1 4/4] COPY --from=build /app /app => exporting to image => => naming to docker.io/library/sec-tools:1.0

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.

~/secopslog — bash
$ docker images sec-tools:1.0 docker run --rm --entrypoint sh sec-tools:1.0 -c id docker run --rm --entrypoint python3 sec-tools:1.0 -c 'import os; print(os.getuid())'
REPOSITORY TAG IMAGE ID CREATED SIZE sec-tools 1.0 4b9f0c2e1a77 3 seconds ago 58.9MB docker: Error response from daemon: failed to create task for container: failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process: exec: "sh": executable file not found in $PATH: unknown. 65532

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.

Three delivery forms, each controlling more of the runtime
Hash-pinned lockfile
pip-compile --generate-hashes
exact versions + SHA-256 per artifact
install with --require-hashes
swapped bytes = hash mismatch = refused
best for
repos, shared dev, CI base
Single-file zipapp
shiv / pex build a .pyz/.pex
code + deps in one runnable file
still needs a Python interpreter
not a static binary
best for
handing a teammate one file
Distroless container
app + runtime, no shell/apt
tiny post-exploitation surface
runs as non-root uid 65532
cannot escalate or spawn a shell
best for
CI jobs and servers
The same hash-pinned lockfile feeds all three; pick by where the tool has to run.
curl | bash undoes all of this
Bootstrapping with curl https://example.com/install.sh | bash runs whatever the server decides to send at that exact moment, with no version, no hash, and no chance to read it first. A compromised host or a hijacked domain turns your setup step into remote code execution on every machine that installs. If you must use a bootstrap script, download it, pin it by hash or vendor it into your repo, read it, then run the copy you reviewed. The same rule covers an unpinned pip install of your own tool: pin and hash, or you are trusting the network on install day.
Quick check
01Your package index is fully compromised and serves a malicious click 8.1.7 (same version string, different code). You install with pip install --require-hashes -r requirements.txt. Why are you protected?
Incorrect — pip does not keep a secret backup index; there is no fallback. The protection is the hash check, not a second source.
Correct — The hash fingerprints the exact bytes of the artifact, so any change to the file fails the check regardless of the version label.
Incorrect — TLS protects the connection, not the file's contents. A compromised index serves bad bytes over perfectly valid TLS.
Incorrect — PyPI does not verify maintainer signatures when you install; it removed GPG (GNU Privacy Guard) signature support in 2023. --require-hashes checks the hash you recorded, not a signature.
02The lesson stresses that pip's --require-hashes mode is "all or nothing." What does that mean in practice?
Incorrect — every dependency, direct and transitive, must be pinned with == and carry a hash.
Incorrect — pip checks each artifact's hash as it downloads and refuses the mismatched file on the spot.
Correct — one unhashed line triggers "Hashes are required in --require-hashes mode," so there is no "mostly pinned."
Incorrect — there is no per-package opt-out; the requirement applies to the whole install at once.
03An attacker gains remote code execution inside your container built on gcr.io/distroless/python3-debian12:nonroot and tries their usual first move, spawning /bin/sh. Why does it fail, and what else limits them?
Incorrect — nothing runs, because the image ships no shell to execute in the first place.
Incorrect — distroless contains no /bin/sh at all, so this is "not found," not a permissions error.
Incorrect — Docker enforces no such policy; the protection comes from the image simply having no shell.
Correct — distroless nonroot ships no shell and runs as uid 65532, which is exactly why docker run --entrypoint sh fails with "sh not found" and getuid() returns 65532.

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.

Related