Einfaches Update durch aktives Dependency Management

Spätestens seit CVE-2021-44228 (“Log4Shell”) ist klar: Entwickler:innen müssen schnell Auskunft geben können, welche externen Bibliotheken in einem Projekt genutzt werden, um diese bei Bedarf sofort auf die neueste Version updaten zu können. Hier helfen Tools, die in den Build-Prozess integriert werden und ein aktives Dependency-Management unterstützen.

Von der ersten größeren Berichterstattung bis zur Veröffentlichung eines Fixes ging diesmal - aufgrund der Schwere der Lücke - alles ziemlich schnell. Allerdings kursierten bereits wenige Stunden nach der ersten Berichterstattung schon funktionierende Exploits im Netz. Da nützte es nicht viel, dass die Open Source Entwickler:innen von Log4J so schnell reagiert hatten.

Damit sich der Schaden in Grenzen hält, muss ein Fix fast genauso schnell seinen Weg in die oft unzähligen Endprodukte finden, in der diese Bibliothek im Einsatz ist. Genau hier hakt es oft.

Vulnerability Scanner wie der OWASP Check helfen in der Regel schon sehr gut, um herauszufinden, ob vulnerable Bibliotheken überhaupt im Einsatz sind. Bei so gravierenden und gleichzeitig einfach auszunutzenden Sicherheitslücken wie Log4Shell sind aber selbst solche Tools oft zu langsam. Als die ersten Exploits kursierten war ein CVE, gegen das geprüft werden konnte, noch gar nicht geschrieben. Man war auf Mundpropaganda und die Berichterstattung im Internet angewiesen.

Die erste Frage, ob Log4J in der kompromittierten Version überhaupt im Einsatz ist, war in vielen Projekten nicht so einfach zu beantworten.

In unseren Projekten nutzen wir schon seit einiger Zeit Dependency Locking, was uns bei der Beantwortung dieser Frage und der anschließenden Mitigation enorm half.

Die Idee dahinter ist einfach: Es wird über den gesamten Abhängigkeitsbaum eine Datei erzeugt, welche die aktuell genutzten Bibliotheken mit der verwendeten Version festlegt. Das von uns im Projekt genutzte Build Tool gradle bringt von Haus aus alles notwendige dafür mit. Ähnliche Konzepte unterstützen u.a. auch der Node Package Manager npm (Frontend) und Terraform (Infra-As-Code).
Aktivieren lässt sich das Locking in gradle ganz einfach durch den Eintrag von

dependencyLocking {
    lockAllConfigurations()
}

in der build.gradle. Nach dem anschließenden Ausführen von

./gradle dependencies --write-locks

wird die Lock-Datei gradle.lockfile erzeugt. Diese Datei bestimmt dann für sämtliche Builds, welche konkrete Version verwendet wird.

Dieses Vorgehen hat gleich mehrere Vorteile:

  • Es ist für jedes Projekt offensichtlich, welche Versionen aktuell laufen. Vulnerable Versionen sind auf einen Blick schnell erkenn- und änderbar.
  • Die Datei stellen wir unter Versionskontrolle, so ist immer nachvollziehbar, welche Bibliothek, wann mit welcher Version aktiv ist bzw. war.
  • Für einen Build ist garantiert, dass sich keine Minor/Patch Versionen ändern, nur weil während des Builds eine neue Minor-Version publiziert wird. Es wird immer exakt das deployed, was auch durch die Test-Suite lief.
  • Jeder Build wird dadurch besser reproduzierbar. Mit demselben git commit purzelt am Ende nachvollziehbar exakt dasselbe Artefakt aus der Build-Pipeline.

Auf den ersten Blick scheint es wie eine Sisyphusaufgabe zu wirken, den Überblick über den Abhängigkeitsbaum zu behalten.

Es lässt sich aber recht einfach in den Entwickleralltag integrieren. Ein in unregelmäßigen kurzen Abständen wiederholt durchgeführtes ./gradle dependencies --write-locks reicht aus, da man einfach dynamische Versionsbereiche angeben kann und trotzdem einen reproduzierbaren Build erhält. Zudem versucht gradle, Versionskonflikte ganz automatisch aufzulösen. In der build.gradle steht dann z.B.

dependencies {
    implementation "commons-io:commons-io:[2.8.0,3["
    testImplementation "org.junit.jupiter:junit-jupiter:[5.8.2, 6["
    …
}

Die untere Version (hier z.B: 5.8.2) ist spezifisch und bezeichnet die Version, die mindestens verwendet werden muss, damit keine der bekannten Sicherheitslücken mehr vorkommen. Die obere Version (hier z.B. 6) ist die nächste exklusiv ausgeschlossene Version, für die man den eigenen Code anpassen müsste. Das ist bei Semantic Versioning dann meist die nächste Major-Version.

Im Fall von Log4J genügt jetzt ein einfacher Blick in die Datei gradle.lockfile, um zu sehen, welche Version der Bibliothek gerade genutzt wird. Und zwar auch dann, wenn sie nur transitiv von einer anderen Library importiert wird. Dort steht z.B.:

org.apache.logging.log4j:log4j-api:2.13.3=compileClasspath,default,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-core:2.13.3=compileClasspath,default,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-to-slf4j:2.13.3=compileClasspath,default,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath

Damit kann man auf einen Blick erkennen, dass eine vulnerable Version von Log4J im Einsatz ist. Meistens - nicht immer - reicht es, gradle im dependency Block die sichere neue Version anzugeben.

dependencies {
    implementation "org.apache.logging.log4j:log4j-api:[2.17.0,3["
    implementation "org.apache.logging.log4j:log4j-core:[2.17.0,3["
    implementation "org.apache.logging.log4j:log4j-to-slf4j:[2.17.0,3["
}

Gerade im Fall transitiver Abhängigkeiten, muss man diese ggfs. auch noch durch

configurations.all {
    // direkt gepinned auf 2.17.0, wegen Zero Day (siehe https://…)
    resolutionStrategy.force "org.apache.logging.log4j:log4j-api:[2.17.0,3["
    resolutionStrategy.force "org.apache.logging.log4j:log4j-core:[2.17.0,3["
    resolutionStrategy.force "org.apache.logging.log4j:log4j-to-slf4j:[2.17.0,3["
}

erzwingen, wenn sich die Version im Lockfile nicht durch obige Maßnahme schon wie gewünscht ändert.

Dabei zeugt es von gutem Stil, einen erklärenden Kommentar hinzuzufügen, damit später geprüft werden kann, ob die Bedingung weiterhin gültig ist. Erzwingen sollte man hier nur so viel wie unbedingt nötig.

Im Gegensatz zu Maven versucht gradle, Abhängigkeiten sehr "demokratisch" aufzulösen. Manchmal wirkt eine transitiv gezogenen Version vor einer direkt im build.gradle angegebenen - je nachdem, welche Bedingung restriktiver formuliert ist.
Wenn in einem bestimmten Fall nicht nachvollziehbar ist, warum gradle beim Schreiben der Locks nicht das gewünschte Ergebnis produziert, also z.B in einen Konflikt läuft, hilft oft ein Blick hinter die Kulissen mit

./gradle dependencyInsight --dependency what_arg --configuration where_arg

what_arg ist dabei der Suchstring (z.B. allgemein “tomcat” oder aber auch beliebig spezifisch bis hin zur Version “org.apache.tomcat:tomcat-jdbc:10.0.14”) und where_arg (optional) ist der Classpath in dem gesucht wird (z.b. compileClasspath oder testCompileCasspath).

Hat man am Ende der Bemühungen dann folgendes im Lockfile stehen

org.apache.logging.log4j:log4j-api:2.17.0=compileClasspath,default,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-core:2.17.0=compileClasspath,default,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-to-slf4j:2.17.0=compileClasspath,default,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath

so kann man sicher sein, dass auch keine andere Komponente eine vulnerable Version von Log4J ins Projekt zieht.

Was ist nun der große Vorteil von Dependency Locking? Am Ende des Tages brauchten wir durch Dependency Locking bei dringenden Sicherheitslücken wie Log4Shell nur wenige Minuten, um anfällige Komponenten zu identifizieren und nachhaltig sicherzustellen, das bestimmte Versionen gar nicht mehr verwendet werden können.

Die dabei ähnlich wie bei industriellen Fertigungsprozessen erstellte Stückliste (englisch: Bill of Materials oder BOM) im Lockfile gibt uns genau Auskunft darüber, wo und wann - sozusagen in welcher Charge - vulnerable Bibliotheken überhaupt zum Einsatz kamen, und dass sie es jetzt definitiv nicht mehr tun.

Die AutorInnen

Niels Knacke
Susanne Nikoyo