Zurück zu allen Beiträgen
Veröffentlicht am · von Renaud Deraison

Jeder hätte dieses Paket veröffentlichen können

Am 28. August öffnete jemand einen Pull Request gegen einen verbreiteten OpenAPI-Codegenerator, schrieb einen Kommentar aus zwei Wörtern darunter, und der eigene Release-Workflow des Projekts veröffentlichte zehn vergiftete Versionen auf npm — mit gültigen Provenance-Attestierungen. Der Angreifer besaß nie eine Zugangsberechtigung. Die erste Welle enthielt überhaupt kein Installationsskript und versteckte ihre Nutzlast dort, wo node-gyp Python auswertet. Das ganze Fenster dauerte drei Stunden und elf Minuten, und die Mindestalter-Sperre von Bromure Agentic Coding, standardmäßig auf zwei Tage, schlägt diese Zeit, ohne irgendetwas über den Angriff zu wissen.

Jemand öffnete einen Pull Request aus einem Fork und hinterließ einen Kommentar aus zwei Wörtern. Die Release-Pipeline des Projekts las den Kommentar, checkte den fremden Code aus, führte ihn aus und signierte, was dabei herauskam. Gestohlen hat diese Person dafür nichts.

@7nohe/openapi-react-query-codegen verwandelt ein OpenAPI-Schema in TanStack-Query-Hooks. Sie richten es auf eine Spezifikation, und es schreibt Ihnen den Client-Code. Aikido zählt mehr als 150.000 Downloads pro Woche. Man nimmt so ein Paket, weil es einem einen Nachmittag spart, und der Coding-Agent installiert es, ohne zweimal nachzufragen.

Am 28. August begann es, einen Zugangsdaten-Dieb auszuliefern.

Aikido Security veröffentlichte die Analyse noch am selben Tag, und SafeDep veröffentlichte eine eigene mit dem Mechanismus des Workflows und einer Minute-für-Minute-Chronologie. Die Schadsoftware benennt sich in ihren eigenen Zeichenketten selbst: Trinitite. Beide Teams ordnen ihr Vorgehen der Familie Mini Shai-Hulud zu, jenem sich selbst vermehrenden npm-Wurm, der sich das ganze Jahr über durch Maintainer-Konten arbeitet.

Interessant ist sein Weg auf npm.

Die Release-Pipeline nahm eine Anweisung von einem Fremden entgegen

Die release.yml des Projekts hatte einen issue_comment-Trigger. Das ist ein GitHub-Actions-Ereignis, das auslöst, wenn jemand ein Issue oder einen Pull Request kommentiert, und es ist eine verbreitete Bequemlichkeit: Maintainer verdrahten es, um ein Release durch ein getipptes Wort statt durch Klicks in einer Oberfläche auszuliefern.

Dieser hier hatte keine Prüfung der Autorenzugehörigkeit. GitHub teilt einem Workflow mit, ob die kommentierende Person der Eigentümer des Repositorys ist, ein Mitglied, ein früherer Beitragender oder eine völlig fremde Person — und der Workflow muss hinsehen. Dieser sah nicht hin.

SafeDep hat die Abfolge nachgezeichnet. Einen Pull Request aus einem Fork öffnen. npm publish darunter kommentieren. Die Pipeline wacht auf, checkt den Code im Fork des Angreifers aus und führt pnpm install aus, was den preinstall-Hook des Angreifers auf dem Runner des Projekts selbst ausführt. Dieser Job trug id-token: write — genug, um ein npm-OIDC-Token für Trusted Publishing zu prägen.

Zehn bösartige Versionen gingen hinaus, jede mit einer gültigen Provenance-Attestierung, weil die Pipeline des Projekts jede einzelne von ihnen gebaut und veröffentlicht hat. Aikidos Satz ist der, den man behalten sollte: „Wenn der Workflow selbst kompromittiert ist, wird dieses Zertifikat zu einem unzuverlässigen Vertrauenssignal.“

Prüfen Sie die Provenance eines Pakets, und Sie erfahren genau eines: ob es aus der Pipeline jenes Repositorys stammt. Hier tat es das. Niemand hat etwas gefälscht, und man kann eine korrekte Signatur über Schadsoftware halten, ohne dass eine der beiden Tatsachen falsch aussieht. Im Mai haben wir dasselbe Ergebnis über ein gestohlenes OIDC-Token eintreffen sehen. Diesmal hat der Angreifer den Diebstahl übersprungen.

ein FremderPull Request auseinem Fork, dannnpm publishrelease.ymlon: issue_commentkeine Autorenprüfungcheckt den Fork auspnpm installführt fremden Code ausid-token: writenpm OIDCTrusted PublishingToken geprägtauf Anfrage, wie geplant10 Versionenauf npm veröffentlichtgültige Provenanceund das stimmt sogarKein Schritt dieser Kette brauchte ein gestohlenes Geheimnis. Das schwache Glied lag vor jeder Signatur.
Wie zehn signierte Releases von jemandem ohne jede Zugangsberechtigung veröffentlicht wurden. Der Release-Workflow löste auf einen Kommentar hin aus, ohne zu prüfen, wer ihn geschrieben hatte, checkte Code aus dem Fork des Angreifers aus, führte ihn während der Abhängigkeitsinstallation aus und nutzte seine eigene id-token-Berechtigung, um ein legitimes npm-Veröffentlichungstoken zu prägen. Die Provenance-Attestierung auf dem Ergebnis ist zutreffend: Die Pipeline des Projekts hat es tatsächlich gebaut und veröffentlicht.

Die erste Welle hatte kein Installationsskript zu finden

Scannen Sie jene ersten acht Versionen nach gefährlichen Installations-Hooks, und Sie kommen leer zurück. Ihre package.json-Dateien deklarieren nichts, das läuft.

Die Nutzlast steckte in binding.gyp, der Build-Beschreibungsdatei, die node-gyp zum Kompilieren nativer Module verwendet. Aikido erklärt den Trick: „Wenn npm install ein Paket verarbeitet, das eine binding.gyp enthält, ruft es node-gyp auf, um das native Modul zu kompilieren. node-gyp wertet das Feld conditions in der Datei mit Python aus, was bedeutet, dass dort beliebige Python-Ausdrücke platziert werden können und diese während der Installation ausgeführt werden, selbst ohne jedes in package.json deklarierte preinstall-Skript.“

Der Angreifer schrieb also einen Python-Ausdruck in eine Build-Datei. Er läuft durch Pythons internen Unterklassenbaum, bis er catch_warnings erreicht, nutzt diese Klasse, um an __builtins__ zu kommen, importiert von dort os und ruft os.system() auf eine im Tarball mitgelieferte JavaScript-Datei auf. SafeDep merkt an, dass der Durchlauf in Unicode-Escapes geschrieben ist, sodass auch ein grep nach os.system leer zurückkommt.

Das JavaScript darunter packt drei Schichten aus — XOR, dann AES-128-GCM, dann ein kommerzieller Obfuskator —, bevor es Bun 1.4.0 von GitHub in ein temporäres Verzeichnis mit dem Präfix trinnyyyy- zieht, um die eigentliche Nutzlast auszuführen.

Neunzehn Minuten später schickte der Angreifer eine zweite Welle, bei der preinstall als Rückfalloption wieder in der package.json stand. SafeDep datiert diese Welle auf weniger als eine Minute nach dem ersten öffentlichen Sicherheitsbericht. Wer das betrieb, las die Veröffentlichungen und lieferte dagegen an.

Drei Stunden und elf Minuten

SafeDeps Chronologie läuft in UTC. Das letzte saubere Release, 3.0.2, ging am 11. August hinaus. Die erste Welle landete am 28. August um 20:00 und endete um 20:02. Die zweite lief von 20:19 bis 20:21. npm zog alle zehn Versionen gegen 23:11 zurück.

Das ergibt ein Expositionsfenster von drei Stunden und elf Minuten. Ein Projekt auf ^3.0.0, dem gewöhnlichen Caret-Bereich, den npm für Sie schreibt, löste geradewegs hinein auf. Wenn Sie in diesem Fenster eine frische Installation gefahren haben, oder Ihre CI, oder Ihr Coding-Agent, haben Sie den Wurm bekommen.

Einmal gestartet, durchkämmte er das Home-Verzeichnis mit mehr als 150 Glob-Mustern: private SSH-Schlüssel, .env-Dateien, ~/.docker/config.json, AWS-, Azure- und GCP-Zugangsdaten, npm-, PyPI- und RubyGems-Token, Kubernetes-Service-Account-Token, Kryptowährungs-Wallets. Auf derselben Liste standen ~/.claude.json, ~/.claude/ und ~/.claude/mcp.json — die Konfiguration des Coding-Agents selbst und die Adressen jedes MCP-Servers, mit dem er spricht.

In Repositorys, die er erreichen konnte, schrieb er eine .claude/settings.json mit einem SessionStart-Hook, der setup.mjs jedes Mal ausführt, wenn jemand das Projekt in Claude Code öffnet. Mit den gerade eingesammelten Registry-Token veröffentlichte er sich selbst erneut in jedem Paket, das sein Opfer auf npm, PyPI und RubyGems betreute. Das ist der Wurm-Anteil: Jede Person, bei der er landete, wurde zur Kandidatin für das nächste Release.

Zwei Tage schlagen drei Stunden

Setzen Sie nun ein Bromure-Agentic-Coding-Profil in den Weg.

Bromure führt jedes Profil als eigene virtuelle Maschine auf Apple Silicon aus, und jedes Paket, das der Agent holt (npm, PyPI, Cargo, RubyGems, Maven, NuGet, Go-Module, Packagist), läuft durch einen Proxy auf der Mac-Seite des Hypervisors, bevor ein einziges Byte den Gast erreicht.

Das Erste, was dieser Proxy prüft, ist das Alter der Version. Supply Chain → die Mindestalter-Sperre ist standardmäßig an, mit zwei Tagen als Untergrenze. Bewegliche Referenzen (latest, Caret-Bereiche, Tilde-Bereiche) lösen kommentarlos auf die neueste Version auf, die älter als die Grenze ist. Pinnen Sie etwas zu Frisches, bekommen Sie ein 451 mit einer klaren Fehlermeldung.

Halten Sie das gegen ein feindliches Fenster von drei Stunden und elf Minuten. Führen Sie am 28. August um 20:30 UTC mitten im Angriff npm install aus, mit ^3.0.0 in Ihrem Manifest, und Ihr Agent löst auf 3.0.2 vom 11. August auf. Er erfährt nie, dass 3.0.3 existiert.

Die Sperre kam ohne Signatur, ohne Reputationswert und ohne Sicherheitsmeldung dorthin. Sie las ein Veröffentlichungsdatum. Das zählt hier, denn die zweite Welle ging binnen einer Minute nach der ersten öffentlichen Warnung hinaus, und jede Verteidigung, die auf einen Bericht wartet, ist zu spät ins Rennen gegangen.

11. Aug3.0.2 — letzte saubere28. Aug, 20:00 & 20:19 UTCWelle 1 (binding.gyp), Welle 2 (preinstall)23:11 UTCnpm zieht alle zehnExposition: 3 Std. 11 Min.Sperre, standardmäßig zwei Tage: ein Caret-Bereich nimmt die neueste Version vor der Grenze — 3.0.2.
Das Angriffsfenster gegen die Standardgrenze. Zehn vergiftete Versionen existierten drei Stunden und elf Minuten lang auf npm. Bromures Mindestalter-Sperre ist ab Werk an, mit zwei Tagen als Untergrenze, sodass ein Caret-Bereich auf 3.0.2 vom 11. August zurückfällt und die vergifteten Versionen nie zu Kandidaten werden. Die Sperre fällt kein Urteil über das Paket; sie weigert sich nur, die Erste zu sein.

Was der Durchlauf findet, wenn er trotzdem läuft

Nehmen wir an, Sie haben die Sperre für ein Paket abgeschaltet, oder die Version war älter als die Grenze, bevor es jemand bemerkte. Die Nutzlast muss noch vier Dinge tun, und in einem Bromure-Profil trifft jedes davon auf eine Kontrolle, die auf dem Mac lebt, wo der Gast sie nicht erreicht.

Sie muss laufen. Supply Chain → Entfernen von Installationsskripten nimmt preinstall, install, postinstall und prepare im Fluge aus npm-Tarballs heraus, schreibt den Tarball neu und aktualisiert den Metadaten-Hash der Registry, sodass npms eigene Prüfung weiterhin besteht. Damit ist die zweite Welle erledigt. Sie können weitere Prüfungen darüberlegen: OSV-Schwachstellenabfragen und Paketfilterung über socket.dev, dessen Prüfung zum Blockieren kompromittierter Pakete genau diese Klasse abdeckt (bösartige Installationsskripte, Schadsoftware), oder Delpi als Ersatz-Registry. Keine davon fragt, ob eine Attestierung existiert — was zählt, wenn die Attestierung echt ist.

Sie muss etwas finden. Der 150-Glob-Durchlauf läuft gegen /home/ubuntu in einer virtuellen Maschine, nicht gegen das Home-Verzeichnis Ihres Macs. Ordnerfreigaben sind explizit und auf acht begrenzt. Die Zugangsdaten, die er findet, sind Attrappen: Token im Gast sind brm_…-Platzhalter, die der Host-Proxy auf der Leitung gegen die echten Werte tauscht, sodass das echte Material nie in den Adressraum der VM gelangt. Private SSH-Schlüssel sind überhaupt nicht im Gast: Ein ssh-agent pro Profil auf dem Host übernimmt das Signieren, und ~/.docker/config.json enthält gefälschtes Base64. Die kubeconfig ist synthetisch, mit Wegwerf-Client-Zertifikaten. AWS-Anfragen werden hostseitig neu signiert, sodass alles, was den Proxy umgeht, als InvalidSignatureException zurückkommt statt authentifiziert. Schalten Sie Genehmigung zur Nutzung erforderlich für eine Zugangsberechtigung ein, und jeder Austausch öffnet einen Dialog auf Ihrem Mac mit einer zeitlich begrenzten Freigabe.

Sie muss nach draußen telefonieren. Guardrails → Ausgehende Verbindungen ist ein Regelwerk im pf-Stil: eine Aktion, ein Protokoll (tcp, udp, web oder any), ein Hostname oder ein IPv4-CIDR, Ports, und für web die HTTP-Methoden, die Sie zulassen. Regeln greifen von oben nach unten, der erste Treffer gewinnt, und Nicht zugeordneter Verkehr auf Verweigern macht aus der Liste eine Erlaubnisliste. Dieselbe Richtlinie wird von unabhängigen Schichten zweimal ausgewertet: vom virtuellen Switch nach Ziel-IP und per DNS mitgelesenem Hostnamen über alle Protokolle hinweg, und vom Host-Proxy nach TLS-SNI und Methode. Eine Bun-Binärdatei zu holen und gestohlene Geheimnisse in ein Ablage-Repository zu schieben, brauchen beide ein Ziel — und Ziele müssen auf der Liste stehen.

Sie muss veröffentlichen. Guardrails → GitHub auf Nur lesen behandelt git-receive-pack als Schreibzugriff und blockiert REST-Schreibvorgänge; es liefert ein hartes 403, das der Agent als gewöhnlichen API-Fehler liest. Kein vergifteter Commit, keine tote Ablage, kein weiteres Release. Dieselben Modi decken GitLab, Bitbucket, Kubernetes, AWS, DigitalOcean und Container-Registrys ab.

Der SessionStart-Hook, den er für das nächste Mal hinterlassen wollte, liegt in einem Home-Verzeichnis, das Home löschen… wegwischt, unter Resources → Storage. Auf Basis zurücksetzen… tut dasselbe für alles, was außerhalb geschrieben wurde. Jede Entscheidung auf dem Weg, jedes erlaubte oder verweigerte Ziel, jedes Paketurteil, jeder Zugangsdaten-Tausch landet im Fenster des Sicherheitsprotokolls auf dem Host, wo Gast-Code nichts bearbeiten kann, weil Gast-Code es nicht erreicht.

was der Wurm noch braucht1. bei der Installation laufenbinding.gyp oder ein preinstall-Hook2. Zugangsdaten finden150+ Globs über das Home-Verzeichnis3. nach draußen verbindenBun holen, Daten in einer Ablage4. weiter veröffentlichenCommits pushen, Pakete neu ausliefernwo es auf dem Mac landetHost-Paket-ProxyAlter, Skripte entfernt, OSV,socket.dev- oder Delpi-Filterein VM-Home-Verzeichnisbrm_…-Platzhalter, falsches Base64,synthetische kubeconfig, keine SSH-KeysAusgehende Verbindungenhostseitig, erster Treffer gewinnt,geprüft am Switch und am ProxyGuardrails, Nur lesengit-receive-pack und REST-Schreibenliefern 403 vom Host-Proxy
Die vier Dinge, die Trinitite nach dem Start braucht, und wo jedes davon in einem Bromure-Agentic-Coding-Profil landet. Die Ausführung trifft auf das Entfernen von Skripten und auf Paketfilterung am Host-Proxy. Der Zugangsdaten-Durchlauf findet Platzhalter in einem VM-Home-Verzeichnis. Ausgehender Verkehr und Veröffentlichung treffen auf ein hostseitiges Regelwerk und auf Guardrails. Keine dieser Kontrollen lebt im Gast, also kann im Gast laufender Code sie nicht ändern.

Warum das immer wieder passiert

Trusted Publishing und Provenance sind es wert, zu existieren. Sie haben den Diebstahl des npm-Tokens eines Maintainers unterbunden, den vor einem Jahr vorherrschenden Angriff. Die Kompromittierung dieser Woche ging ohne jede Berührung eines Tokens darum herum: Eine fremde Person konnte den Release-Prozess erreichen, also signierte die Pipeline eine wahrheitsgemäße Attestierung über feindlichem Code.

Da können Sie sich nicht herausauditieren, denn der nächste Workflow, der versagt, wird jemand anderem gehören. Was Sie tun können, ist aufzuhören, die erste Maschine zu sein, die eine neue Version von irgendetwas ausführt.

Öffnen Sie ein Profil, sehen Sie unter Supply Chain nach und prüfen Sie, ob die Mindestalter-Sperre an ist. Sie sollte es sein, auf zwei Tagen, ohne dass Sie sie angefasst hätten. Dann investieren Sie zwei weitere Minuten: OSV-Schwachstellenprüfung an, socket.dev oder Delpi ausgewählt, falls Sie einen Schlüssel haben, Entfernen von Installationsskripten an. Setzen Sie in den Guardrails Nicht zugeordneter Verkehr auf Verweigern mit einer kurzen Erlaubnisliste, und stellen Sie GitHub auf Nur lesen für jedes Profil, das nichts ausliefert. Schalten Sie unter Zugangsdaten Genehmigung zur Nutzung erforderlich für alles ein, was Geld ausgeben kann.

Dann lassen Sie den Agenten installieren, was er braucht. Er bekommt 3.0.2. Installieren Sie Bromure Agentic Coding und lassen Sie die Mindestalter-Sperre in Ruhe.