Packaging, pinning and auditing your tool

pyproject.toml, a wheel, a hash-locked install, pip-audit and pipx.

Intermediate35 min · lesson 15 of 16
Lesson files
The scripts, test data and local test servers this lesson uses, exactly as they ran on the lab machine (9 files, 6 KB): ps-package.tar.gz. Unpack it with tar -xzf ps-package.tar.gz, which creates ps-package/. SHA-256: 50556a1b0003bd3127acfddc609231a1f6cd31c0a43472f62c482de36c80216a

A tool that lives as a folder of scripts works on the author's machine. For a teammate or a server it needs a name, a version, a declared list of dependencies and a command to run. In this lesson you will turn hashcheck from the argparse lesson into a package: a src layout, a pyproject.toml, two commands installed as entry points, an editable install for development and a wheel for everyone else. Then you will lock every dependency to an exact version and a SHA-256 hash, watch a tampered download and a missing hash both stop the install, audit the lock for known vulnerabilities, and install the tool for end users with pipx.

From source to an installed tool
1Declare
pyproject.toml: name, version, dependency ranges
2Build
python -m build: a wheel (and an sdist)
3Lock
pip-compile: exact versions + SHA-256 of every file
4Verify and install
pip --require-hashes: all or nothing
5Audit
pip-audit: known advisories for the locked versions

A package: src layout and pyproject.toml

Unpack the lesson files (the box at the top of this page) in your home directory and work in ~/ps-package. They hold the project as this lesson starts it: the modules from the argparse lesson in src/hashcheck/ (hashcheck.py renamed to cli.py), a pyproject.toml, and that lesson's make_uploads.sh, which the first command below runs to build the sample uploads and the IOC list; a copy of the list goes into feed/, where a test server will publish it. src/hashcheck/ has an __init__.py (one docstring line), which makes it a package: modules that are imported together under one name. Inside a package, modules import each other relatively (from .cli_args import build_parser; the dot means "from this package"), which is the only change to their code, as grep shows. One new module gives the package a real dependency: hashcheck-fetch downloads an IOC list with requests, the library the HTTP lesson justified.

deploy@web01:~/ps-package · Ubuntu 26.04 LTS
$ bash make_uploads.sh > /dev/null mkdir -p feed && cp iocs.txt feed/ find src -type f | sort grep -n "^from \." src/hashcheck/*.py
src/hashcheck/__init__.py src/hashcheck/cli.py src/hashcheck/cli_args.py src/hashcheck/fetch.py src/hashcheck/hashing.py src/hashcheck/quarantine.py src/hashcheck/cli.py:11:from .cli_args import build_parser src/hashcheck/cli.py:12:from .hashing import STDIN, IocListError, digest, read_iocs src/hashcheck/cli.py:13:from .quarantine import QuarantineError, destination, prepare, quarantine src/hashcheck/fetch.py:15:from .hashing import IocListError, load_iocs
src/hashcheck/fetch.py
"""hashcheck-fetch: download an IOC list, refuse an unusable one, and replace the old file atomically.
Exit status: 0 saved; 2 usage error; 4 the download failed, or the list is unusable or cannot be
saved (the old file is kept).
"""
import argparse
import io
import os
import sys
import tempfile
from pathlib import Path
import requests
from .hashing import IocListError, load_iocs
MAX_BYTES = 10_000_000 # an IOC list larger than this is a mistake or an attack
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="hashcheck-fetch", allow_abbrev=False,
description="Download an IOC list for hashcheck.")
parser.add_argument("url", help="where the list is published (https://)")
parser.add_argument("--out", required=True, type=Path, metavar="FILE", help="where to save it")
parser.add_argument("--allow-http", action="store_true", help="accept http:// (a lab server)")
args = parser.parse_args(argv)
if not (args.url.startswith("https://") or args.allow_http):
parser.error("the URL must start with https://: this list decides what counts as malware")
try:
text = download(args.url)
iocs = load_iocs(io.StringIO(text), args.url) # check before saving
save(text, args.out)
except (requests.RequestException, UnicodeDecodeError, IocListError, OSError) as err:
print(f"hashcheck-fetch: {err}; {args.out} not changed", file=sys.stderr)
return 4
print(f"saved {len(iocs)} indicators to {args.out}")
return 0
def download(url: str) -> str:
with requests.get(url, timeout=(3.05, 10), stream=True) as response: # connect, read
response.raise_for_status()
body = b""
for chunk in response.iter_content(65536):
body += chunk
if len(body) > MAX_BYTES:
raise IocListError(f"{url}: more than {MAX_BYTES} bytes")
return body.decode("utf-8") # UTF-8 whatever the server claims; bad bytes are an error
def save(text: str, out: Path) -> None:
"""Readers see the old list or the new one, never half of each (the files lesson)."""
fd, tmp = tempfile.mkstemp(dir=out.parent, prefix=f".{out.name}.")
try:
with open(fd, "w", encoding="utf-8") as f:
f.write(text)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, out)
except BaseException:
os.unlink(tmp) # never leave the temporary file behind
raise

An IOC list decides what the scanner treats as malware, so the fetcher is strict about it. It accepts only https:// unless --allow-http says otherwise (for a local test server only), because anyone on the network path could replace a list fetched over plain HTTP with harmless hashes; signing the list itself is scripting-adv material. It reads the body in chunks up to a size cap, decodes it as UTF-8 itself (for a text/plain answer without a charset, requests would guess ISO-8859-1 and turn every non-ASCII label into garbage), and checks it with load_iocs() before saving. save() is the files lesson's pattern: a temporary file in the same directory, fsync(), then os.replace(), and the temporary file removed on any failure, so a failed or empty download never destroys a working list and a full disk is exit status 4, not a traceback. The package is described in pyproject.toml:

pyproject.toml
[build-system]
requires = ["hatchling==1.32.4"] # the tool that turns this directory into a wheel
build-backend = "hatchling.build"
[project]
name = "hashcheck"
version = "0.1.0"
description = "Find files whose SHA-256 is on an IOC list, and quarantine them."
requires-python = ">=3.14" # the version it is tested on
dependencies = [
"requests>=2.33,<3", # a range: the lock file pins the exact version
]
[project.scripts]
hashcheck = "hashcheck.cli:main"
hashcheck-fetch = "hashcheck.fetch:main"
[tool.hatch.build.targets.sdist]
include = ["/src"] # without this, the sdist takes almost every file in the directory

[build-system] names the build backend, the tool that turns the source into an installable file. This project uses hatchling, pinned to one version so every build uses the same tool. [project] is the package's metadata. dependencies is a range, >=2.33,<3: a package that pinned requests==2.34.2 could never be installed next to another tool that needs a different 2.x, and every security fix in requests would need a new release of hashcheck. Exact versions belong in a lock file, later in this lesson. The floor 2.33 is not arbitrary; the audit section shows why. [project.scripts] defines entry points: the command hashcheck calls the function main in the module hashcheck.cli. The last table is explained with the build.

deploy@web01:~/ps-package · Ubuntu 26.04 LTS
$ python3 -c "import hashcheck"
Traceback (most recent call last): File "<string>", line 1, in <module> import hashcheck ModuleNotFoundError: No module named 'hashcheck'

That failure is the point of the src layout. From the project directory, Python cannot import hashcheck by accident; tests and commands always use the installed package, so a broken install shows up on your machine, not a teammate's.

Editable install, entry points and a wheel

First a venv for development. Ubuntu's python3-venv puts pip 25.1.1 into a new venv, a year behind the current release, so the first step upgrades it to a pinned 26.2.1 and every platform in this lab behaves the same. The tool output in this lesson was captured without a terminal, as in a CI job, so you see no colours, spinners or progress bars; at a terminal you will see those.

deploy@web01:~/ps-package · Ubuntu 26.04 LTS
$ python3 -m venv .venv .venv/bin/python -m pip install -q pip==26.2.1 .venv/bin/python -m pip --version
pip 26.2.1 from /home/deploy/ps-package/.venv/lib/python3.14/site-packages/pip (python 3.14)
$ .venv/bin/python -m pip install -e .
Obtaining file:///home/deploy/ps-package Installing build dependencies: started Installing build dependencies: finished with status 'done' … Collecting requests<3,>=2.33 (from hashcheck==0.1.0) Downloading requests-2.34.2-py3-none-any.whl.metadata (4.8 kB) … Building editable for hashcheck (pyproject.toml): started Building editable for hashcheck (pyproject.toml): finished with status 'done' Created wheel for hashcheck: filename=hashcheck-0.1.0-py3-none-any.whl size=1319 sha256=707939d8015f2242b65a0bd8835afe0ae7c5082bf1e2304167d4a95242395673 … Installing collected packages: urllib3, idna, charset_normalizer, certifi, requests, hashcheck Successfully installed certifi-2026.7.22 charset_normalizer-3.5.1 hashcheck-0.1.0 idna-3.20 requests-2.34.2 urllib3-2.8.0

pip install -e . (editable) reads pyproject.toml, installs the pinned hatchling into a temporary build environment, resolves the requests range to 2.34.2 and its four dependencies, and installs hashcheck so that it points at src/: edits to the source take effect without reinstalling. For each entry point pip writes a small launcher into the venv's bin/:

deploy@web01:~/ps-package · Ubuntu 26.04 LTS
$ cat .venv/bin/hashcheck
#!/home/deploy/ps-package/.venv/bin/python import sys from hashcheck.cli import main if __name__ == '__main__': sys.argv[0] = sys.argv[0].removesuffix('.exe') sys.exit(main())
$ .venv/bin/hashcheck scan --iocs iocs.txt uploads/a/invoice.pdf; echo "exit status $?" .venv/bin/python -c "import hashcheck; print(hashcheck.__file__)"
uploads/a/invoice.pdf dropper-2026-091 exit status 1 /home/deploy/ps-package/src/hashcheck/__init__.py
$ python3 -m http.server 18150 --bind 127.0.0.1 --directory feed > feed.log 2>&1 & echo $! > feed.pid; sleep 1 .venv/bin/hashcheck-fetch http://127.0.0.1:18150/iocs.txt --out fetched.txt; echo "exit status $?" .venv/bin/hashcheck-fetch --allow-http http://127.0.0.1:18150/iocs.txt --out fetched.txt; echo "exit status $?" .venv/bin/hashcheck-fetch --allow-http http://127.0.0.1:18150/missing.txt --out fetched.txt; echo "exit status $?" kill "$(cat feed.pid)" && rm feed.pid
usage: hashcheck-fetch [-h] --out FILE [--allow-http] url hashcheck-fetch: error: the URL must start with https://: this list decides what counts as malware exit status 2 saved 3 indicators to fetched.txt exit status 0 hashcheck-fetch: 404 Client Error: File not found for url: http://127.0.0.1:18150/missing.txt; fetched.txt not changed exit status 4

The launcher's first line is the venv's own Python, so the command works without activating the venv, including from cron. The import path shows the editable install reading src/. hashcheck-fetch refused the plain http:// URL with a usage error, saved the list from the local test server with --allow-http, and for the missing file printed the requests error, kept the old file and exited 4, its documented status. The last line stopped the test server. The build tools go into a second venv, because build, pip-tools and pip-audit are not needed at run time:

deploy@web01:~/ps-package · Ubuntu 26.04 LTS
$ python3 -m venv .tools .tools/bin/python -m pip install -q pip==26.2.1 build==1.6.1 pip-tools==7.6.1 pip-audit==2.10.1 .tools/bin/python -m pip list | grep -iE "^(pip|build|pip[-_]tools|pip[-_]audit) "
build 1.6.1 pip 26.2.1 pip_audit 2.10.1 pip-tools 7.6.1

python -m build makes two files: an sdist (source distribution, a .tar.gz of the project source) and a wheel (a .whl zip that pip unpacks without running any build step). Here is the first attempt, with the [tool.hatch.build.targets.sdist] table removed:

deploy@web01:~/ps-package · Ubuntu 26.04 LTS
$ cp pyproject.toml pyproject.toml.keep sed -i "/^\[tool\.hatch/,\$d" pyproject.toml .tools/bin/python -m build --sdist ls -l dist tar -tzf dist/hashcheck-0.1.0.tar.gz | cut -d/ -f2 | sort | uniq -c mv pyproject.toml.keep pyproject.toml rm -r dist
… Successfully built hashcheck-0.1.0.tar.gz total 6324 -rw-r--r-- 1 deploy deploy 6473756 Sep 28 20:57 hashcheck-0.1.0.tar.gz 2043 .tools 1 PKG-INFO 1 feed 1 feed.log 1 fetched.txt 1 iocs.txt 1 make_uploads.sh 1 old.in 1 pyproject.toml 1 pyproject.toml.keep 6 src 4 uploads

A 6.4 MB sdist, holding the whole .tools venv (2043 files), the IOC list, the sample uploads and the fetched list. Without instructions, hatchling leaves out only what .gitignore excludes (this directory has none) and a few fixed names such as .venv, .git and __pycache__; everything else goes in. That is why the development venv, .venv, stayed out while the tools venv went in: its name is .tools. A token file or a .env sitting in the project would have been published the same way. The include = ["/src"] table limits the sdist to the source (hatchling adds pyproject.toml and its metadata itself). The real build:

deploy@web01:~/ps-package · Ubuntu 26.04 LTS
$ .tools/bin/python -m build
* Creating isolated environment: venv+pip... * Installing packages in isolated environment: - hatchling==1.32.4 * Getting build dependencies for sdist... * Installed build dependency versions: - hatchling==1.32.4 * Building sdist... * Building wheel from sdist * Creating isolated environment: venv+pip... * Installing packages in isolated environment: - hatchling==1.32.4 * Getting build dependencies for wheel... * Installed build dependency versions: - hatchling==1.32.4 * Building wheel... Successfully built hashcheck-0.1.0.tar.gz and hashcheck-0.1.0-py3-none-any.whl
$ ls -l dist python3 -m zipfile -l dist/hashcheck-0.1.0-py3-none-any.whl tar -tzf dist/hashcheck-0.1.0.tar.gz
total 16 -rw-r--r-- 1 deploy deploy 7785 Sep 28 20:57 hashcheck-0.1.0-py3-none-any.whl -rw-r--r-- 1 deploy deploy 5287 Sep 28 20:57 hashcheck-0.1.0.tar.gz File Name Modified Size hashcheck/__init__.py 2020-02-02 00:00:00 82 hashcheck/cli.py 2020-02-02 00:00:00 3047 hashcheck/cli_args.py 2020-02-02 00:00:00 2360 hashcheck/fetch.py 2020-02-02 00:00:00 2507 hashcheck/hashing.py 2020-02-02 00:00:00 2011 hashcheck/quarantine.py 2020-02-02 00:00:00 2666 hashcheck-0.1.0.dist-info/METADATA 2020-02-02 00:00:00 184 hashcheck-0.1.0.dist-info/WHEEL 2020-02-02 00:00:00 87 hashcheck-0.1.0.dist-info/entry_points.txt 2020-02-02 00:00:00 88 hashcheck-0.1.0.dist-info/RECORD 2020-02-02 00:00:00 767 hashcheck-0.1.0/src/hashcheck/__init__.py hashcheck-0.1.0/src/hashcheck/cli.py hashcheck-0.1.0/src/hashcheck/cli_args.py hashcheck-0.1.0/src/hashcheck/fetch.py hashcheck-0.1.0/src/hashcheck/hashing.py hashcheck-0.1.0/src/hashcheck/quarantine.py hashcheck-0.1.0/pyproject.toml hashcheck-0.1.0/PKG-INFO
$ python3 -c 'import zipfile; print(zipfile.ZipFile("dist/hashcheck-0.1.0-py3-none-any.whl").read("hashcheck-0.1.0.dist-info/METADATA").decode())'
Metadata-Version: 2.5 Name: hashcheck Version: 0.1.0 Summary: Find files whose SHA-256 is on an IOC list, and quarantine them. Requires-Python: >=3.14 Requires-Dist: requests<3,>=2.33

The wheel is 8 KB: the six modules, METADATA with the name, version and the Requires-Dist range, entry_points.txt with the two commands, and RECORD, a list of every file with its hash. py3-none-any in the name means pure Python, any platform. That single file is what you hand to other people. The 5 KB sdist now holds only src/, pyproject.toml and PKG-INFO.

Lock the versions, with hashes

A range says what works; a lock file says exactly what was tested. pip-compile from pip-tools resolves the ranges once and writes every package, direct and indirect, with an exact version, and with --generate-hashes the SHA-256 of every file PyPI offers for it:

deploy@web01:~/ps-package · Ubuntu 26.04 LTS
$ .tools/bin/pip-compile --generate-hashes --strip-extras --pip-args "--timeout 60" --output-file requirements.lock pyproject.toml
# # This file is autogenerated by pip-compile with Python 3.14 # by the following command: # # pip-compile --generate-hashes --no-index --output-file=requirements.lock --pip-args='--timeout 60' --strip-extras pyproject.toml # certifi==2026.7.22 \ --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \ --hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55 # via requests charset-normalizer==3.5.1 \ --hash=sha256:00668ebb0609751758682eb0b5857e7c35b9f00e84dfdef062e103244ec94d45 \ … # via requests idna==3.20 \ --hash=sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44 \ --hash=sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c # via requests requests==2.34.2 \ --hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \ --hash=sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed # via hashcheck (pyproject.toml) urllib3==2.8.0 \ --hash=sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3 \ --hash=sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63 # via requests

Five packages; # via says who needed each one. The header is pip-compile's own record of how to reproduce the file, so it can list options you did not type, such as --no-index. charset-normalizer has a hash for each of its platform wheels, which is why its entry runs to many lines (left out above). --pip-args "--timeout 60" gives pip 60 seconds per network read instead of its default 15; two lab runs failed on a slow PyPI download without it. Install it into a fresh venv, the way a server would:

deploy@web01:~/ps-package · Ubuntu 26.04 LTS
$ python3 -m venv prod prod/bin/python -m pip install -q pip==26.2.1 prod/bin/python -m pip install --require-hashes -r requirements.lock prod/bin/python -m pip install --no-deps dist/hashcheck-0.1.0-py3-none-any.whl prod/bin/hashcheck scan --iocs iocs.txt uploads/b/invoice.pdf; echo "exit status $?"
Collecting certifi==2026.7.22 (from -r requirements.lock (line 7)) Using cached certifi-2026.7.22-py3-none-any.whl (136 kB) … Installing collected packages: urllib3, idna, charset-normalizer, certifi, requests … Successfully installed certifi-2026.7.22 charset-normalizer-3.5.1 idna-3.20 requests-2.34.2 urllib3-2.8.0 Processing ./dist/hashcheck-0.1.0-py3-none-any.whl Installing collected packages: hashcheck Successfully installed hashcheck-0.1.0 uploads/b/invoice.pdf invoice-lure-2026-114 exit status 1

--require-hashes makes pip compare the SHA-256 of every file it installs with the lock and refuse anything that does not match. The tool's own wheel is installed in a second command with --no-deps, so pip resolves nothing beyond the lock. To see the check fail, build a wheelhouse (a local directory of wheels, used for offline installs with --no-index --find-links) and change one byte of one wheel:

deploy@web01:~/ps-package · Ubuntu 26.04 LTS
$ prod/bin/python -m pip download -q --require-hashes -r requirements.lock -d wheelhouse ls wheelhouse
certifi-2026.7.22-py3-none-any.whl charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl idna-3.20-py3-none-any.whl requests-2.34.2-py3-none-any.whl urllib3-2.8.0-py3-none-any.whl
$ cp wheelhouse/idna-3.20-py3-none-any.whl idna.good printf x >> wheelhouse/idna-3.20-py3-none-any.whl python3 -m venv check check/bin/python -m pip install -q pip==26.2.1 check/bin/python -m pip install --no-index --find-links wheelhouse --require-hashes -r requirements.lock; echo "exit status $?" check/bin/python -m pip list
Looking in links: wheelhouse Processing ./wheelhouse/certifi-2026.7.22-py3-none-any.whl (from -r requirements.lock (line 7)) Processing ./wheelhouse/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl (from -r requirements.lock (line 11)) Processing ./wheelhouse/idna-3.20-py3-none-any.whl (from -r requirements.lock (line 185)) 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. idna==3.20 from file:///home/deploy/ps-package/wheelhouse/idna-3.20-py3-none-any.whl (from -r requirements.lock (line 185)): Expected sha256 a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44 Expected or ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c Got 22cadec73f71048c53999844b869ec79aab7c9715a0e99cad3c35fb157cf0e04 exit status 1 Package Version ------- ------- pip 26.2.1

pip names the file, the two hashes the lock allows for idna==3.20 (its wheel and its sdist) and the hash it got: 64 hex digits each, as every SHA-256 is. It stopped before installing anything, including the three files that were fine; pip list shows only pip. Put the good wheel back and the same offline install succeeds:

deploy@web01:~/ps-package · Ubuntu 26.04 LTS
$ mv idna.good wheelhouse/idna-3.20-py3-none-any.whl check/bin/python -m pip install -q --no-index --find-links wheelhouse --require-hashes -r requirements.lock; echo "exit status $?" check/bin/python -m pip list
exit status 0 Package Version ------------------ --------- certifi 2026.7.22 charset-normalizer 3.5.1 idna 3.20 pip 26.2.1 requests 2.34.2 urllib3 2.8.0

Now a lock line without a hash, as happens when someone adds a package by hand:

deploy@web01:~/ps-package · Ubuntu 26.04 LTS
$ cp requirements.lock partial.lock echo "pyyaml==6.0.3" >> partial.lock check/bin/python -m pip install --require-hashes -r partial.lock; echo "exit status $?" check/bin/python -m pip list | grep -i yaml || echo "PyYAML is not installed"
… Collecting pyyaml==6.0.3 (from -r partial.lock (line 197)) Downloading pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl (766 kB) … ERROR: Hashes are required in --require-hashes mode, but they are missing from some requirements. Here is a list of those requirements along with the hashes their downloaded archives actually had. Add lines like these to your requirements files to prevent tampering. (If you did not enable --require-hashes manually, note that it turns on automatically when any package has a hash.) pyyaml==6.0.3 --hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 exit status 1 PyYAML is not installed

Hash-checking mode fails closed: one requirement without a hash, and nothing is installed. pip helpfully prints the hash of the file it just downloaded. Pasting that line into the lock proves only that the file matches itself; it compares the download with nothing you trusted before. Add packages to pyproject.toml and run pip-compile again. A hash also cannot tell you whether the locked version was malicious when you locked it: it proves "unchanged since the lock", not "safe". The lock also covers only what the tool runs with. The tools that build and audit it (build, the hatchling inside its build environment, pip-tools, pip-audit) were pinned here but not hash-checked, and they produce the artifact and the verdict; in CI they deserve a lock of their own, which the scripting-adv lessons on packaging and the supply chain set up.

Audit the lock with pip-audit

pip-audit, maintained by the Python Packaging Authority, checks exact versions against published advisories; by default it asks PyPI's vulnerability API, so it needs the network. Point it at the lock, which contains the indirect dependencies too. --disable-pip audits the pinned lines as written instead of installing them into a temporary venv, which works because the lock is fully pinned and hashed:

deploy@web01:~/ps-package · Ubuntu 26.04 LTS
$ .tools/bin/pip-audit --disable-pip -r requirements.lock; echo "exit status $?"
No known vulnerabilities found exit status 0

Clean today. To see a finding, lock an old release of requests in a throwaway file:

old.in
# A throwaway input for the audit demo: an old release with published advisories.
requests==2.32.3
deploy@web01:~/ps-package · Ubuntu 26.04 LTS
$ .tools/bin/pip-compile -q --generate-hashes --strip-extras --pip-args "--timeout 60" --output-file old.lock old.in grep -E "^[a-z]" old.lock .tools/bin/pip-audit --disable-pip --aliases on -r old.lock; echo "exit status $?"
certifi==2026.7.22 \ charset-normalizer==3.5.1 \ idna==3.20 \ requests==2.32.3 \ urllib3==2.8.0 \ Found 4 known vulnerabilities in 1 package Name Version ID Fix Versions Aliases -------- ------- --------------- ------------ ----------------------------------- requests 2.32.3 PYSEC-2026-1872 2.32.4 GHSA-9hjg-9r4m-mvj7, CVE-2024-47081 requests 2.32.3 PYSEC-2026-2275 2.33.0 GHSA-gc5v-m9x4-r6x2, CVE-2026-25645 requests 2.32.3 PYSEC-2026-1872 2.32.4 GHSA-9hjg-9r4m-mvj7, CVE-2024-47081 requests 2.32.3 PYSEC-2026-2275 2.33.0 GHSA-gc5v-m9x4-r6x2, CVE-2026-25645 exit status 1

Exit status 1, which fails a CI step. The table says four, but it holds two advisories, each listed twice with the same ID and aliases: count the distinct IDs, not the rows. CVE-2024-47081 (GHSA-9hjg-9r4m-mvj7) can leak .netrc credentials through a crafted URL and is fixed in 2.32.4; CVE-2026-25645 (GHSA-gc5v-m9x4-r6x2) is insecure reuse of a temporary file and is fixed in 2.33.0. That second fix is why pyproject.toml says >=2.33. An audit only knows published advisories: a freshly hijacked release is malicious, not "known vulnerable", and passes. Run it on every change to the lock and on a schedule, because new advisories appear for versions you locked long ago.

pipx for the people who run it

Teammates who only want the command should not manage venvs. pipx installs each application into a venv of its own and puts only its commands on PATH. Ubuntu 26.04 packages pipx 1.8.0 (sudo apt install pipx), which is enough for install, run and upgrade; the lab installs 1.17.7, the current release when it ran, from PyPI into its own venv, so the version is pinned:

deploy@web01:~/ps-package · Ubuntu 26.04 LTS
$ python3 -m venv pipx-venv pipx-venv/bin/python -m pip install -q pipx==1.17.7 pipx-venv/bin/pipx --version
1.17.7
$ pipx-venv/bin/pipx install ./dist/hashcheck-0.1.0-py3-none-any.whl
creating virtual environment... creating shared libraries... upgrading shared libraries... WARNING: Skipping setuptools as it is not installed. determining package name from '/home/deploy/ps-package/dist/hashcheck-0.1.0-py3-none-any.whl'... creating virtual environment... installing hashcheck from spec '/home/deploy/ps-package/dist/hashcheck-0.1.0-py3-none-any.whl'... ⚠️ Note: '/home/deploy/.local/bin' is not on your PATH environment variable. These apps will not be globally accessible until your PATH is updated. Run `pipx ensurepath` to automatically add it, or manually modify your PATH in your shell's config file (e.g. ~/.bashrc). done! ✨ 🌟 ✨ installed package hashcheck 0.1.0, installed using Python 3.14.4 These apps are now available - hashcheck - hashcheck-fetch

The commands went into ~/.local/bin, which did not exist when this shell started. Ubuntu's ~/.profile adds that directory to PATH at login if it exists, so the next login finds it (pipx ensurepath does the same for other setups):

deploy@web01:~/ps-package · Ubuntu 26.04 LTS
$ command -v hashcheck hashcheck-fetch hashcheck scan --iocs iocs.txt uploads/a/invoice.pdf; echo "exit status $?" head -n 1 "$(command -v hashcheck)"
/home/deploy/.local/bin/hashcheck /home/deploy/.local/bin/hashcheck-fetch uploads/a/invoice.pdf dropper-2026-091 exit status 1 #!/home/deploy/.local/share/pipx/venvs/hashcheck/bin/python -E
$ pipx-venv/bin/pipx runpip hashcheck list
Package Version ------------------ --------- certifi 2026.7.22 charset-normalizer 3.5.1 hashcheck 0.1.0 idna 3.20 pip 26.2.1 requests 2.34.2 urllib3 2.8.0
$ pipx-venv/bin/pipx uninstall hashcheck
uninstalled hashcheck! ✨ 🌟 ✨

The launcher points at pipx's own venv for hashcheck (-E makes Python ignore PYTHON* environment variables). pipx runpip hashcheck list shows how the dependencies got there: pipx resolved the requests range at install time. It did not read your lock, so the versions match only because nothing newer exists today. Where the exact, hash-checked set matters, on servers, install into a venv from the lock as above. pipx isolates dependencies, not privileges: the tool runs with your user's rights.

Where packages come from matters as much as which versions. --extra-index-url adds a second index, and pip then treats both indexes as equal and installs the highest version it finds. If your company's private index serves an internal package, say acme-ioc-utils, and someone uploads acme-ioc-utils 99.0 to public PyPI, the public one wins. That is dependency confusion. Use one index (a private mirror that proxies PyPI) with --index-url, and keep hashes in the lock so a swapped file fails the install.

Two newer lock formats are worth knowing. PEP 751 standardised pylock.toml, which pip 25.1 and later can write experimentally (pip lock) and pip 26.1 and later can install from, also experimentally (pip install -r pylock.toml), and uv, a fast installer written in Rust, keeps its own uv.lock. The scripting-adv lesson on shipping automation compares these locking strategies.

Try this

Give hashcheck a --version option that reads the installed version with importlib.metadata.version("hashcheck") (in cli_args.py: parser.add_argument("--version", action="version", version=...)), and raise the version in pyproject.toml to 0.2.0. Work in a copy of the project, build only the wheel (python -m build --wheel --outdir try-dist try-project), create a fresh venv, install the lock with --require-hashes and the new wheel with --no-deps, and check that hashcheck --version prints hashcheck 0.2.0. Then try the shortcut people reach for: python3 try-project/src/hashcheck/cli.py --version. It fails with ImportError: attempted relative import with no known parent package, because a file run directly is not part of a package; installed packages and entry points are how a package runs.

Takeaway

Declare dependency ranges in pyproject.toml, lock exact versions with hashes, and install servers only with --require-hashes from that lock, so that a changed or unlisted file stops the install. Audit the lock regularly, limit what the sdist contains, and remember that pipx isolates dependencies, not privileges.

Quick check
01A teammate changes dependencies in pyproject.toml to "requests==2.34.2" "for security". What is the main problem?
Incorrect — pip honours it, as the Requires-Dist line in METADATA would show. The problem is what the pin does to others.
Incorrect — Build backends accept exact pins; the build would succeed and the conflicts come later, at install time.
Incorrect — pip-compile locks an exact pin like any range; it simply has one choice.
Correct — Ranges in the metadata, exact versions and hashes in the lock.
02pip install --require-hashes fails: one line in the lock has no hash, and pip prints pyyaml==6.0.3 --hash=sha256:501a.... A teammate pastes that line into the lock and the install passes. What has been verified?
Correct — Add the package to pyproject.toml and let pip-compile produce the lock line.
Incorrect — TLS protects the connection, not the content PyPI or a mirror serves. The hash was taken from whatever arrived.
Incorrect — Hashing and auditing are separate steps; pip-audit would have to check this version.
Incorrect — Other lines are checked against their own hashes on every install; the new line adds nothing to that.
03pip-audit -r requirements.lock prints "No known vulnerabilities found". What does that tell you?
Incorrect — An audit matches versions against advisories. It does not inspect code, and a new hijacked release has no advisory.
Incorrect — That is --require-hashes during install. pip-audit checks versions against advisory data.
Correct — Audit on every lock change and on a schedule.
Incorrect — The audit covered only the locked versions. Other versions in the range were not checked, as requests 2.32.3 showed.

Related