Reproducible Builds: Wie man Python-Software vor Supply-Chain-Angriffen schützt

3.286 potenziell unerwünschte Anwendungen, 2.155 Fälle von Secrets-Exfiltration und 1.326 Dropper, allein auf PyPI, allein im zweiten Quartal 2026, gezählt von der Sicherheitsfirma Sonatype in ihrem “Open Source Malware Index”. Macht zusammen fast 6.800 als bösartig eingestufte Python-Paket-Artefakte in drei Monaten, auf einem einzelnen Package-Index. Drei der Kampagnen dahinter sind gut genug dokumentiert (mehrere unabhängige Sicherheitsfirmen, übereinstimmende Details), um sie hier konkret durchzugehen.

Am 24. März 2026 erschienen zwei neue Versionen von litellm (einer Python-Bibliothek, die eine einheitliche Schnittstelle zu über hundert verschiedenen LLM-Anbietern wie OpenAI oder Anthropic bietet und allein im letzten Monat über 651 Millionen Downloads zählte, laut pypistats.org) auf PyPI: 1.82.7 und 1.82.8 (Datadog Security Labs, Akamai). Kein Tippfehler-Paket, kein Fake, sondern das echte, verbreitete Projekt selbst, veröffentlicht über einen gestohlenen Zugriff auf dessen CI-Pipeline. Akamais Analyse macht dafür einen fehlkonfigurierten pull_request_target-GitHub-Actions-Workflow verantwortlich, über den ein Zugriffs-Token abgegriffen wurde. Version 1.82.7 versteckte den Schadcode noch im Proxy-Modul des Pakets selbst (litellm/proxy/proxy_server.py), ausgeführt erst beim Import; Version 1.82.8 ging einen Schritt weiter und brachte eine unscheinbare litellm_init.pth-Datei mit. .pth-Dateien sind eigentlich ein harmloser, alter Mechanismus: Python liest beim Interpreter-Start (über das site-Modul) alle Dateien mit dieser Endung im site-packages-Verzeichnis ein, und jede Zeile darin wird normalerweise schlicht als zusätzlicher Pfad an den Modul-Suchpfad sys.path angehängt. Die Ausnahme, die das Format zum Angriffswerkzeug macht: Zeilen, die mit import beginnen, werden stattdessen als Python-Code ausgeführt, bei jedem Start jedes Interpreters in dieser Umgebung, noch bevor das eigentliche Programm überhaupt läuft und ganz unabhängig davon, ob das betroffene Paket je importiert wird. Wer das Paket in diesem Zeitfenster installierte, schickte damit ungefragt Umgebungsvariablen, SSH-Schlüssel und Cloud-Zugangsdaten an einen fremden Server.

Kein Einzelfall: Am 19. Mai 2026 traf es Microsofts offizielles durabletask-SDK für Azure Durable Functions (rund 366.000 Downloads pro Monat, laut pypistats.org). Drei bösartige Versionen, veröffentlicht über einen kompromittierten PyPI-API-Token, in einem 35-Minuten-Fenster (Wiz, StepSecurity). Anfang Juni dieselbe Technik noch einmal, diesmal gegen 19 Bioinformatik-Pakete aus dem wissenschaftlichen Python-Ökosystem, wieder über eine .pth-Datei — dieser Angriff läuft in der Berichterstattung unter dem Namen “Shai-Hulud” (BleepingComputer). Alle drei Fälle werden demselben Akteur zugeordnet, dessen Kampagne die beteiligten Sicherheitsfirmen “TeamPCP” nennen, und folgen demselben Muster: Zugriff auf die Veröffentlichungs-Pipeline kapern, eine neue Version veröffentlichen, die aussieht wie jede andere; die Versionsnummer allein verrät nichts.

Wie aktuell das Thema ist, zeigt sich noch an einer zweiten Stelle: Bis vor wenigen Wochen war es auf PyPI technisch möglich, einer Paket-Version, die schon Jahre zuvor veröffentlicht wurde, nachträglich eine komplett neue Datei hinzuzufügen: ein Paket, das 2019 nur als Sdist (source distribution, ein Distributions-Archiv mit dem rohen Quellcode, aus dem beim Installieren erst noch ein Wheel gebaut werden muss) erschien, hätte 2026 plötzlich ein frisches Wheel (das bereits fertig gebaute, direkt installierbare Format, kein Kompilieren mehr nötig) bekommen können, ohne dass sich die Versionsnummer ändert. PyPI selbst hat diese Lücke erst Anfang Juli 2026 geschlossen. Genau dieser Fall, dass ein bereits als sicher geltendes Release nachträglich verändert wird, ist der Grund, warum weiter unten nicht nur Versionsnummern, sondern Hashes gepinnt werden — die Details dazu folgen in einem eigenen Abschnitt.

Die drei Fälle oben drehen sich alle um dieselbe Angriffsklasse: Zugriff auf eine Veröffentlichungs-Pipeline kapern und ein Artefakt austauschen oder nachschieben. Es gibt aber noch eine zweite, ältere und strukturell andere Bedrohung, für die Reproducible Builds als organisiertes Projekt schon 2013 entstanden ist, unter anderem verstärkt durch die Snowden-Enthüllungen desselben Jahres und die Sorge vor Angriffen auf den Build-Prozess selbst statt auf den einsehbaren Quellcode: Schadcode, der nicht in einer ausgetauschten Datei steckt, sondern im Build-Prozess selbst versteckt ist, unsichtbar für jeden, der nur den öffentlich einsehbaren Quellcode liest. Wie konkret das ausgehen kann, zeigte gut ein Jahrzehnt später xz-utils: Im April 2024 fiel einem Microsoft-Entwickler auf, dass SSH-Logins auf seinem Debian-Testsystem rund eine halbe Sekunde länger dauerten als gewohnt. Ursache war eine über Monate in die Autotools-Build-Skripte eingeschleuste Backdoor. Sie stand nicht im einsehbaren Git-Repository, sondern nur in den daraus gebauten Release-Tarballs, die die Distributionen normalerweise unverändert als Grundlage für ihre eigenen Builds übernehmen. Wer den Build-Prozess nicht selbst nachvollziehen und mit dem veröffentlichten Ergebnis vergleichen konnte, hatte keine Chance, den Unterschied zu bemerken. Genau das ist der Grund, warum weiter unten nicht nur gepinnt, sondern auch tatsächlich zweimal gebaut und verglichen wird, statt der eigenen Build-Pipeline blind zu vertrauen.

Genau dieses Nachvollziehen ist der Kern von Reproducible Builds: Aus demselben Quellcode muss zu jedem Zeitpunkt, auf jeder Maschine, exakt dasselbe Ergebnis herauskommen: bitidentisch, nicht nur “sollte funktional gleich sein”. Nur dann kann jemand anderes als der ursprüngliche Autor unabhängig überprüfen, dass ein veröffentlichtes Artefakt wirklich aus dem angegebenen Quellcode stammt, und nur dann fällt überhaupt auf, wenn sich das nachträglich ändert.

Das ist kein rein theoretisches Ziel. Debian betreibt seit Jahren ein eigenes Verifikationssystem, das jedes Archiv-Paket unabhängig nachbaut und mit dem offiziell veröffentlichten Ergebnis vergleicht. Für Debian 13 (“trixie”) lag der Stand laut LWN bei über 34.000 reproduzierbar gebauten Paketen gegenüber 764, bei denen das (noch) nicht gelingt, umgerechnet knapp 98 Prozent. Ein Python-Paket, wie es in diesem Artikel behandelt wird, ist im Vergleich dazu ein einzelnes, kleines Rädchen, aber dieselbe Technik (unabhängig nachbauen, Ergebnis vergleichen) skaliert nachweislich auf eine ganze Distribution.

Für Python-Pakete lässt sich derselbe Standard mit ein paar gezielten Handgriffen erreichen, auch ganz ohne distributionsweite Infrastruktur wie bei Debian. Wie diszipliniert das bei Laufzeit-Abhängigkeiten in der Praxis tatsächlich passiert, ist uneinheitlich: Laut der JetBrains Python Developers Survey 2024 speichern 12 Prozent der befragten Projekte gar keine Abhängigkeitsinformationen, und unter denen, die es tun, geben 74 Prozent pip als eines der genutzten Werkzeuge an (Mehrfachnennungen waren möglich), deutlich höher als die Anteile der Werkzeuge, die von sich aus ein Lock-File mit Hashes erzeugen: Poetry (20 Prozent), uv (12 Prozent), pip-tools (9 Prozent) und Pipenv (8 Prozent). Selbst pip-compile aus pip-tools pinnt von sich aus nur Versionsnummern, Hashes kommen erst mit dem zusätzlichen --generate-hashes-Flag dazu, wie weiter unten gezeigt wird. Eine Kategorie bleibt davon unabhängig aber praktisch immer komplett unangetastet: das Build-System selbst, dessen direkte Abhängigkeit selbst die offizielle setuptools-Dokumentation nur mit einer losen Untergrenze empfiehlt, nie exakt gepinnt. Dieser Artikel zeigt an einem kleinen Demo-Projekt, wie sich beide Lücken schließen lassen, inklusive zweier Stolperfallen, die erst beim Testen sichtbar werden.

Das Grundprinzip hinter allem, was folgt — neue Versionen nicht sofort und automatisch übernehmen, sondern nur über einen bewussten, überprüfbaren Upgrade-Schritt —, hat GitHub erst letzte Woche in einem eigenen Werkzeug nachvollzogen: Seit dem 23. Juli 2026 wartet Dependabot standardmäßig drei Tage nach Erscheinen einer neuen Version, bevor es einen Update-Pull-Request öffnet (GitHub-Blog). Begründung: Bösartige Pakete werden meist innerhalb weniger Stunden entdeckt, nicht Tage. Der Beleg dafür ist konkret: Beim npm-Angriff auf chalk, debug und ein gutes Dutzend weiterer Pakete im September 2025 (zusammen mehrere Milliarden wöchentliche Downloads) waren die bösartigen Versionen etwa zwei Stunden online, bevor die Community sie bemerkte und entfernte. Sicherheitsupdates zu einer bereits öffentlich bekannten Schwachstelle sind von der Verzögerung ausgenommen, die soll ja gerade schnell ankommen. Für alles andere gilt: Ein Update, das ein paar Tage wartet, bekommt mit hoher Wahrscheinlichkeit schon die Information mit, falls sich herausstellt, dass die neue Version bösartig war.

Was reproduzierbares, exaktes Pinnen dazu beiträgt, ist dasselbe Prinzip, nur dauerhaft statt nur für drei Tage: Es verhindert, dass eine frisch veröffentlichte, möglicherweise kompromittierte Version automatisch und unbemerkt in den eigenen Build wandert (dafür sorgt exaktes statt lose gebundenes Pinnen plus ein bewusster, überprüfbarer Upgrade-Schritt), und es verhindert, dass eine bereits geprüfte, als sicher akzeptierte Datei nachträglich unbemerkt ausgetauscht wird (dafür sorgen Hashes). Zwei unterschiedliche Schutzwirkungen, die im Rest des Artikels beide vorkommen.

Der Nutzen beschränkt sich dabei nicht auf die Abwehr gezielter Angriffe. Dieselbe Disziplin zahlt sich im ganz normalen Produktivbetrieb aus: Wer exakt weiß, welche Bytes tatsächlich deployt wurden, statt nur eine Versionsnummer, die je nach Zeitpunkt für unterschiedliche Artefakte stehen kann, bekommt beim nächsten Update deutlich weniger Überraschungen. Kein “hat gestern noch funktioniert, heute bricht der Build”, weil sich eine Transitiv-Abhängigkeit still verändert hat, und ein Rollback lässt sich exakt auf den zuletzt bekannten, funktionierenden Stand durchführen, statt darauf zu hoffen, dass “dieselbe Version” auch wirklich dasselbe bedeutet.

Das Demo-Projekt

Für diesen Artikel dient ein minimales Paket namens reprodemo als Beispiel. Die eigentliche Funktion ist irrelevant, wichtig ist nur, dass es eine echte, mehrstufige Laufzeit-Abhängigkeitskette hat:

[build-system]
requires = ["uv_build==0.12.0"]
build-backend = "uv_build"

[project]
name = "reprodemo"
version = "0.1.0"
requires-python = ">=3.13"
dependencies = [
    "requests>=2.32",
    "psutil>=6",
]

requests zieht transitiv certifi, charset-normalizer, idna und urllib3 nach: ein realistischer Abhängigkeitsbaum. psutil wurde bewusst dazugenommen: Es kompiliert eine C-Extension und veröffentlicht für jede Plattform und ABI-Variante eine eigene Wheel-Datei, was weiter unten noch wichtig wird.

Als Build-Backend kommt uv_build zum Einsatz, das eigene Build-Backend des Tools uv, aber als eigenständiges PyPI-Paket nutzbar, ganz ohne dass uv selbst installiert sein müsste. Der Grund für diese Wahl: Im Gegensatz zu vielen anderen Backends (etwa hatchling) hat uv_build keine einzige transitive Abhängigkeit. Das vereinfacht das erste der beiden Probleme, um die es in diesem Artikel geht, so weit, dass es kaum noch eines ist. Sechs Build-Backends wurden dafür konkret verglichen: zweimal gebaut ohne SOURCE_DATE_EPOCH, einmal mit SOURCE_DATE_EPOCH=1700000000 gesetzt, und einmal nach einer manuell veränderten Änderungszeit der Quelldatei:

BackendTransitive Build-AbhängigkeitenReproduzierbar ohne SOURCE_DATE_EPOCHSOURCE_DATE_EPOCH wird respektiertC-Extensions im eigenen Paket
hatchling 1.31.0packaging, pathspec, pluggy, trove-classifiers (+ editables nur bei Editable-Installs, + tomli unter Python < 3.11)Ja, fixer Platzhalter 2.2.2020JaJa, über Plugins
poetry-core 2.4.1keineJa, fixer Platzhalter 1.1.2016JaAls instabil markiertes Zusatzfeature
pdm-backend 2.4.9keine (importlib-metadata nur unter Python < 3.10)Ja, fixer Platzhalter 1.1.2016JaNein, nur per Delegation an setuptools
uv_build 0.12.0keineJa, fix auf 1.1.1980Nein, ignoriert die VariableNein
flit-core 3.12.0keineNur teilweise: dist-info fix auf 1.1.2016, Quelldateien behalten reales mtimeJa, dann auch für QuelldateienNein
setuptools 83.0.0 (+ wheel)keine ab Werk (alle Requires-Dist sind an optionale Extras wie test/doc gebunden)NeinJa, aber nur wenn aktiv gesetztJa, am längsten etabliert

Fünf der sechs Backends (alle außer hatchling) haben keine oder kaum transitive Abhängigkeiten, aber nur drei davon (hatchling, poetry-core, pdm-backend) normalisieren Zeitstempel im Wheel vollständig von sich aus. uv_build normalisiert ebenso vollständig, ignoriert dabei aber SOURCE_DATE_EPOCH komplett, praktisch folgenlos, solange niemand dem Zeitstempel selbst eine Bedeutung geben will. flit-core normalisiert nur zur Hälfte (nur dist-info, nicht die echten Quelldateien), ein Risiko bei frischem git clone, weil Git dabei die Checkout-Zeit statt der Commit-Zeit als Datei-Zeitstempel setzt. setuptools normalisiert ohne aktiv gesetztes SOURCE_DATE_EPOCH gar nichts, holt das aber über die längste Praxiserfahrung bei C-Extensions und die einzige native Unterstützung für Dynamic Dependencies aus einer externen Datei wieder auf.

Für dieses Demo-Projekt fiel die Wahl auf uv_build, aber gleichwertig wären auch poetry-core oder pdm-backend gewesen: Beide erfüllen laut obiger Tabelle dieselben Kriterien (kein Abhängigkeitsbaum, volle Reproduzierbarkeit ab Werk), uv_build ist hier keine objektiv bessere Wahl, nur diejenige, die in diesem Artikel als Beispiel dient. Ein Unterschied ist dabei erwähnenswert: uv_build ist eine vorkompilierte Rust-Binary und veröffentlicht deshalb pro Plattform ein eigenes Wheel, derselbe Effekt wie beim gerade erwähnten psutil, nur eben beim Build-System statt bei einer Laufzeit-Abhängigkeit. Der weiter unten gepinnte Hash gilt damit immer nur für genau eine Plattform, während poetry-core und pdm-backend als reines Python (py3-none-any) mit einem einzigen, überall gültigen Hash auskommen. In der Praxis wiegt das selten schwer: Gebaut wird normalerweise auf derselben Plattform-Klasse, auf der auch deployt wird — ein Linux-Deployment entsteht auf einem Linux-Build-System, nicht unter Windows —, und von genau so einer festen Zielumgebung geht auch dieser Artikel aus. Relevant wird der Unterschied erst, wenn dieselbe requirements-build.txt heterogene Build-Maschinen bedienen soll, etwa Entwickler-Notebooks unter macOS neben einer Linux-CI: Dann braucht es entweder mehrere --hash-Zeilen pro Eintrag (pip akzeptiert beliebig viele, jede passende genügt) oder eines der beiden Pure-Python-Backends. Wer schon mit PDM oder Poetry als Projektverwaltung arbeitet, hat mit pdm-backend beziehungsweise poetry-core bereits ein ebenso geeignetes Backend zur Hand, ohne dafür extra zu uv_build wechseln zu müssen. Wer dagegen selbst C-Code im eigenen Paket kompilieren muss, findet weiter unten, im eigenen Abschnitt, zwei Alternativen: hatchling mit Plugin-System, oder setuptools mit explizit gesetztem SOURCE_DATE_EPOCH.

Eine Anmerkung zur Aktualität

Ein Teil dessen, was dieser Artikel zeigt, stützt sich auf sehr junge pip-Funktionen, nicht auf etwas seit Jahren Etabliertes: Der Befehl pip lock ist seit pip 25.1 (April 2025) als experimentelles Feature verfügbar, pip install -r pylock.toml (also das Zurückspielen eines damit erzeugten Lock-Files) erst seit pip 26.1, das ist, zum Zeitpunkt dieses Artikels, gerade einmal gut drei Monate alt. Beides implementiert PEP 751, ein herstellerneutrales Lock-Datei-Format namens pylock.toml. Wer eine ältere pip-Version, ein eingebettetes System oder schlicht eine konservativere Umgebung im Einsatz hat, sollte das wissen, bevor er den Rest dieses Artikels als sofort einsatzbereite Anleitung liest, statt als Blick auf das, was gerade aktuell möglich wird.

Praktisch heißt das: pip 26.1 ist die eigentliche Mindestversion für diesen gesamten Artikel, nicht nur für pylock.toml. Weiter unten (Abschnitt “Das Build-System pinnen”) zeigt sich, dass dieselbe Pip-Version auch die Hash-Prüfung aus einer Constraint-Datei allein erst zuverlässig macht, mit älterem pip bricht genau dieser Schritt ab, unabhängig davon, ob der Hash stimmt. Da die meisten Distributionen derzeit noch ältere pip-Versionen als Systempaket ausliefern, führt der Weg dorthin in der Praxis über ein Update im venv (python -m pip install --upgrade pip) — und wer die Disziplin dieses Artikels konsequent anwendet, pinnt auch diesen Schritt: pip ist ein Paket wie jedes andere, eine Version, ein Hash, erzeugt mit demselben pip lock "pip==26.1" --no-deps-Muster, das weiter unten für das Build-Backend gezeigt wird. Das Werkzeug, das später alle anderen Hashes prüft, sollte nicht das einzige ungeprüfte Glied der Kette bleiben. Wer stattdessen uv als Installer einsetzt, braucht aus demselben, unten näher erklärten Grund mindestens uv 0.4.6.

Das Build-System pinnen

[build-system].requires sagt pip, welches Werkzeug (hier: uv_build) zum Bauen des Pakets gebraucht wird. Diese Zeile bleibt in praktisch jedem Projekt, das man sich ansieht, ungepinnt, oder wird bestenfalls sehr lose mit einer Unter- und Obergrenze definiert (etwa uv_build>=0.11,<0.12). Jeder Build erzeugt dafür eine frische, isolierte Umgebung und installiert die aktuellste innerhalb dieser Grenzen verfügbare Version, komplett ungeprüft. Es gibt keine Lock-Datei dafür, keine Möglichkeit, den Build von letztem Monat bitgenau zu wiederholen, und keine Chance, im Nachhinein zu sagen, welcher Code beim Bauen tatsächlich gelaufen ist.

Schritt 1: Exakt pinnen

[build-system]
requires = ["uv_build==0.12.0"]
build-backend = "uv_build"

Simpel, aber wirksam: Ab jetzt zieht jeder Build exakt diese Version, egal was gerade neu auf PyPI erschienen ist. Weil uv_build keine einzige transitive Abhängigkeit hat, ist diese eine Zeile bereits der komplette Abhängigkeitsbaum, es gibt nichts, was pip-compile --all-build-deps noch auflösen müsste. Ein Blick in uv_builds eigene Metadaten bestätigt das: kein einziger Requires-Dist-Eintrag.

Schritt 2: Auch diese eine Abhängigkeit hashen

Eine Versionsnummer allein wäre, konsequent zu Ende gedacht, immer noch nur ein Versprechen, kein Beweis. pip lock kann auch für eine einzelne Abhängigkeit einen Hash liefern:

$ pip lock "uv_build==0.12.0" --no-deps -o pylock.build.toml
WARNING: pip lock is currently an experimental command. It may be removed/changed in a future release without prior warning.
Collecting uv_build==0.12.0
  Using cached uv_build-0.12.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.metadata (1.7 kB)

--build-constraint (dazu gleich mehr) versteht dieses pylock.toml allerdings nicht, es erwartet weiterhin das klassische, gehashte requirements.txt-Format. pylock.toml ist aber nur TOML, ein kurzes Skript genügt zur Umwandlung:

#!/usr/bin/env python3
"""Convert a pylock.toml file into classic requirements.txt --hash lines."""

import sys
import tomllib


def main(pylock_path: str) -> None:
    with open(pylock_path, "rb") as f:
        data = tomllib.load(f)

    for pkg in data["packages"]:
        for wheel in pkg.get("wheels", []):
            digest = wheel["hashes"]["sha256"]
            print(f"{pkg['name']}=={pkg['version']} \\")
            print(f"    --hash=sha256:{digest}")


if __name__ == "__main__":
    main(sys.argv[1])
$ python3 scripts/pylock_to_hashes.py pylock.build.toml > requirements-build.txt
$ cat requirements-build.txt
uv-build==0.12.0 \
    --hash=sha256:5f9a918f7ce2d80d0575525e0aa97d8bf8d570c0ddd5670df3fcd11dd34d31b0

Der Name kommt dabei als uv-build heraus, mit Bindestrich statt Unterstrich, weil pylock.toml Paketnamen nach PEP 503 normalisiert. Für pip ist das dieselbe Anforderung wie uv_build, Bindestrich, Unterstrich und Punkt gelten als austauschbar, der anschließende pip wheel --build-constraint-Aufruf funktioniert damit unverändert. Für ein Backend ohne transitive Abhängigkeiten wie uv_build reicht das bereits, ganz ohne den Wheelhouse-Umweg, der bei einem größeren Baum nötig wäre (dazu mehr im Abschnitt zu hatchling).

Schritt 3: Den Pin auch tatsächlich durchsetzen

Eine Datei mit einem Hash ist erst mal nur ein Dokument. Damit ein Build sie auch wirklich befolgt, statt trotzdem wieder live von PyPI zu resolven, gibt es seit pip 25.3 (Oktober 2025) ein eigenes Flag:

$ pip wheel --build-constraint requirements-build.txt --no-deps -w dist/ .

Wer testen will, ob das wirklich greift, kann probeweise einen falschen Hash in requirements-build.txt eintragen und denselben Befehl erneut ausführen:

$ pip wheel --build-constraint requirements-build.txt --no-deps -w dist/ .
[...]
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.
    uv_build==0.12.0 from https://files.pythonhosted.org/.../uv_build-0.12.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl:
        Expected sha256 0000000000000000000000000000000000000000000000000000000000000000
             Got        5f9a918f7ce2d80d0575525e0aa97d8bf8d570c0ddd5670df3fcd11dd34d31b0

Der Build bricht ab, der falsche Hash wird zuverlässig erkannt. Eine Präzisierung dazu, die sich beim Testen als überraschend herausstellte: Bis pip 26.0 musste [build-system].requires zusätzlich selbst exakt mit == gepinnt sein, damit --build-constraint mit Hashes überhaupt funktionierte, ein offener Pin (requires = ["uv_build"]) brach mit “In –require-hashes mode, all requirements must have their versions pinned with ==” ab, unabhängig vom Inhalt der Constraint-Datei. Seit pip 26.1 (Issue #9243, Jahre offen, erst mit diesem Release geschlossen) reicht die Constraint-Datei mit Hash allein: Ein offener Pin funktioniert jetzt genauso, der Hash wird trotzdem korrekt geprüft, wie oben gezeigt (getestet mit pip 26.0 und 26.1 im direkten Vergleich, exakt derselbe Aufruf, unterschiedliches Ergebnis).

Der eigentliche Fix kam über pypa/pip#13887 (gemerged 17.4.2026), nachdem das Problem schon seit Issue #8792 (2020, mit dem damals neuen resolvelib-Resolver eingeführt) und der ausführlichen Diskussion in Issue #9020 bekannt war, ohne dass sich jemand an die eigentliche Implementierung herangetraut hätte.

Dieselbe Bugklasse betraf nicht nur pip: uv hatte bis Version 0.4.5 exakt dieselbe Lücke, mit einem deutlich gefährlicheren Fehlerbild. Statt abzubrechen, installierte uv pip install ... -c constraints.txt das manipulierte Paket damals klaglos, ganz ohne Warnung, ganz ohne Hinweis, dass der Hash gar nicht geprüft wurde (direkt getestet: uv==0.4.5, derselbe manipulierte Hash wie oben, Installed 1 package statt eines Fehlers). Behoben in astral-sh/uv#7093 (schließt Issue #7089), veröffentlicht am 5.9.2024, gut anderthalb Jahre vor pips Fix. Der Unterschied zwischen den beiden alten Fehlerbildern ist also nicht nur graduell: pip verweigerte den Build wenigstens sichtbar, uv akzeptierte den falschen Hash kommentarlos. Ein Grund mehr, eine Hash-Prüfung wie in diesem Artikel testweise mit einem absichtlich falschen Hash laufen zu lassen, statt sich auf die Dokumentation oder den Namen des Flags zu verlassen.

Wer statt pip wheel das PyPA-Referenz-Frontend python -m build verwendet (weiter unten für die setuptools/hatchling-Vergleiche im Einsatz) — das Werkzeug, das die isolierte Umgebung anlegt, darin das Backend installiert und dessen Hooks aufruft, im Unterschied zum Backend selbst, das Sdist/Wheel tatsächlich baut —, hat mit --dependency-constraints-txt ein zu --build-constraint analoges, aber technisch komplett unabhängig implementiertes Flag zur Verfügung. Es brachte bis vor Kurzem einen eigenen, dritten Bug mit: build las die Constraints-Datei zeilenweise ein und deduplizierte sie in ein Python-set, bevor es sie für pip/uv neu in eine temporäre Datei schrieb. Da ein set keine garantierte Reihenfolge hat, konnte eine per Backslash fortgesetzte --hash-Zeile (genau das Format aus pip-compile --generate-hashes, siehe unten) beim Zurückschreiben von ihrer Requirement-Zeile getrennt landen, je nach Python-Hash-Seed mal richtig zusammenhängend, mal nicht, mit derselben stillen Folge wie oben bei uv: Der Hash wurde teils überhaupt nicht geprüft, ohne dass das im Output sichtbar wurde. Genau das fiel beim Recherchieren für diesen Artikel auf und wurde direkt gemeldet und behoben, in pypa/build#1140: Der Fix liest die Datei jetzt als unveränderten Block und reicht sie so durch, ganz ohne Zeilen-Zerlegung. Die drei Bugs (pip, uv, build selbst) sind unabhängig voneinander, wirken aber in derselben Kette: Ob ein per build durchgereichter Hash am Ende tatsächlich geprüft wird, hängt weiterhin davon ab, welche pip- oder uv-Version build im Hintergrund aufruft.

Der exakte Pin in pyproject.toml bleibt trotzdem sinnvoll, aus einem anderen Grund als bisher gedacht: Er schützt nicht mehr die Hash-Prüfung selbst, sondern den Fall, dass --build-constraint schlicht vergessen oder nicht verwendet wird, etwa weil jemand ein anderes Werkzeug benutzt oder das Flag nicht kennt. Ganz ohne --build-constraint zieht ein offener Pin klaglos die jeweils aktuellste PyPI-Version, ganz ohne Prüfung, ein exakter Pin zieht wenigstens immer dieselbe Version, auch dann.

Schritt 4: Weniger pinnen mit uv build

pip wheel kann grundsätzlich nur Wheels bauen, kein Sdist, dafür gibt es keinen pip-eigenen Befehl. Backend-agnostische Frontends, die beides aus einem Aufruf liefern, gibt es mehrere: python -m build, uv build, hatch build, pdm build, poetry build (alle fünf direkt gegen ein setuptools-Testprojekt geprüft, nicht deren eigenes Backend, alle fünf bauen korrekt Sdist und Wheel). Der Unterschied liegt darin, was jedes Werkzeug an eigenen Abhängigkeiten mitbringt, die dann genauso gepinnt werden müssten wie uv_build selbst:

FrontendEigene transitive Abhängigkeiten
uv build0
python -m build3 (build, packaging, pyproject-hooks)
hatch build43 (cryptography, keyring, httpx, rich, click, uvm. — voller CLI-Stack)

pdm build (36) und poetry build (45) liegen in derselben Größenordnung wie hatch build — alle drei sind vollständige Projektverwaltungs-CLIs, kein schlankes Build-Werkzeug. Wer eines davon ohnehin schon als Projektverwaltung einsetzt, hat diese Abhängigkeiten längst installiert und muss sie nicht extra für den Build-Schritt neu rechtfertigen; wer nur bauen will, fährt mit uv build günstiger.

uv build sticht heraus: Das PyPI-Paket uv ist nur ein dünner Python-Wrapper um eine vorkompilierte Rust-Binary, ein Blick in die Wheel-Metadaten zeigt keinen einzigen Requires-Dist-Eintrag. uv selbst zu pinnen ist damit genauso trivial wie pip selbst zu pinnen: eine Version, ein Hash, keine Auflösung nötig. uv build bringt mit --build-constraints/--require-hashes ein zu --build-constraint analoges Flag mit:

$ uv build --sdist --wheel --build-constraints requirements-build.txt --require-hashes -o dist .
Building source distribution...
Building wheel...
Successfully built dist/reprodemo-0.1.0.tar.gz
Successfully built dist/reprodemo-0.1.0-py3-none-any.whl

Sieht nach demselben Schutz aus wie bei pip wheel. Derselbe Test wie oben, mit absichtlich falschem Hash in requirements-build.txt, zeigt aber etwas anderes:

$ uv build --sdist --wheel --build-constraints requirements-build.txt --require-hashes -o dist .
Building source distribution...
Building wheel...
Successfully built dist/reprodemo-0.1.0.tar.gz
Successfully built dist/reprodemo-0.1.0-py3-none-any.whl

Kein Fehler. Der falsche Hash wird komplett stillschweigend akzeptiert. Grund: uv hat sein eigenes Backend uv_build zur Beschleunigung fest im uv-Binary eingebaut (den sogenannten Fast Path) und verwendet diese gebündelte Kopie, sobald sie als “kompatibel” mit der Anforderung gilt — ganz ohne uv_build von PyPI zu laden, und damit auch ganz ohne etwas, gegen das --require-hashes prüfen könnte. Direkt nachgetestet: Selbst eine Range wie uv_build>=0.11,<0.12, die die installierte Version 0.12.0 unzweideutig ausschließt, wird vom Fast Path trotzdem mit genau dieser 0.12.0 bedient — nachweisbar über das Generator-Feld in der WHEEL-Metadatei des Ergebnisses, das dann uv 0.12.0 statt der gepinnten Version zeigt. Kein Supply-Chain-Vektor im engeren Sinn (es wird ja kein potenziell manipuliertes externes Artefakt geladen), aber ein handfestes Reproduzierbarkeits-/ Provenienzproblem: Welcher Code tatsächlich baut, hängt am Ende vom zufällig installierten uv ab, nicht vom Pin in der pyproject.toml.

Der Fix: --force-pep517 schaltet den Fast Path ab und erzwingt den regulären PEP-517-Weg über PyPI, inklusive echter Hash-Prüfung:

$ uv build --sdist --wheel --build-constraints requirements-build.txt --require-hashes --force-pep517 -o dist .
error: Failed to build `.`
  Caused by: Failed to install requirements from `build-system.requires`
  Caused by: Failed to download `uv-build==0.12.0`
  Caused by: Hash mismatch for `uv-build==0.12.0`

    Expected:
      sha256:0000000000000000000000000000000000000000000000000000000000000000

    Computed:
      sha256:5f9a918f7ce2d80d0575525e0aa97d8bf8d570c0ddd5670df3fcd11dd34d31b0

Diese Lücke war zum Zeitpunkt der Recherche für diesen Artikel noch nicht in uvs eigenem Issue-Tracker erfasst — gemeldet unter astral-sh/uv#20860. Praktische Konsequenz: Wer uv build für ein Projekt mit uv_build als eigenem Backend einsetzt, sollte --force-pep517 grundsätzlich mitgeben, sobald --build-constraints/--require-hashes im Spiel sind — für jedes andere Backend (setuptools, hatchling, …) greift die Hash-Prüfung dagegen bereits ohne diesen Zusatz zuverlässig, weil dort kein Fast Path existiert, der sie umgehen könnte.

Weil uv selbst keine Abhängigkeiten hat, lohnt es sich, es genauso zu pinnen wie pip: eine Version, ein Hash, in derselben CI-Pipeline oder demselben Container-Image festgeschrieben, in der auch alles andere gepinnt wird. Das ist ein separater Pin mit einem anderen Zweck als der --force-pep517-Fix oben: Er sorgt dafür, dass dieselbe uv-Version überall zum Einsatz kommt (Werkzeug-Konsistenz), ersetzt aber nicht den build-system.requires-Pin selbst, der weiterhin unabhängig davon gilt, welche uv-Version gerade installiert ist.

Laufzeit-Abhängigkeiten: ein Hash pro Paket, ganz ohne Umweg

Beim Build-System ist wenig zu tun, weil uv_build keinen Abhängigkeitsbaum hat. Bei den Laufzeit-Abhängigkeiten (requests, psutil) sieht das anders aus, psutil allein bringt für jede Plattform und ABI-Variante eine eigene Wheel-Datei mit. Der klassische Weg dorthin, pip-compile --generate-hashes (aus pip-tools), zeigt das Problem sofort: Ein Blick in die naiv erzeugte Datei zeigt psutil mit 21 Hashes, für Windows, macOS, jede unterstützte CPython-ABI und die Free-Threaded-Build-Variante, obwohl die laufende Maschine immer nur genau eine davon je installieren wird.

Das ist kein Bug, sondern Absicht: pip-compile löst plattformübergreifend auf, damit dieselbe Datei auf jeder unterstützten Plattform installierbar bleibt. Für die meisten Projekte, die genau eine konkrete Zielumgebung haben (eine bestimmte Linux-Distribution, ein bestimmtes Container-Base-Image), ist das unnötig. Frühere Fassungen dieser Methodik haben dieses Problem mit einem zweistufigen Wheelhouse-Umweg gelöst (pip download --no-deps in ein leeres Verzeichnis, dann pip-compile --no-index --find-links erneut dagegen laufen lassen). pip lock braucht diesen Umweg nicht: Es löst von Haus aus nur für die aktuelle Umgebung auf, genau wie pip install das schon immer tut, nur eben mit Hashes und als Lock-Datei statt als Installation:

$ pip lock -r requirements.txt -o pylock.toml
Collecting psutil>=6 (from -r requirements.txt (line 2))
  Using cached psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl.metadata (22 kB)
Collecting charset_normalizer<4,>=2 (from requests>=2.32->-r requirements.txt (line 1))
  Using cached charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl.metadata (41 kB)
[[packages]]
name = "psutil"
version = "7.2.2"

[[packages.wheels]]
name = "psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl"
url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl"

[packages.wheels.hashes]
sha256 = "076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9"

Aus 21 Hashes für psutil wird genau einer, ganz ohne Zusatzwerkzeug, ohne Wheelhouse-Verzeichnis, in einem einzigen Befehl. Ein Detail, das beim Testen auffiel: Der Dateiname muss mit pylock. beginnen (pylock. toml, pylock.deps.toml, nicht z. B. deps-pylock.toml), sonst erkennt pip das Format beim späteren Installieren nicht und behandelt die Datei fälschlich als klassische requirements.txt.

Bemerkenswert außerdem: Für die Berechnung des Hashes muss nichts installiert werden. Ein pip lock in einem komplett leeren venv (nur pip selbst) läuft in unter einer Sekunde durch und installiert dabei nichts, pip lädt für jeden Kandidaten nur eine kleine Metadaten-Datei herunter (per PEP 658/691), prüft anhand des Dateinamens, welche Version zur aktuellen Plattform passt, und liest den Hash direkt aus PyPIs Index-Antwort.

Was andere Werkzeuge hier bieten

Naheliegend ist die Frage, ob uv, Poetry, PDM oder Hatch denselben Ein-Hash-Stand nicht schon von Haus aus liefern, schließlich haben alle vier ein eigenes Lock-File-Konzept. Direkt nachgetestet, mit denselben Abhängigkeiten (requests, psutil):

WerkzeugEigenes Lock-FormatHashes für psutilAn ein Backend gekoppelt?
uv lockuv.lock, herstellereigen21Nein, frei kombinierbar
poetry lockpoetry.lock, herstellereigen21Ja, fest an poetry-core
pdm lockpdm.lock, herstellereigen21Nein, laut Doku mit setuptools/flit-core/hatchling
pdm lock (lock.format = pylock)pylock.toml, nativ21 (selbst mit --platform/--python-Einschränkung nur auf 3 reduzierbar)wie oben
hatch env lockpylock.toml, nativ1Ja, fest an hatchling

Nur hatch env lock kommt, wie pip lock, auf genau einen Hash, weil es intern selbst pip lock aufruft. Der Haken dabei: Hatchs eingebauter pip-Locker kann eine pylock.toml zwar erzeugen, aber nicht wieder zurückspielen (“Applying lockfiles with the pip locker is not yet supported”), dafür bräuchte es zusätzlich installer = "uv" in der Hatch-Konfiguration. uv, Poetry und PDMs Standardmodus lösen plattformübergreifend auf, exakt wie das klassische pip-compile, auch ein nachträglicher Export ins pylock.toml-Format (bei uv und Poetry über eigene Export-Befehle möglich) ändert daran nichts, er verpackt nur dieselbe, bereits breite Auflösung in ein anderes Dateiformat um. Für das konkrete Ziel dieses Artikels, ein Hash pro Paket, ohne Zusatzschritt, bleibt pip lock also tatsächlich die einfachste Lösung.

Die Lücke, gegen die genau dieser Hash tatsächlich schützt

Hierher gehört die in der Einleitung schon kurz erwähnte PyPI-Regel, die diesen Abschnitt noch wichtiger macht, als er ohnehin schon ist: Eine bereits veröffentlichte Version konnte jederzeit (auch Jahre später) eine zusätzliche Datei bekommen, solange deren exakter Dateiname noch nicht existierte. PyPIs eigene Analyse fand 56 von 15.000 untersuchten Projekten, die genau das (harmlos, für Python-3.14-Kompatibilität) bereits getan hatten: Technisch möglich war es für jedes Release. Das Sicherheitsrisiko: Ein gestohlener Publishing-Token hätte ein altes, längst als vertrauenswürdig eingestuftes Release nachträglich vergiften können, ohne dass sich an der Versionsnummer irgendetwas geändert hätte.

PyPI hat diese Lücke am 8. Juli 2026 geschlossen und die Änderung am 22. Juli im offiziellen Blogpost angekündigt: Releases, die älter als 14 Tage sind, akzeptieren seither keine neuen Datei-Uploads mehr. Bis dahin war das genannte Angriffsszenario aber ein Jahrzehnt lang echt möglich, und zwar unabhängig davon, ob man es nutzt. Genau hier zeigt sich der Unterschied zwischen Versions- und Hash-Pinning konkret: Eine später nachgeschobene, andere Datei für dieselbe Version hätte zwangsläufig einen anderen Hash als die eine, die schon in der eigenen pylock.toml eingetragen ist. pip install --require-hashes prüft die Bytes, nicht den Dateinamen oder die Versionsnummer, und lehnt die neue Datei deshalb ab. Wer nur die Version pinnt, hätte sich auf PyPIs guten Willen verlassen müssen; wer den Hash pinnt, war strukturell abgesichert, lange bevor PyPI selbst nachgezogen hat.

Alles zusammen bauen und prüfen

Zwei Dateien liegen jetzt bereit: requirements-build.txt (der eine Build-System-Pin, aus Schritt 2/3) und pylock.toml (die Laufzeit-Abhängigkeiten, aus dem vorigen Abschnitt). Vier Schritte bauen daraus ein geprüftes Ergebnis: bauen, ein zweites Mal bauen und vergleichen, Laufzeit-Abhängigkeiten installieren, eigenes Paket installieren.

1. Sdist und Wheel in einem Aufruf bauen. dist/ ist ab hier das eigentliche Ergebnis, --force-pep517 wegen der uv_build-Fast-Path-Falle von oben nicht vergessen:

$ uv build --sdist --wheel --build-constraints requirements-build.txt --require-hashes --force-pep517 -o dist .

2. Ein zweites Mal bauen, zum Vergleich. dist2/ ist rein zu Prüfzwecken da und kann danach gelöscht werden:

$ uv build --sdist --wheel --build-constraints requirements-build.txt --require-hashes --force-pep517 -o dist2 .
$ sha256sum dist/*.whl dist2/*.whl
6c16e96a6646e879ae0fa4d29a061beb903097e9cc65415e95b325d32bb2be6b  dist/reprodemo-0.1.0-py3-none-any.whl
6c16e96a6646e879ae0fa4d29a061beb903097e9cc65415e95b325d32bb2be6b  dist2/reprodemo-0.1.0-py3-none-any.whl
$ sha256sum dist/*.tar.gz dist2/*.tar.gz
e8fe688e5ef3d9834a59a0cf2b7b385fc2d92f19290b45691a276fa77e447141  dist/reprodemo-0.1.0.tar.gz
e8fe688e5ef3d9834a59a0cf2b7b385fc2d92f19290b45691a276fa77e447141  dist2/reprodemo-0.1.0.tar.gz

Beide Artefakte bitidentisch, ganz ohne dass dafür SOURCE_DATE_EPOCH gesetzt werden musste: uv_build normalisiert jeden Zeitstempel unbedingt, im Wheel auf den 1.1.1980 (ZIPs kennen kein früheres Datum), im Sdist auf den 1.1.1970 (tar erlaubt Unix-Zeitstempel 0) — unabhängig vom echten Zeitpunkt des Build-Laufs oder vom mtime der Quelldateien (konkret getestet, auch nach einer manuell auf 2030 vorgestellten Änderungszeit einer Quelldatei blieben beide Hashes identisch). Der einzige Nachteil: uv_build ignoriert SOURCE_DATE_EPOCH sogar dann, wenn die Variable gesetzt ist, der Zeitstempel trägt also nie eine eigene Bedeutung. Für die Reproduzierbarkeit selbst spielt das keine Rolle. Nebenbei bestätigt: pip wheel --build-constraint, python -m build --dependency-constraints-txt und uv build --force-pep517 liefern für dasselbe Projekt exakt denselben Wheel-Hash — die Bytes hängen vom Backend ab, nicht vom Frontend, das es aufruft.

3. Laufzeit-Abhängigkeiten installieren, hash-geprüft, in einem frischen, leeren venv:

$ pip install -r pylock.toml --require-hashes

4. Das eigene Wheel installieren, aus dist/ von Schritt 1, separat von Schritt 3 und ohne --require-hashes: pylock.toml kann ein Verzeichnis wie das eigene Projekt gar nicht hash-verifizieren, ein Verzeichnis hat keinen festen Hash, deshalb der eigene, zweite Befehl. Das Sdist aus Schritt 1 wird hier nicht installiert, es ist für die Veröffentlichung in einem internen oder öffentlichen Package-Repository gedacht (viele Indizes verlangen oder empfehlen beide Formate), nicht für die lokale Installation:

$ pip install --no-deps dist/reprodemo-0.1.0-py3-none-any.whl
$ python -c "import requests, psutil, reprodemo; print('OK', requests.__version__, psutil.__version__)"
OK 2.34.2 7.2.2

--no-deps ist in Schritt 4 unproblematisch: reprodemos Laufzeit-Abhängigkeiten sind durch Schritt 3 bereits vollständig und hash-geprüft installiert, hier gibt es nichts mehr, was eine erneute Abhängigkeitsauflösung noch prüfen müsste. Riskant wäre --no-deps nur zusammen mit --require-hashes auf der vollständigen Abhängigkeitsliste (Schritt 3), weil dort --require-hashes normalerweise bemerkt, wenn ein gebrauchtes Paket in der Datei fehlt, und --no-deps genau diese Prüfung abschalten würde. Schritt 3 verzichtet deshalb bewusst auf --no-deps.

Alternative für C-Extensions: hatchling oder setuptools

Alles oben Gezeigte hat einen stillen Vorbehalt: Es funktioniert so reibungslos, weil uv_build keinen Abhängigkeitsbaum hat. Wer C-Code im eigenen Paket kompilieren muss (nicht in einer Abhängigkeit, psutils C-Extension etwa wird von psutils eigenem Build-Prozess erzeugt und betrifft reprodemos Backend gar nicht), hat zwei echte Optionen mit Praxiserfahrung: hatchling mit seinem Plugin-System, oder setuptools, das am längsten etablierte Backend für genau diesen Fall. Beide sind gleichwertige Alternativen, nur mit entgegengesetztem Trade-off: hatchling bringt einen kleinen Abhängigkeitsbaum mit, normalisiert Zeitstempel aber ohne jedes Zutun; setuptools hat (fast) keine Abhängigkeiten, verlangt aber ein aktiv gesetztes SOURCE_DATE_EPOCH, ohne das normalisiert es beim Bauen gar nichts.

setuptools

[build-system]
requires = ["setuptools==83.0.0"]
build-backend = "setuptools.build_meta"

setuptools hat, wie schon in der Backend-Tabelle oben gezeigt, ab Werk keine einzige unbedingte Abhängigkeit (alle Requires-Dist hängen an optionalen Extras wie test/doc). Problem 1 ist damit genauso einfach wie bei uv_build: Dieser eine exakte Pin ist bereits der komplette Baum, kein pip-compile --all-build-deps nötig. Genauso lässt sich auch dieser eine Pin hashen (pip lock "setuptools==83.0.0" --no-deps) und mit --build-constraints/--require-hashes durchsetzen — dieselben Befehle wie bei uv_build in Schritt 2/3, hier nicht noch einmal wiederholt, nur mit anderem Paketnamen.

Der Unterschied liegt bei der Reproduzierbarkeit selbst: Ohne gesetztes SOURCE_DATE_EPOCH normalisiert setuptools beim Bauen gar nichts. Direkt getestet, zwei Builds im Abstand weniger Sekunden, Sdist und Wheel zusammen, Hash-verifiziert:

$ uv build --sdist --wheel --build-constraints requirements-build.txt --require-hashes -o dist .
$ uv build --sdist --wheel --build-constraints requirements-build.txt --require-hashes -o dist2 .
$ sha256sum dist/*.whl dist2/*.whl
d7f6bd688751f68a9cf2f02f10e6b4aebeb57a5251726361726067b220bb9516  dist/reprodemo-0.1.0-py3-none-any.whl
e6fa06bf48e5c1c75fdd311c338a00467f0884ff1b241bd8b120c4dc34301b07  dist2/reprodemo-0.1.0-py3-none-any.whl
$ sha256sum dist/*.tar.gz dist2/*.tar.gz
32ff3bbd6557b74f4a2f1af41ea1b58bb8b2fcb703b78774e2600b5cb9e22aef  dist/reprodemo-0.1.0.tar.gz
a2eadd1a5406a4ca6c13fc11b6c196fcab6b28471dd6ad284bc641752df2242d  dist2/reprodemo-0.1.0.tar.gz

Beide unterschiedlich. Mit explizit gesetztem SOURCE_DATE_EPOCH (zum Beispiel auf den Zeitstempel des letzten Commits, export SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)) verschwindet der Unterschied — aber nur beim Wheel:

$ export SOURCE_DATE_EPOCH=1785405600
$ uv build --sdist --wheel --build-constraints requirements-build.txt --require-hashes -o dist .
$ uv build --sdist --wheel --build-constraints requirements-build.txt --require-hashes -o dist2 .
$ sha256sum dist/*.whl dist2/*.whl
ea4210cd92239ad45e9f775af720c6d8174856927c645bae4213db95157d91ce  dist/reprodemo-0.1.0-py3-none-any.whl
ea4210cd92239ad45e9f775af720c6d8174856927c645bae4213db95157d91ce  dist2/reprodemo-0.1.0-py3-none-any.whl
$ sha256sum dist/*.tar.gz dist2/*.tar.gz
2817f76306206712eb009c6c5e852825a71de43a6f94f44ca8a77d524498a7c8  dist/reprodemo-0.1.0.tar.gz
b0498bdc7f48effd3bbd9a3978983e5660de9ab9505dbd13a62b1b6383a2248d  dist2/reprodemo-0.1.0.tar.gz

Das Wheel wird bitidentisch, das Sdist bleibt trotz gesetzter Variable unterschiedlich. Der Grund liegt eine Ebene tiefer, als SOURCE_DATE_EPOCH normalerweise wirkt: Ein .tar.gz ist ein gzip-komprimiertes Tar-Archiv, und das gzip-Format trägt in seinem eigenen Header ein separates MTIME-Feld, unabhängig von den Zeitstempeln der einzelnen Dateien im Tar-Archiv selbst. setuptools setzt dieses gzip-Header-Feld nicht auf SOURCE_DATE_EPOCH, sondern auf die echte Bauzeit:

$ python3 -c "
import struct
for f in ['dist/reprodemo-0.1.0.tar.gz', 'dist2/reprodemo-0.1.0.tar.gz']:
    with open(f, 'rb') as fh:
        _, _, _, _, mtime, _, _ = struct.unpack('<BBBBIBB', fh.read(10))
        print(f, 'gzip MTIME=', mtime)
"
dist/reprodemo-0.1.0.tar.gz gzip MTIME= 1785509770
dist2/reprodemo-0.1.0.tar.gz gzip MTIME= 1785509774

Beide weit weg von 1785405600 (dem gesetzten SOURCE_DATE_EPOCH) und noch dazu vier Sekunden auseinander, exakt der Abstand zwischen den beiden Build-Läufen. Der Inhalt des Tar-Archivs selbst ist dabei identisch, nur der gzip-Wrapper macht den Unterschied. Wer setuptools einsetzt und auch das Sdist bitidentisch braucht, kann sich also nicht allein auf SOURCE_DATE_EPOCH verlassen — das Wheel ist reproduzierbar abgesichert, das Sdist (Stand setuptools 83.0.0) nicht.

hatchling

hatchling, das Backend des Projektverwaltungs-Tools Hatch, ist die zweite Option:

[build-system]
requires = ["hatchling==1.31.0"]
build-backend = "hatchling.build"

hatchling selbst hängt von packaging, pathspec, pluggy und trove-classifiers ab (plus editables für Editable-Installs, plus tomli unter Python < 3.11); die tauchen in [build-system].requires gar nicht auf, weil PEP 518 nur die direkten Einträge kennt. Das ist genau das Problem, das mit uv_build gar nicht erst entsteht, und hier kommt pip lock nicht weiter: Es hat kein Äquivalent zu pip-compile --all-build-deps --only-build-deps, es sperrt nur, was tatsächlich installiert würde, nicht die separate Build-Abhängigkeiten-Auflösung von PEP 518. Für ein Backend mit echtem Baum bleibt pip-tools deshalb nötig:

$ pip-compile --all-build-deps --only-build-deps pyproject.toml -o requirements-build.txt
editables==0.6
    # via reprodemo (pyproject.toml::build-system.backend::editable)
hatchling==1.31.0
    # via reprodemo (pyproject.toml::build-system.requires)
packaging==26.2
    # via hatchling
pathspec==1.1.1
    # via hatchling
pluggy==1.6.0
    # via hatchling
trove-classifiers==2026.6.1.19
    # via hatchling

Der Rest der Methodik bleibt identisch: uv build --sdist --wheel --build-constraints requirements-build.txt --require-hashes erzwingt die Pins für alle fünf Abhängigkeiten auf einmal, pip lock sperrt weiterhin die Laufzeit-Abhängigkeiten mit einem Hash pro Paket. Anders als bei setuptools betrifft die gzip-MTIME-Falle von oben hatchling nicht: Direkt getestet, mit gesetztem SOURCE_DATE_EPOCH und Output außerhalb des Projektverzeichnisses (sonst landet das Ergebnis des ersten Laufs versehentlich im Sdist des zweiten, ein eigener Stolperstein), ergeben zwei Builds denselben SHA256, für Wheel und Sdist:

$ export SOURCE_DATE_EPOCH=1785405600
$ uv build --sdist --wheel --build-constraints requirements-build.txt --require-hashes -o /tmp/dist-a .
$ uv build --sdist --wheel --build-constraints requirements-build.txt --require-hashes -o /tmp/dist-b .
$ sha256sum /tmp/dist-a/*.whl /tmp/dist-b/*.whl
a30c7e64ead06fc0f8d0c4cdd6b9c963eb598d64f914fad22433e825038f123c  /tmp/dist-a/reprodemo-0.1.0-py3-none-any.whl
a30c7e64ead06fc0f8d0c4cdd6b9c963eb598d64f914fad22433e825038f123c  /tmp/dist-b/reprodemo-0.1.0-py3-none-any.whl
$ sha256sum /tmp/dist-a/*.tar.gz /tmp/dist-b/*.tar.gz
ad5ee9f290c03dce2f8e145d775b30b27056a6a07a2cb9defdf79bebab6841fe  /tmp/dist-a/reprodemo-0.1.0.tar.gz
ad5ee9f290c03dce2f8e145d775b30b27056a6a07a2cb9defdf79bebab6841fe  /tmp/dist-b/reprodemo-0.1.0.tar.gz

hatchling normalisiert von sich aus jeden Zeitstempel, im Wheel und im gzip-Header des Sdist, ohne dass SOURCE_DATE_EPOCH gesetzt werden müsste — der Unterschied hier kommt nur davon, dass ohne die Variable beide Formate genau wie bei setuptools die echte Bauzeit einbetten.

Wer stattdessen mit hatch als Projektverwaltung arbeitet statt mit pip/pip-tools: hatch env lock erreicht, wie oben gezeigt, dieselbe 1-Hash-Präzision für die Laufzeit-Abhängigkeiten, aber am Build-System-Problem ändert das nichts, hatch env lock pinnt nur die Umgebung, nie [build-system].requires selbst. pip-compile --all-build-deps bleibt also auch mit hatch als Tool nötig, hier gibt es keine Abkürzung.

Kurz zusammengefasst, mit vertauschten Kosten: setuptools braucht kein pip-compile --all-build-deps (keine unbedingten Abhängigkeiten, genau wie uv_build), verlangt dafür ein bewusst gepflegtes SOURCE_DATE_EPOCH in jeder Build-Pipeline — und selbst damit bleibt (Stand 83.0.0) nur das Wheel bitidentisch, das Sdist wegen der gzip-MTIME-Lücke nicht. hatchling normalisiert ohne jedes Zutun, für beide Formate, bringt dafür seinen eigenen kleinen Abhängigkeitsbaum mit, den pip-compile --all-build-deps auflösen muss. Welches der beiden passt, hängt davon ab, was im jeweiligen Projekt schon vorhanden ist, nicht davon, welches grundsätzlich besser wäre.

Checkliste

Wer den eigenen Build auf dieselbe Weise absichern will, kann diese Punkte der Reihe nach durchgehen:

#PrüfpunktBefehl/Hinweis
1Build-Backend mit möglichst wenig (oder keinen) transitiven Abhängigkeiten wählenpoetry-core, pdm-backend, uv_build, nur bei C-Extension-Bedarf hatchling/setuptools
2[build-system].requires exakt gepinnt, nicht nur Untergrenzenrequires = ["paket==X.Y.Z"]
3Bei einem Backend mit echtem Baum: transitive Build-Abhängigkeiten aufgelöstpip-compile --all-build-deps --only-build-deps
4Build-Pins auch tatsächlich erzwungen (Sdist und Wheel)uv build --sdist --wheel --build-constraints requirements-build.txt --require-hashes --force-pep517
5Frontend-Werkzeug selbst so schlank wie möglich, dabei mit falschem Hash getestet statt der Doku vertrautuv build (0 eigene Abhängigkeiten) vor python -m build (3) oder hatch/pdm/poetry build (30+)
6Laufzeit-Abhängigkeiten mit genau einem Hash pro Paketpip lock -r requirements.txt -o pylock.toml
7Installation tatsächlich hash-geprüftpip install -r pylock.toml --require-hashes
8Eigenes Paket separat installiert, nicht Teil derselben Hash-Prüfungpip install --no-deps dist/*.whl
9Reproduzierbarkeit selbst nachgewiesen, nicht nur angenommenzweimal bauen, sha256sum vergleichen (Sdist und Wheel)
10Frischer, leerer venv als Testfeldkeine zufällig schon installierten Pakete, die etwas verdecken

Fazit und Ausblick

Aus einem ungepinnten [build-system].requires wird, mit der richtigen Backend- und Frontend-Wahl, ein Build, der sich fast von selbst reproduzierbar absichern lässt: ein Backend ohne Abhängigkeiten, ein exakter Pin, ein pip lock-Aufruf, zwei Installationsschritte. Als einziges Zusatzwerkzeug jenseits von pip genügt uv — selbst abhängigkeitsfrei, genauso trivial zu pinnen wie pip —, für den Fall mit echtem Abhängigkeitsbaum (hatchling) zusätzlich pip-tools, aber auch dort keine exotische Software. Der Preis ist Aktualität: pip lock und pylock.toml sind noch jung und offiziell als experimentell markiert, wer das nicht mittragen will oder kann, findet mit dem klassischen, gehashten requirements.txt-Format weiterhin einen Weg, der seit 2016 mit praktisch jeder pip-Version funktioniert.

Ein zweiter Preis, unabhängig vom Alter der Werkzeuge: Jeder der drei getesteten Wege, einen Build-Backend-Pin tatsächlich durchzusetzen (pip wheel --build-constraint, python -m build --dependency-constraints-txt, uv build --build-constraints), hatte beim genauen Hinschauen einen eigenen, unabhängigen Bug, der die Hash-Prüfung ganz oder teilweise aushebeln konnte — drei verschiedene Implementierungen, dieselbe Bugklasse, unterschiedlich lange unentdeckt (von wenigen Wochen bis zu sechs Jahren). Zwei davon (build, uv build) wurden im Zuge dieses Artikels selbst gefunden, gemeldet und teilweise schon gefixt. Die Lehre daraus ist nicht “diese drei Werkzeuge sind kaputt”, sondern dass sich keine Hash-Prüfung allein auf den Namen eines Flags verlassen sollte: Ein Build mit absichtlich falschem Hash, wie in diesem Artikel mehrfach gezeigt, ist der einzige verlässliche Nachweis, dass eine Prüfung tatsächlich greift.

Eine Einschränkung dazu, damit klar bleibt, was genau gezeigt wurde: Echt bitidentisch nachgewiesen wurde hier nur der Build von reprodemo selbst, zweimal gebaut, sha256sum verglichen. Für psutil, requests und den Rest des Abhängigkeitsbaums gilt das nicht im selben, strengen Sinn, die wurden gehasht, aber nicht selbst aus ihrem eigenen Quellcode nachgebaut, um zu prüfen, ob das veröffentlichte Wheel wirklich exakt daraus entsteht. Das wäre die Debian-Eigenschaft von oben, auf jedes einzelne Paket im eigenen Abhängigkeitsbaum angewendet, und selbst Debian kommt dabei nach Jahren dedizierter Infrastruktur auf knapp 98 statt 100 Prozent. Was hier stattdessen erreicht wird, reicht aber genau für die Angriffsklasse, die den Artikel eröffnet hat: Ein einmal gepinnter Hash verhindert zuverlässig, dass eine nachträglich ausgetauschte oder eine automatisch mitgezogene, frisch kompromittierte Datei unbemerkt in den eigenen Build gelangt, unabhängig davon, ob das zugrunde liegende Wheel selbst aus seinem Quellcode reproduzierbar wäre. Für die Absicherung gegen LiteLLM-, durabletask- oder Shai-Hulud-artige Vorfälle ist genau das der relevante Teil.

Was hier bewusst außen vor blieb: Sobald ein Python-Paket am Ende in ein Distributionspaket wandert (RPM, .deb, ein Container-Image mit eingebettetem Interpreter), kommen weitere Ebenen dazu, die auf demselben Prinzip aufbauen, aber eigene Fallstricke mitbringen: der Compiler, der eingebettete Interpreter selbst, und der Paketierungs-Prozess drumherum. Das ist Stoff für einen eigenen Artikel.

Quellen zu den genannten Vorfällen

Teilt den Beitrag, falls ihr mögt

Kommentar hinterlassen