Wenn du JavaScript oder TypeScript schreibst, hast du Dependency-Deklarationen wie ^1.7.2 schon unzählige Male gesehen. Wahrscheinlich sieht es in deiner eigenen package.json gerade genauso aus. Bei mir war das lange genauso.
Es fühlt sich sicher an. Du gibst dir damit Spielraum: Minor-Versionen und Patches laufen automatisch mit, ohne dass du darüber nachdenken musst. Aber diese Art, Dependencies zu deklarieren, tut mehr, als du vielleicht annimmst. Sie macht aus deiner package.json unbemerkt einen Pointer statt einer Spezifikation. Und ein Pointer zeigt nicht immer auf dasselbe Ziel — mit jeder neuen Version ändert sich, worauf er zeigt.
Dieser Beitrag ist eine kurze Reflexion darüber, warum ich dazu übergegangen bin, jede npm-Dependency auf eine exakte Version zu pinnen: was das konkret bedeutet, wo die echten Tradeoffs liegen, und wie eine kleine Open-Source-GitHub-Action, die ich gebaut habe — pin-npm-dependencies — dabei hilft, diese Praxis in unseren Projekten durchzusetzen.
🔍 Was bedeutet “Pinning” eigentlich?
So verstehe ich gepinnt: drei Dinge, die aufeinander aufbauen.
- Eine exakte Version in der
package.json—"axios": "1.7.2", nicht"^1.7.2"oder"~1.7.2". - Ein committetes Lockfile —
package-lock.json,yarn.lockoderpnpm-lock.yaml, eingecheckt in Git. Das friert die aufgelöste Version jeder transitiven Dependency ein, dazu einen Content-Hash für jedes Tarball. - Ein strikter Installer in CI und Docker —
npm ciinstalliert exakt das, was im Lockfile steht, und schlägt fehl, wenn das nicht geht (yarn und pnpm haben Äquivalente). Ein einfachesnpm installist da großzügiger: Fehlt das Lockfile oder passt es nicht zurpackage.json, löst es einfach neu auf und schreibt das Lockfile still um.
Schritt 2 ist bei den meisten Teams bereits Standard. Schritt 1 erfüllen deutlich weniger — und Schritt 3 fällt häufiger unter den Tisch, als du denkst. Die Integritäts-Hashes im Lockfile schützen dich gegen Manipulation an bereits gelockten Paketen. Aber sie können dich nicht schützen, wenn das Lockfile selbst neu generiert wird — jemand fügt eine Dependency hinzu, ein Renovate-PR wird gemerged, ein CI-Job führt npm install statt npm ci aus. In diesem Moment löst jede Range in der package.json frisch auf, eine neue Version kommt rein, und nichts schlägt Alarm. Genau hier setzt Schritt 1 an und schließt diese Lücke.
⚠️ Warum lockere Versionen ein Supply-Chain-Risiko sind
Diese Unberechenbarkeit ist genau die Lücke, die ein Angreifer braucht. Jede Range in deiner package.json ist eine offene Einladung, beim nächsten npm install einfach das zu nehmen, was gerade aktuell in 1.x verfügbar ist — ohne Prüfung, ohne dass es jemand bemerkt.
Was durch diese Lücke kommt, folgt fast immer demselben ernüchternden Muster: Die npm-Credentials eines Maintainers werden gephisht, oder ein populäres Paket bekommt unauffällig einen neuen Maintainer. Manchmal wird auch ein aufgegebener Paketname einfach neu registriert. Dann erscheint eine bösartige Version. Oft mit einem postinstall-Hook — einem Skript, das npm automatisch direkt nach dem Download ausführt, bevor irgendein Stück deines Codes das Paket je importiert.
Ein eingechecktes Lockfile verschafft dir einen zeitlichen Puffer. Du ziehst die neue Version nicht bei jeder Installation rein. Aber das nächste Mal, wenn das Lockfile neu generiert wird — jemand fügt eine Dependency hinzu, ein Renovate-PR wird gemerged, ein CI-Job führt npm install statt npm ci aus — löst die Range frisch auf, und die neue Version fließt rein. Es gibt keinen PR, der diese Dependency betrifft, kein echtes Review eines 200-Zeilen-Lockfile-Diffs — niemand wirft noch einen zweiten Blick darauf.
Das ist nicht theoretisch. Im März 2026 wurde axios — der meistinstallierte JavaScript-HTTP-Client der Welt — von einem staatsnahen Threat Actor kompromittiert. Die Angreifer haben den axios-Quellcode nicht einmal direkt verändert. Sie nutzten einen gekaperten Maintainer-Account, um zwei neue Versionen zu veröffentlichen, die im Verborgenen eine vorgelagerte bösartige Dependency hinzufügten, deren postinstall-Hook einen plattformübergreifenden Remote-Access-Trojaner ablegte. Das Post-Mortem der axios-Maintainer und Microsofts Threat-Intel-Analyse gehen die ganze Kette durch.
Die bösartigen Versionen waren nur wenige Stunden online — aber wer das Pech hatte, dass sein Lockfile genau in diesem Zeitfenster neu generiert wurde, hat sie automatisch mit installiert, vermutlich ohne überhaupt zu bemerken, dass sich die axios-Version geändert hatte.
Und axios ist nur der jüngste Eintrag auf einer Liste, die immer länger wird — event-stream im Jahr 2018, ua-parser-js 2021, und Dutzende mehr. Die Motive unterscheiden sich, die Angriffsfläche bleibt dieselbe.
🤖 Und dann ist da noch der AI-Aspekt
In den letzten zwei Jahren — vielleicht sogar erst in den letzten Monaten — hat sich diese Diskussion spürbar verändert. AI-Coding-Agenten wie Copilot, Cursor und Claude Code schlagen nicht mehr nur gelegentlich ein Autocomplete vor. Bei vielen Teams schreiben sie inzwischen einen erheblichen Teil der Codebasis: ganze Features, Refactorings, Dependency-Entscheidungen, Install-Befehle, CI-Workflows.
Wie groß ist dieser Anteil? Groß genug, dass Spotify kürzlich öffentlich gemacht hat, dass ihre stärksten Engineers seit Dezember keine Zeile Code mehr getippt haben. Sie shippen mit Agenten. Spotify ist damit sicher ein Extremfall. Aber die Richtung ist überall dieselbe.
Daraus folgen zwei Dinge, die Pinning wichtiger machen — nicht weniger wichtig.
Erstens erben Agenten die Defaults des Ecosystems, auf dem sie trainiert wurden. Das bedeutet: Sie schreiben ohne zu zögern "react": "^18.3.0" hin, solange dein Tooling nicht gegensteuert. Bei Hunderten kleinen Änderungen durch Agenten wächst der ungepinnte Anteil in deinem Repo schleichend.
Zweitens fällt in agentischen Flows der menschliche Review-Schritt oft weg. Wenn ein Agent autonom Dependencies installiert und Tests ausführt, fehlt der Reflex, kurz innezuhalten und zu fragen: “Moment, warum fügen wir diese Dependency überhaupt hinzu?” — ein Reflex, den ein menschlicher Reviewer manchmal hat. Die Zahl der package.json-Entscheidungen, die niemand bewusst getroffen hat, steigt weiter.
Eine CI-Guardrail, die exakte Versionen erzwingt, ist einer der billigsten Wege, die Kontrolle zu behalten, während sich der Rest des Workflows beschleunigt.
⚖️ Der ehrliche Tradeoff: Pinning ist nicht gratis
Pinning ist nicht umsonst. Wäre es das, hätte npm es längst zum Standard gemacht.
Wenn du jede Dependency pinnst, tauschst du impliziten Drift gegen expliziten Aufwand:
- Jeder Patch-Release wird zu einem kleinen PR — Dependabot oder Renovate funktionieren weiter, sie produzieren nur mehr Rauschen.
- Lockfile-Konflikte werden häufiger, wenn zwei Branches gleichzeitig Versionen bumpen.
- Wenn dein Team diese PRs nicht wirklich anschaut, hängst du bei Security-Patches hinterher und stehst am Ende schlechter da als vorher.
Pinning ist ein Constraint, den du dir bewusst auferlegst. Es zahlt sich nur aus, wenn du es mit echter Update-Disziplin paarst — automatisierten Upgrade-PRs, einem festen Zeitfenster, um sie zu reviewen, und npm ci überall, wo es zählt. Ohne das frierst du lediglich den aktuellen Stand der Sicherheitslücken in deinem Repo ein.
Was du im Gegenzug bekommst, ist konkret: Jede Dependency-Änderung taucht als reviewbarer Diff auf. Ein Upgrade wird zu einer bewussten Entscheidung. Die Chance, etwas Seltsames zu erkennen, bevor es in Production landet, steigt von praktisch null auf spürbar mehr.
🛡️ Mach einen Check daraus, keine Konvention
All das zu wissen und ein Team dazu zu bringen, sich daran zu halten, sind zwei verschiedene Probleme. Konventionen in einer README verblassen mit der Zeit; ein einziges npm install <pkg> ohne --save-exact schreibt wieder einen Caret hinein. Wenn dir Pinning wichtig ist, lohnt es sich, es so zu behandeln wie jede andere Invariante in deinem Code: als etwas, das die CI bei jedem PR prüft — nicht als etwas, das sich jeder Entwickler merken muss.
Dieser Check lässt sich einfach umsetzen — den Build fehlschlagen lassen, sobald irgendeine package.json eine nicht-exakte Version enthält, mutable Git-Refs wie #master eingeschlossen. Du kannst dir das mit ein paar Zeilen Skript selbst bauen, oder die kleine Open-Source-GitHub-Action nehmen, die ich genau dafür gebaut habe: pin-npm-dependencies. Sie funktioniert mit npm, yarn und pnpm. Kombiniere sie mit save-exact=true in deiner .npmrc, damit npm install <pkg> lokal exakte Versionen schreibt — dann hast du beide Seiten abgedeckt.
Der Punkt ist nicht das spezifische Tool. Es ist, dass Pinning nur dann von Dauer ist, wenn etwas anderes als Disziplin es durchsetzt.
🤝 Wie siehst du das?
Hat dir schon mal eine transitive Dependency ungefragt den Boden unter den Füßen weggezogen, oder hat dein Team Frieden mit Caret-Ranges geschlossen? Mich würde interessieren, wo für dich in deinem Kontext die Linie richtig sitzt.
This post was originally published in English on Medium.com.
Marco Schäck