Zum Inhalt
wannacode

Abhängigkeiten: fremder Code, den dein Code ausführt

Kurz gesagt: Abhängigkeiten sind fremder Code, den dein Programm ausführt; die Lock-Datei und der Hash des Pakets schützen vor Manipulation, und ein Scanner findet bekannte Schwachstellen.

Problem

Das Paket, das niemand gewählt hat

Die package.json des Shops listet 14 Pakete. node_modules hat 900. Jedes der 14 bringt eigene Abhängigkeiten mit, und die bringen ihre eigenen mit.

In einer Nacht kommt jemand in das Konto des Autors eines kleinen Pakets zum Formatieren von Datumsangaben. Das Paket liegt tief im Baum: Der Shop benutzt es über die Bibliothek für PDF-Rechnungen, und niemand im Team weiß, dass es da ist. Der Angreifer lädt eine neue Version mit einer zusätzlichen Zeile hoch: einer Zeile, die die Umgebung des Prozesses liest und PAYMENT_API_KEY nach draußen schickt. Am Morgen holt der Build des Shops die neue Version, weil der Bereich ^2.1.3 sie erlaubt.

Der Schlüssel, den du in der vorigen Lektion aus dem Code in die Umgebung verlegt hast, leakt von dort. Der Shop hat in seinem eigenen Code nichts falsch gemacht.

Das ist nicht ausgedacht. Es ist mit event-stream (2018) und mit ua-parser-js (2021) passiert, und 2024 wurde der Austausch in xz utils kurz entdeckt, bevor er Server auf der ganzen Welt erreichte.

Die Idee

Dein Prozess, fremder Code

Jedes Paket, das das Programm lädt, läuft in deinem Prozess und mit deinen Rechten. Es liest dieselbe Umgebung und dieselben Dateien und spricht mit demselben Netz. Manche Pakete führen schon bei der Installation Code aus (die postinstall-Skripte von npm, setup.py in alten Python-Paketen).

Die Pakete, die du angefordert hast, sind direkte Abhängigkeiten. Die, die sie mitbringen, sind transitiv. Die transitiven sind meist die Mehrheit, und niemand hat sie gewählt.

Das ist die Lieferkette (Supply Chain): vom Autor des Pakets über die Registry (npm, Maven Central, NuGet, PyPI) bis zu deinem Build. Ein Angreifer kann an jedem Glied einsteigen:

  • ein gekapertes Konto eines Autors: eine neue Version mit schädlichem Code;
  • Typosquatting: ein Paket mit dem Namen eines bekannten, aber einen Buchstaben daneben (reqeusts, lodahs), das auf einen Tippfehler wartet;
  • ein Austausch unterwegs: ein Mirror, Cache oder Proxy, der unter demselben Namen und derselben Version andere Bytes liefert;
  • ein verlassenes Paket, das jemand übernimmt und Monate später „aktualisiert“.

Gegen all das gibt es zwei einfache Regeln. Wisse genau, welche Versionen hereinkommen. Und prüfe, dass die Bytes dieselben sind, die du geprüft hast.

Sieh es dir an

Der Baum und das ausgetauschte Paket

def verify(name, data, lock_file):
expected = lock_file[name]
actual = integrity(data)
if actual != expected:
print(f"{name}: MANIPULIERT")
print(f" lock: {brief(expected)}")
print(f" geladen: {brief(actual)}")
return False
print(f"{name}: OK")
return True
Schritt für Schritt, in Worten
  1. Der Shop fordert zwei Pakete an. Sie bringen drei weitere eigene mit. Alle fünf laufen im Shop.
  2. Jemand tauscht date-fmt unterwegs aus. Ohne Prüfung läuft sein Code im Shop, mit dessen Umgebung und Dateien.
  3. Die Lock-Datei enthält die genaue Version und den Hash jedes Pakets, auch der tiefen.
  4. Der Hash des Heruntergeladenen: SHA-512, in Base64 geschrieben.
  5. Derselbe Hash wie in der Lock-Datei: Das sind die Bytes, die geprüft wurden. Installiert.
  6. date-fmt hat eine Zeile mehr, und der ganze Hash ist anders. Es wird nicht installiert.
  7. Installiert: 4 von 5. Der Build hält an, und niemand führt den ausgetauschten Code aus.

Der Hash hier ist ein Verwandter des hash() aus der Lektion über Hashcodes, aber er ist kryptografisch. SHA-512 ergibt 64 Bytes, und niemand kann ein anderes Paket mit demselben Fingerabdruck machen. Der Hashcode in den Collections hat 4 Bytes und wiederholt sich leicht: Er ist gut für Buckets in einer Tabelle, aber nicht, um fremden Code zu prüfen.

Die Idee

Die Lock-Datei: die genaue Version und ihr Fingerabdruck

Das Manifest sagt, was du willst: "pdf-invoice": "^5.0.0" heißt „5.0.0 oder ein neueres 5.x“. Die Lock-Datei sagt genau, was installiert ist: die Version jedes Pakets im Baum, auch der transitiven, und einen Hash seines Inhalts. Sie liegt in Git, und der Build installiert genau das.

pip allein hat keine Lock-Datei. pip-tools (pip-compile --generate-hashes) erzeugt eine requirements.txt mit den genauen Versionen und Hashes aller Pakete, auch der transitiven:

date-fmt==2.1.3 \
--hash=sha256:9f2c…41ab

pip install --require-hashes -r requirements.txt bricht ab, wenn irgendein Hash nicht stimmt. Poetry (poetry.lock) und uv (uv.lock) tun das von allein.

Die Prüfung ist einfach: ein Hash der heruntergeladenen Bytes und ein Vergleich mit dem Eintrag. Hier ist sie als Programm, mit einem Hash in der Form von npm, sha512- und Base64:

def integrity(data):
digest = hashlib.sha512(data).digest()
return "sha512-" + base64.b64encode(digest).decode()
def brief(hash_):
return hash_[:19] + "…"
def verify(name, data, lock_file):
expected = lock_file[name]
actual = integrity(data)
if actual != expected:
print(f"{name}: MANIPULIERT")
print(f" lock: {brief(expected)}")
print(f" geladen: {brief(actual)}")
return False
print(f"{name}: OK")
return True
lock_file = {
"pdf-invoice@5.0.2": "sha512-IN0lH/YKriHbj87FMea7idgW4qdE8vgdeYTdEVVqz/GwAnMAzmU8X7TOO2TJ20CSQLaDtD9hKB7J5bw8JLos0A==",
"font-kit@3.1.0": "sha512-19+g7wktnKQh+JGfMRNeNdrG3//S+BsOUeuBcAp8p10kuHCpM3HFgbQzKnzTQ+DRLcA2b1MmnHFfHYvjFrjm9g==",
"date-fmt@2.1.3": "sha512-Jeb+bpiC7IqY/wWOY2a0umsIM6ce0XcMc8XlRr9udQktm8kZCNIV+qcJtad0Uip8hhhdn9P+8yE6p56qsZkZSw==",
"pay-sdk@4.2.0": "sha512-qyQmG1zNAk4HmkwNd+yxZ0GkyOsQoLn9CGHMW2LsBWmkhwpMo7rXHYT2keUIu6n50HuYBjv411Twk69Wqc0nig==",
"http-lite@1.8.0": "sha512-TI5SSzlpujK57oie/hoakeRXqVh5YuW22qwmU7I6moo75Gl3qXY5ZqRuEsy0sKdy6dz7uqhlJzDDliKF4tUQQA==",
}
downloaded = [
("pdf-invoice@5.0.2", "pdf-invoice 5.0.2: renders invoices to PDF".encode()),
("font-kit@3.1.0", "font-kit 3.1.0: loads and measures fonts".encode()),
("date-fmt@2.1.3", "date-fmt 2.1.3: formats dates and times\n+ one line added by the attacker".encode()),
("pay-sdk@4.2.0", "pay-sdk 4.2.0: talks to the payment provider".encode()),
("http-lite@1.8.0", "http-lite 1.8.0: a small HTTP client".encode()),
]
installed = sum(verify(name, data, lock_file) for name, data in downloaded)
print(f"Installiert: {installed} von {len(downloaded)}")
Ausgabe
pdf-invoice@5.0.2: OK
font-kit@3.1.0: OK
date-fmt@2.1.3: MANIPULIERT
lock: sha512-Jeb+bpiC7IqY…
geladen: sha512-sMR370edweyw…
pay-sdk@4.2.0: OK
http-lite@1.8.0: OK
Installiert: 4 von 5

Die Pakete hier sind ein kurzer Text, aber für den Hash gibt es keinen Unterschied: Es sind Bytes. Eine zusätzliche Zeile in date-fmt, und der Hash ist völlig anders, schon ab dem ersten Zeichen.

Probier es aus

Sage die Ausgabe voraus

Die beiden Texte unterscheiden sich nur durch ein Leerzeichen am Ende. Was gibt das aus?

a = integrity("total 49.90".encode())
b = integrity("total 49.90 ".encode())
print(len(a))
print(len(b))
print(a == b)
Antwort

Probier es aus

Ergänze

Welcher Algorithmus liefert den Hash in der Lock-Datei?

digest = hashlib.(data).digest()

Probier es aus

Finde den Fehler

Ein Kollege schreibt die Prüfung neu. Der Registry-Server liefert das Paket zusammen mit seinem Hash, also vergleicht der Kollege mit diesem. Welche Zeile ist der Fehler?

Wähle die Zeile mit dem Fehler.

Die Idee

Bekannte Schwachstellen und Aktualisieren

Die Lock-Datei schützt vor Austausch, friert aber auch die alten Fehler ein. Ein Paket ohne schädlichen Code kann eine Schwachstelle haben: einen Fehler, der einen Angreifer hereinlässt. Ein Beispiel ist Log4Shell (2021): Eine Zeile im Log eines Servers mit log4j-core vor 2.17.1 genügte, damit der Angreifer eigenen Code ausführte.

Schwachstellen werden in Datenbanken veröffentlicht (CVE, GitHub Advisory Database, OSV), und Werkzeuge vergleichen deinen Baum damit:

  • JavaScript: npm audit;
  • Python: pip-audit;
  • .NET: dotnet list package --vulnerable --include-transitive;
  • Java: OWASP Dependency-Check (ein Plugin für Maven und Gradle);
  • für alle: Dependabot und Renovate öffnen einen Pull Request mit dem Update.

Die Hygiene des Aktualisierens:

  • Klein und oft. Eine Version mit Tests dahinter zu aktualisieren ist sicher. Fünf Jahre auf einmal nicht. Hier helfen Tests als Sicherheitsnetz.
  • Kritische sofort, besonders wenn der Fix schon öffentlich ist.
  • Lies, was sich ändert, bevor du eine Hauptversion annimmst.
  • Entferne, was du nicht brauchst. Jedes Paket ist Code, den du ausführst. Sechs Zeilen eigener Code sind manchmal billiger als ein Paket mit 40 Abhängigkeiten.
  • Eine neue Abhängigkeit ist eine Entscheidung. Wer sie pflegt, wie viele Leute, wie viele Abhängigkeiten sie mitbringt, wann die letzte Version erschienen ist.
Genauer, für Neugierige

Bereiche. In npm erlaubt ^2.1.3 jedes 2.x.y ab 2.1.3, ~2.1.3 nur 2.1.y. Deshalb ergeben ohne Lock-Datei zwei Builds desselben Codes verschiedene Bäume. Bibliotheken schreiben Bereiche, um nicht alle auf eine Version zu zwingen; Anwendungen sperren den genauen Baum.

Installationsskripte. npm ci --ignore-scripts führt postinstall und Ähnliches nicht aus. Viele Projekte machen das in CI standardmäßig und erlauben Skripte nur für aufgelistete Pakete.

Eine eigene Registry. Firmen holen Pakete oft über eine eigene Proxy-Registry (Artifactory, Nexus, Azure Artifacts). Sie behält eine Kopie, lässt nur freigegebene Pakete durch und verfolgt, was wo benutzt wird.

SBOM. Eine Software Bill of Materials ist eine Liste jeder Komponente im Produkt, im Format CycloneDX oder SPDX. Kommt das nächste Log4Shell, ist die Antwort auf „sind wir betroffen?“ eine Suche darin und keine Woche Wühlen.

Signaturen. Ein Hash sagt „dieselben Bytes wie bei der Prüfung“. Eine Signatur sagt „von diesem Autor veröffentlicht“: npm provenance, Sigstore, signierte Pakete in NuGet und Maven Central.

So siehst du den Baum: npm ls --all, mvn dependency:tree, gradle dependencies, dotnet list package --include-transitive, pipdeptree.

Übung

Ein kleines Audit

Löse die Aufgabe in deinem eigenen Editor.

Installiert sind log-kit 2.14.1, http-lite 1.8.0, pay-sdk 4.2.0 und qr-code 2.9.0. Die Schwachstellendatenbank sagt (Paket, behoben in Version, Schweregrad): log-kit 2.17.1 critical, http-lite 1.8.2 high, pay-sdk 3.9.0 moderate, qr-code 2.10.0 low. Gib für jedes aus, ob es verwundbar ist und auf welche Version zu aktualisieren ist, und wie viele verwundbar sind:

Ausgabe
log-kit 2.14.1: verwundbar (critical), aktualisiere auf 2.17.1
http-lite 1.8.0: verwundbar (high), aktualisiere auf 1.8.2
pay-sdk 4.2.0: in Ordnung
qr-code 2.9.0: verwundbar (low), aktualisiere auf 2.10.0
Verwundbar: 3

Vorsicht bei qr-code: Als Text kommt "2.9.0" nach "2.10.0", weil 9 nach 1 kommt. Versionen werden Teil für Teil als Zahlen verglichen.

Weitere Aufgaben

  • Schweregrad. Sortiere die verwundbaren nach Schweregrad: critical, high, moderate, low.
  • Ein Lock aus Text. Lies die Lock-Datei als Zeilen name@version hash und prüfe damit die heruntergeladenen Pakete aus der Lektion.
  • Typosquatting. Vergleiche den Namen eines neuen Pakets mit einer Liste beliebter und warne, wenn er sich um einen Buchstaben unterscheidet (eine Änderung, Einfügung oder Löschung).

Test

Teste dich

1. Der Shop hängt direkt von pdf-invoice ab. Das hängt von date-fmt ab. Wessen Code läuft, wenn der Shop eine Rechnung druckt?
2. Die Lock-Datei von npm sagt date-fmt Version 2.1.3 mit dem Hash sha512-Jeb+…. Der Server liefert ein Paket mit dem Hash sha512-sMR3…. Was macht npm ci?
3. Was ist Typosquatting?
4. npm audit sagt, log-kit 2.14.1 hat eine kritische Schwachstelle, behoben in 2.17.1. Was ist richtig?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·