Wichtigste Erkenntnisse
- Geänderte Builds rangieren in Maven und Python vor dem Upstream, in
npmund Gradle. - Bei acht Konsistenzprüfungen in vier Paketmanagern konnte eine gepatchte Version nur dreimal überzeugen.
- Die Sichtbarkeit des Resolvers – also die Frage, ob ein Resolver von sich aus einen gepatchten Build auswählt – entscheidet darüber, ob eine Korrektur jemals ausgeliefert wird.
- Der Ansatz, der überall funktioniert, besteht darin, die gepatchte Version explizit festzulegen und die ausgelieferte Version neu zu kompilieren.
Hier ist ein Szenario, das wir schon mehr als einmal beobachtet haben. Ein Team portiert einen Sicherheitspatch rückwärts in eine Bibliothek, die es nicht aktualisieren kann, veröffentlicht die gepatchte Version in seiner internen Registry und macht weiter. Die CI ist grün. Eine Woche später meldet der Scanner die CVE immer noch, und als schließlich jemand die Sperrdatei öffnet, ist die gepatchte Version nicht vorhanden. Es ist nichts fehlgeschlagen. Der Paketmanager hat sich einfach die behobene Version angesehen, entschieden, dass sie nicht geeignet ist, und stattdessen die anfällige Version installiert.
Das ist kein Fehler in npm, oder Gradle, oder was auch immer. Jedes Ökosystem hält sich genau an die Vorgaben seiner Versionsregeln. Das Problem ist, dass diese Regeln für Upstream-Betreuer geschrieben wurden, die Releases veröffentlichen, und nicht für diejenigen, die einen Fix auf Basis des Releases eines anderen bereitstellen. Die Bereitstellung gepatchter Open-Source-Pakete über verschiedene Ökosysteme hinweg ist ein wesentlicher Teil unserer Arbeit bei SecureChain, und die wichtigste Erkenntnis, die wir dabei gewonnen haben, ist, dass die Versionsnummer, die man einem Fix zuweist, genauso wichtig ist wie der Fix selbst.
Was die Sichtbarkeit eines Resolvers bedeutet
Die Resolver-Sichtbarkeit gibt an, ob ein Paketmanager unter Berücksichtigung der bereits für ein Projekt festgelegten Versionsbeschränkungen von sich aus einen gepatchten Build auswählt. Ein Build mit hoher Resolver-Sichtbarkeit wird von bestehenden Ranges und Pins erfasst, ohne dass jemand das Manifest bearbeiten muss. Ein Build mit geringer Resolver-Sichtbarkeit verbleibt in der Registry – zwar vollständig verfügbar, wird aber vollständig ignoriert –, bis jemand ihn manuell einbindet.
Die Sichtbarkeit hängt von zwei Faktoren ab. Der erste ist die Reihenfolge, d. h., ob das Ökosystem Ihre gepatchte Version als neuer oder älter als die Upstream-Version einstuft, auf der sie basiert. Der zweite ist die Übereinstimmung, d. h., ob allgemeine Einschränkungen wie ein Caret-Bereich oder ein exakter Pin die gepatchte Version überhaupt als akzeptabel ansehen. Diese beiden Fragen werden in den verschiedenen Ökosystemen unterschiedlich beantwortet, weshalb eine Strategie, die an einer Stelle hervorragend funktioniert, an einer anderen stillschweigend scheitert.
Ein Build, vier unterschiedliche Urteile
Um dies zu veranschaulichen, haben wir ein einzelnes hypothetisches Paket in der Upstream-Version herangezogen 4.7.3, sowie eine gepatchte Version derselben Veröffentlichung, und habe beide durch die jeweilige Versionslogik des jeweiligen Ökosystems laufen lassen. Das bedeutete, dass npmeigene semver Bibliothek (7.6.0), der Versionsvergleicher von Maven 3.9.11, Gradle 8.14, das eine Abfrage gegen ein lokales Repository durchführt, und Pythons packaging Bibliothek, das ist nämlich pip dient zur Überprüfung von Versionen. Die gepatchte Version wurde wie folgt geschrieben: 4.7.3-patched.2 überall außer in Python, wo es 4.7.3+patched.2, da dies die Form ist, die die Python-Spezifikation für diese Aufgabe vorsieht. Bei jedem Durchlauf wurde eine neue Auflösung erstellt, ohne dass eine Sperrdatei im Weg stand.
Dann haben wir jedem zwei Fragen gestellt. Bei einem normalen Kompatibilitätsbereich (^4.7.0 für npm, [4.7,4.8) für Maven und Gradle, ~=4.7.0 (für Python) – welche Version ist besser? Und mit 4.7.3 Als exakte Version deklariert – welche gewinnt dann?
| Ökosystem | Rangliste der gepatchten Builds | Auswahl an kompatiblen Produkten | Auswahl aus Exact 4.7.3 |
|---|---|---|---|
| npm | Unter 4.7.3 | Upstream 4.7.3 | Upstream 4.7.3 |
| Maven | Über 4.7.3 | Gepatchte Version | Upstream 4.7.3 |
| Gradle | Unter 4.7.3 | Upstream 4.7.3 | Upstream 4.7.3 |
| Python (pip) | Über 4.7.3 | Gepatchte Version | Gepatchte Version |
Drei von acht Fällen, und zwei davon betreffen Python. Maven und Gradle lesen dieselben POM-Dateien aus denselben Repositorys und kamen dennoch zu gegensätzlichen Schlussfolgerungen darüber, welche Version neuer ist. Falls Sie das überrascht, sind Sie in guter Gesellschaft. Auch bei uns hat es einige Leute überrascht.
Warum npm Ihren Sicherheitspatch wie eine Beta-Version behandelt
npm befolgt die Regeln des Semantic Versioning, und laut SemVer kennzeichnet ein Bindestrich nach der Patch-Nummer den Beginn einer Vorabversion. Vorabversionen werden unterhalb der ihnen vorausgehenden Version sortiert, daher 4.7.3-patched.2 gilt als älter als 4.7.3. Was npm Was das betrifft, so handelt es sich bei Ihrer „hardened“-Version um einen Release-Kandidaten für die anfällige Version.
Das Abgleichen verschlimmert die Situation noch. Standardmäßig npm Bei der Bereichsanpassung werden Vorabversionen komplett übersprungen, daher ^4.7.0 und ~4.7.0 wird niemals die gepatchte Version auswählen. Selbst ein Bereich, der wie folgt geschrieben ist: ^4.7.3-patched.0, das sich ausdrücklich für Vorabversionen entscheidet, bevorzugt weiterhin die einfache Form 4.7.3 wenn beides verfügbar ist, denn 4.7.3 ist „neuer“.
Der Fall, der den Teams tatsächlich zu schaffen macht, ist unauffälliger. Angenommen, Ihre interne Registrierungsdatenbank enthält die gepatchte 4.7.3 Bau und ein altes 4.7.2, aber nicht stromaufwärts 4.7.3. Man würde erwarten, dass ^4.7.0 um auf dem Feld zu landen. In unserem Test ergab sich daraus 4.7.2, die ältere und nach wie vor anfällige Version, weil 4.7.2 war die höchste verfügbare Version, die noch keine Vorabversion war. Die Korrektur war direkt da und npm ist darüber hinweggestiegen.
Also in npm, muss die gepatchte Version eindeutig benannt werden. Wenn foo ist eine direkte Abhängigkeit; das bedeutet lediglich, dass die genaue Version angegeben werden muss dependencies. Für Kopien, die weiter unten in der Hierarchie liegen, npm Overrides sind das Werkzeug, das im Stammverzeichnis festgelegt wird package.json der Anwendung:
{
"overrides": {
"foo": "4.7.3-patched.2"
}
}
Das zwingt jede Instanz von foo an einer beliebigen Stelle im Baum zur gepatchten Version, ob transitiv oder nicht, und npm ls foo Das zeigt dir, ob es geklappt hat. Ein paar Tricks sollte man kennen. npm berücksichtigt nur Überschreibungen im Stammprojekt, sodass eine Bibliothek diese nicht für ihre Nutzer festlegen kann. Wenn foo ist ebenfalls eine direkte Abhängigkeit; ihr Eintrag in dependencies muss genau mit der Überschreibung übereinstimmen (die "$foo" das Referenzformular sorgt dafür, dass beide synchron bleiben), oder npm Die Installation schlägt fehl mit einem EOVERRIDE Fehler. Und das Override bleibt auf der alten Basis geparkt. Sobald Schiffe stromaufwärts 4.7.4, jedes Projekt, das keine eigene Überschreibung enthält, springt gerne über einen Caret-Bereich dorthin, und 4.7.4 Es wird keinen Backport geben, wenn der Fix im Upstream nie akzeptiert wurde. Deshalb betrachten wir jede Veröffentlichung eines Upstream-Patches als Anlass, den Fix auf der neuen Basis neu zu erstellen, sodass 4.7.4-patched.1 gibt es schon, bevor es jemand braucht. Yarns resolutions und pnpm‘s overrides verfolgen denselben Ansatz mit ihrer eigenen Syntax.
Maven und Gradle lesen dieselbe POM und kommen zu unterschiedlichen Ergebnissen
Der Versionsvergleicher von Maven kennt eine kurze Liste von Qualifizierern wie alpha, beta, rcund snapshot, und alles, was es nicht erkennt, wird nach der einfachen Version sortiert. In Maven also, 4.7.3-patched.2 ist neuer als 4.7.3, und eine Reihe wie [4.7,4.8) wählt es aus. Meistens gute Nachrichten.
Der Haken daran ist, dass in Maven kaum jemand Versionsbereiche verwendet. Eine einfache Versionsangabe in einer POM ist eine weiche Anforderung, und Maven löst Konflikte anhand der Entfernung vom Stamm des Baums und nicht danach, welche Version die neueste ist. Wenn also die nächstgelegene Deklaration lautet: 4.7.3, dann erhält man 4.7.3, Punkt. Die Reihenfolge bei Maven ist für gepatchte Builds geeignet, doch bei der Auflösung wird die Frage nach der Reihenfolge ohnehin selten gestellt. Um den gepatchten Build einzubinden, deklariert man ihn in dependencyManagement auf der obersten Ebene, wodurch die Vorgaben des transitiven Baums außer Kraft gesetzt werden.
Gradle verfährt bei der Reihenfolge genau umgekehrt. Hier gilt die Regel, dass ein zusätzlicher nicht-numerischer Teil die Versionsnummer verringert, und patched gehört nicht zu den wenigen besonderen Wörtern (rc, snapshot, release, sp, und noch ein paar andere), die ihren eigenen Platz in der Reihe bekommen. Also 4.7.3-patched.2 Sortiert nach 4.7.3. Gradle löst Konflikte außerdem, indem es die höchste Version im Diagramm auswählt. Wenn also eine Abhängigkeit die gepatchte Version anfordert und eine andere die 4.7.3, unser Test-Build wurde auf den Upstream-Zweig aufgelöst 4.7.3. Die anfällige Version wurde ausgewählt, da Gradle sie als neuer einstufte. Die Korrektur ist eine strenge Version, die mit strictly, entweder in der Abhängigkeit selbst oder innerhalb einer Abhängigkeitsbeschränkung, wie in version { strictly '4.7.3-patched.2' }. Eine einfache Einschränkung reicht hier nicht aus, da Gradle sie nur als Mindestwert betrachtet und 4.7.3 steht aus Sicht von Gradle bereits über der gepatchten Version.
Python macht es richtig, wenn man das richtige Trennzeichen wählt
Die Versionsspezifikation von Python, PEP 440, bietet etwas, was die anderen nicht haben, nämlich eine lokale Versionsbezeichnung, die genau für diesen Fall gedacht ist. Alles, was nach einem Pluszeichen steht, ist eine lokale Bezeichnung, und 4.7.3+patched.2 Sorten oben 4.7.3. Noch besser wäre ein öffentlicher Pin wie ==4.7.3 ignoriert beim Abgleich die lokale Bezeichnung, sodass ein Projekt, das bereits an 4.7.3 wird akzeptieren 4.7.3+patched.2 und ziehen dies vor. Es sind keine offensichtlichen Änderungen erforderlich. Es ist die einzige Zeile in der Tabelle, in der beide Antworten auf den gepatchten Build hinweisen. Sperrdateien spielen jedoch weiterhin eine Rolle. Eine Sperrdatei oder eine Anforderungen-Datei mit festgelegten Hash-Werten behält den alten Build bei, bis du ihn neu generierst, und eine Umgebung, in der bereits 4.7.3 Einmal installiert, bleibt es an Ort und Stelle, bis jemand es ausführt pip install --upgrade.
Der Haken daran ist, dass stattdessen ein „Post-Release“ verwendet wird. 4.7.3.post1 noch höher, aber ==4.7.3 stimmt nicht überein, sodass jedes Projekt, das an die exakte Upstream-Version gebunden ist, diese stillschweigend überspringt. Wenn Sie gepatchte Python-Builds veröffentlichen, ist das lokale Label dasjenige, das die gebundenen Projekte erreicht. Durch die Verwendung eines Post-Release-Builds stellen Sie sicher, dass eine Korrektur nur diejenigen erreicht, die bereits ein Upgrade durchgeführt haben.
Es gibt dabei jedoch einen praktischen Haken. PyPI akzeptiert keine Uploads mit lokalen Versionsbezeichnungen, sodass gepatchte Builds an anderer Stelle gehostet werden müssen, in der Regel in einem privaten Index. Wie dieser Index eingerichtet ist, spielt auch aus Sicherheitsgründen eine Rolle. pip Es ordnet keinen Index vor einem anderen ein. Es wählt einfach das beste Ergebnis aus allen Quellen aus, die es durchsuchen soll. Wenn man also auf pip Die Verwendung eines einzigen Index, der sowohl für Ihre Builds als auch für Upstream-Pakete dient, ist sicherer als das Hinzufügen eines zusätzlichen Index, was zu Verwirrung bei den Abhängigkeiten führen kann.
Was das bedeutet, wenn Sie gepatchte Pakete veröffentlichen oder nutzen
Die Lehre, die wir daraus gezogen haben, ist, dass man sich nicht darauf verlassen kann, dass ein Resolver von selbst eine Lösung findet. Python kommt dem noch am nächsten, und selbst dort hängt es davon ab, dass man die richtige Versionssyntax wählt. Überall sonst muss der gepatchte Build explizit festgelegt werden, sei es durch eine Überschreibung, eine dependencyManagement Eintrag oder eine strenge Variante, und alles, was ausgeliefert wird, muss neu kompiliert werden, damit der Pin tatsächlich darin enthalten ist. Wenn Sie Eigentümer der Anwendung sind, ist das eine Änderung an einer Stelle. Wenn Sie Pakete veröffentlichen, die von anderen Teams genutzt werden, und die Korrektur drei Ebenen tiefer im Baum landet, bedeutet dies, dass auch die darüber liegenden Pakete der Reihe nach neu kompiliert werden müssen, bis das Artefakt der obersten Ebene die korrigierte Version enthält, da niemand weiter unten in der Kette Ihre Überschreibung für Sie hinzufügen wird. Das ist zwar aufwändiger als die Veröffentlichung eines einzelnen Artefakts, aber es ist der einzige Ansatz, den wir gefunden haben, der sich in allen vier Ökosystemen gleich verhält.
Eine weitere Erkenntnis ist, dass man den Sicherheitsstatus nicht mehr anhand der Versionszeichenfolge ablesen sollte. Ein Suffix gibt einem Resolver Auskunft darüber, in welche Kategorie ein Build fällt. Es sagt jedoch nichts Verlässliches darüber aus, welche CVEs dieser Build tatsächlich behebt, und viele Scanner werden 4.7.3-patched.2, ordne es wieder zu 4.7.3, und jede bekannte CVE für diese Version zu kennzeichnen. Eine maschinenlesbare Aufzeichnung der behobenen Probleme, beispielsweise ein VEX-Dokument, das mit dem Build mitgeliefert wird, ermöglicht es einem Scanner, den Unterschied zu erkennen – vorausgesetzt, der Scanner liest diese Aufzeichnung tatsächlich aus.
Das ist alles nichts Außergewöhnliches. Es handelt sich lediglich um den Teil der Lieferkettensicherheit, der in den Interna von Paketmanagern verborgen ist – und genau dort verpuffen viele gute Korrekturen. Wenn Sie Release-Versionen pflegen, deren Unterstützung vom Upstream-Entwickler eingestellt wurde – und genau hier setzt der „Endless Lifecycle Support“ von TuxCare an –, lohnt es sich, einen Nachmittag damit zu verbringen, zu überprüfen, was Ihre eigenen Resolver tatsächlich installieren.
FAQ
Was versteht man unter Resolver-Sichtbarkeit?
Die Resolver-Sichtbarkeit gibt an, ob ein Paketmanager unter Berücksichtigung der bereits von einem Projekt festgelegten Versionsbeschränkungen von sich aus eine gepatchte Version auswählt. Dies hängt davon ab, wie das Ökosystem die gepatchte Version im Verhältnis zur Upstream-Version einordnet und ob allgemeine Beschränkungen wie Versionsbereiche und exakte Versionsangaben damit übereinstimmen.
Warum installiert npm keine gepatchte Paketversion?
npm behandelt jede Version mit einem Bindestrich als Suffix, wie zum Beispiel 4.7.3-patched.2, als Vorabveröffentlichung. Vorabveröffentlichungen werden unterhalb der entsprechenden Veröffentlichung sortiert und bei normalen Caret- und Tilde-Bereichsangaben übersprungen, daher npm installiert stattdessen die Upstream-Version oder eine ältere Version.
Wie erzwingt man in npm eine gepatchte transitive Abhängigkeit?
Füge ein overrides Zugang zum Stammverzeichnis package.json in der die genaue Version des Patches angegeben ist. npm verwendet dann diese Version für jede Instanz des Pakets im Abhängigkeitsbaum, einschließlich der transitiven Abhängigkeiten. Ist das Paket zudem eine direkte Abhängigkeit, so gilt dessen Version in dependencies muss mit der Überschreibung übereinstimmen, oder npm endet mit einem EOVERRIDE Fehler. Überschreibungen in einer Bibliothek package.json werden ignoriert, wenn diese Bibliothek als Abhängigkeit installiert wird.
Behandelt Maven einen benutzerdefinierten Versionsqualifizierer als neuer als die Veröffentlichung?
Ja. Maven sortiert nicht erkannte Qualifizierer hinter der einfachen Release-Version, daher 4.7.3-patched.2 ist neuer als 4.7.3. Maven löst Konflikte jedoch in der Regel anhand der Entfernung im Abhängigkeitsbaum und nicht anhand der Version, daher benötigen Sie dennoch dependencyManagement um sicherzustellen, dass die gepatchte Version ausgewählt ist.
Warum zieht Gradle die Upstream-Version einem gepatchten Build vor?
Gradle behandelt einen zusätzlichen nicht-numerischen Teil wie -patched.2 als niedriger als die Standardversion, und bei der Konfliktlösung wird die höchste Version ausgewählt. Deklarieren Sie den gepatchten Build mit strictly, entweder in der Abhängigkeit selbst oder innerhalb einer Abhängigkeitsbedingung, um dies zu erzwingen.
Wird „pip install 4.7.3+patched.2“ mit einem Pin von ==4.7.3 ausgeführt?
Ja, solange sich die gepatchte Version in einem Index befindet pip wie man sieht. Gemäß PEP 440 ignoriert ein öffentlicher Pin beim Abgleich lokale Versionskennzeichnungen, und eine lokale Version wird vor der öffentlichen sortiert, sodass bei einer Neuinstallation der gepatchte Build ausgewählt wird. Eine Umgebung, in der bereits 4.7.3 Bedürfnisse pip install --upgrade, und es muss zunächst eine Sperrdatei neu erstellt werden.
