1 Punkte von GN⁺ 4 시간 전 | 1 Kommentare | Auf WhatsApp teilen
  • Supply-Chain-Angriffe sind zu einem größeren Problem geworden, weil die Kosten für die Verteilung von Software sehr stark gesunken sind und Build- und Deployment-Automatisierung weit verbreitet eingesetzt wird
  • In den 1970er Jahren gab es eine Softwarekrise, weil es schwer war, wiederverwendbare Software zu bauen; heute holen Paket-Repositories und Paketmanager Code allein anhand von Namen und Version und bauen ihn
  • Automatische Dependency-Updates sorgen über CI dafür, dass sich bösartige Änderungen schnell verbreiten, und ein guter Supply-Chain-Angriff breitet sich mit der Geschwindigkeit aus, mit der CI-Runner laufen
  • Vendoring, also alle Abhängigkeiten gemeinsam im Projekt-Repository abzulegen, vergrößert zwar das Repository, verhindert aber automatische Änderungen und macht Umfang und Kosten von Abhängigkeiten besser sichtbar
  • Es ist keine Lösung für jede Art von Software, aber viele kleine Programme können davon profitieren, plötzlich von außen veränderbare Abhängigkeiten auf 2–3 Stück zu reduzieren

Das Problem

  • Supply-Chain-Angriffe werden nicht nur deshalb immer problematischer, weil sich das Wesen von Software oder Wartung verändert hätte, sondern weil das Kostenmodell für das Teilen und Verteilen von Software extrem günstig geworden ist
  • Die Verteilung ist so billig geworden, dass viel Automatisierung eingesetzt wird, selbst wenn dabei Verschwendung entsteht, und die Automatisierung an sich ist nützlich
  • Alle paar Monate kommt es zu einem neuen Supply-Chain-Angriff, der große Teile des weltweiten Codes beschädigt

Wie wir hier gelandet sind

  • Ende der 1960er und Anfang der 1970er wussten die Menschen nicht gut, wie man wiederverwendbare Software baut; das nannte man die Softwarekrise
  • Die Nachfrage nach Software stieg exponentiell, aber die Fähigkeit, neue Software mit der geforderten Komplexität zu bauen, nahm langsamer zu
  • Diese Zeit führte zu Forschung zu Modularität, strukturiertem Programmieren und Ähnlichem; fast jedes nach 1990 entstandene Modulsystem von Programmiersprachen lässt sich in seiner Linie bis zu Modula-2 zurückverfolgen
  • In den 1990er- und 2000er-Jahren brachte das Internet stärkere Lösungen hervor, Software-Builds und -Verteilung wurden günstig, und ein großer Teil der Software, die man tatsächlich verwenden wollte, war Open Source
  • Auf Basis von CPAN, CTAN und Linux-Distributionen entstanden viele Paket-Repositories und Paketmanager; diese Werkzeuge finden, holen und bauen Software allein über Manifest-Dateien, Namen und meist ziemlich willkürliche Versionsnummern
  • Von manueller Integration zu automatischen Abhängigkeiten

    • Früher war eine gute Methode zum Bau komplexer Softwaresysteme, funktionierende Bausteine sorgfältig von Hand zusammenzusetzen; Linux-Distributionen tun im Grunde genau das
    • 2003 war es so mühsam, SDL mit allen Funktionen zu bauen, dass es Tage dauern konnte, und man muss diese Zeit nicht vermissen
    • Wenn eine Linux-Distribution als bekannte Grundumgebung vorhanden ist, kann viel maßgeschneiderte Software in ihrer eigenen Welt laufen und muss sich um andere Teile des Systems kaum kümmern
    • Wenn mit anderer Software kommuniziert wird, dann oft über Dateien oder Netzwerk-Sockets mit gut bekannten Protokollen
    • Es gibt inzwischen viel gute Software, die von Grund auf in Rust oder Go gebaut oder als Docker-Container ausgeliefert wird, und diese Software interagiert kaum mit Systembibliotheken
    • Statt sich an die von der OS-Distribution bereitgestellte Softwareauswahl anzupassen, ist es weit verbreitet, dass das Build-System die benötigten Bibliotheken direkt holt
  • Die Krise in der Gegenrichtung

    • Heute gibt es, im Gegensatz zu den 1970ern, eine Krise, in der Menschen Software zu stark wiederverwenden und Programme dadurch schlechter werden
    • Die Verteilung von Software ist immer noch sehr billig, aber die Nutzung von Software kostet weiterhin etwas
    • Lange Zeit waren die größten Kosten die Komplexität, Software zu bauen und auf einem Computer lauffähig zu machen; dieses Problem ist durch Automatisierung zu großen Teilen verschwunden
    • Jetzt wird viel mehr Software gebaut, verteilt und genutzt, und die Kosten zeigen sich in Form von Dependency Hell, Aufblähung, langen Build-Zeiten sowie verschwundenen Paketen oder Paketmanagern
    • Das größte Problem sind Supply-Chain-Angriffe
  • Wie sich Supply-Chain-Angriffe ausbreiten

    • Supply-Chain-Angriffe sind ein Problem, das so alt ist wie Open-Source-Software selbst
    • Der frühere Versuch eines bösartigen Patches für den Linux-Kernel, bei dem uid == 0 durch uid = 0 ersetzt werden sollte, war der erste in freier Wildbahn beobachtete bösartige Kernel-Patch und zählt als Versuch eines Supply-Chain-Angriffs
    • Dass Supply-Chain-Angriffe in den letzten zehn Jahren größer und problematischer geworden sind, liegt daran, dass Build-Systeme automatisiert Quellcode holen und ausliefern
    • CI-Systeme laufen normalerweise bei jeder Codeänderung oder größeren Änderung, und solche Änderungen werden automatisch für alle verfügbar, die von diesem Code abhängen
    • Auch die CI-Systeme der abhängigen Seite ziehen die Änderung und übernehmen damit den neu eingeschleusten Schadcode; ein guter Supply-Chain-Angriff verbreitet sich wie ein Lauffeuer mit der Geschwindigkeit, mit der CI-Runner ausgeführt werden
    • Es gibt Methoden, Supply-Chain-Angriffe zu verlangsamen, etwa Dependency-Cooldowns, aber dabei entstehen Debatten über Richtlinien und Verantwortlichkeiten

Die Lösung

  • Der Kern ist, Build-Systeme wie npm oder cargo nicht bei jedem Mal Abhängigkeiten automatisch von Netzwerk-Orten holen zu lassen, sondern alle Abhängigkeiten zusammen mit der Software abzulegen
  • Alle Abhängigkeiten werden in das Projekt gevendort, und die Inhalte der Upstream-Source-Control werden in das Git-Repository kopiert und committet
  • Gibt es Upstream-Updates, lädt man sie herunter und kopiert sie erneut; wenn die Handarbeit lästig wird, kann ein Build-Tool das automatisieren
  • Wenn bereits ein Lockfile vorhanden ist, muss man es nur mit dem vollständigen Source-Tree in der Versionsverwaltung verknüpfen
  • Man besitzt jede Zeile Quellcode in dem Sinn, dass sie stark kontrolliert wird
  • Kosten und Trade-offs

    • Das Repository wird größer, aber Speicherplatz ist billig
    • Übertragungskosten sind weniger billig als Speicherplatz, bleiben in dieser Diskussion aber ein in Kauf zu nehmender Faktor
    • Die Build-Zeit wirkt so, als müsste sie steigen, aber das muss nicht sein, weil diese Abhängigkeiten ohnehin schon neu gebaut wurden
    • Code-Wiederverwendung kann schwieriger werden, und bei Programmen wie Clients und Servern, die gemeinsame Protokollbibliotheken nutzen, kann das ein echtes Problem sein
    • Solche Programme haben ohnehin schon mit Versionsabweichungen zu tun und müssen damit umgehen; dass man dadurch tatsächlich aufmerksam werden muss, ist langfristig nicht unbedingt schlechter
  • Firebreaks gegen Supply-Chain-Angriffe

    • Wenn Abhängigkeiten nicht automatisch aktualisiert werden, wird jedes Paket im Ökosystem zu einer Brandschneise gegen Supply-Chain-Angriffe
    • Dieselbe Methode verhindert auch die Verbreitung von Bugfixes und Patches, aber wenn ein Fix wichtig ist, wird ihn ohnehin jemand manuell suchen
    • Änderungen, nach denen niemand sucht, sind oft nicht besonders wichtig
    • Einen ähnlichen Effekt kann man erzielen, wenn man semver oder die Vorstellung, dass „zwei unterschiedliche Code-Stände sich gleich verhalten sollten“, im Build-System aufgibt und alle Versionsnummern als voneinander unabhängige, eindeutige Werte behandelt
    • Das Problem mit semver ist, dass es nicht die reale Welt beschreibt, sondern menschliche Absichten ausdrückt, und selbst das nur funktioniert, wenn es einigermaßen korrekt verwendet wird
    • Versionen als eindeutig zu behandeln löst aber nicht die Probleme verschwindender, manipulierter oder anderweitig beschädigter Abhängigkeiten
  • Sichtbarkeit von Abhängigkeiten

    • Wenn alle Abhängigkeiten gevendort werden, bremst das nicht nur automatische Änderungen, sondern erhöht auch leicht die Kosten ihrer Nutzung
    • Diese Kostensteigerung ist nicht irreversibel; sie sorgt nur dafür, dass man beim Einsatz von Upstream-Code etwas mehr nachdenkt
    • Sie wirkt als sanfter Mechanismus, der beim Hinzufügen neuer Abhängigkeiten noch einmal fragen lässt: „Brauchen wir das wirklich?“
    • Die Sichtbarkeit von Abhängigkeiten steigt, und die hinter Abhängigkeiten verborgene Aufblähung bleibt weniger verborgen
    • Wenn man eine einfache Bibliothek hinzufügt, die nach etwa 200 Zeilen aussieht, und sie in Wirklichkeit 50.000 Zeilen umfasst, wird deutlicher, dass man innehalten und nach dem Grund fragen sollte
    • Der magische Charakter von Abhängigkeiten nimmt ab, und Fehlerpfade im Codebestand lassen sich leichter bis in fremden Code zurückverfolgen
  • Dependency-Tree und Shared-Probleme

    • Wenn alles standardmäßig gevendort wird, kann das flachere und breitere Dependency-Trees fördern
    • Bis zu riesigen Bibliotheken wie Boost oder Qt zu gehen, ist nicht wünschenswert
    • Solche riesigen Bibliotheken existieren, weil das Erstellen und Nutzen kleiner C/C++-Bibliotheken zu schwierig ist
    • Hinter Boost oder Qt steht die Annahme, dass es besser ist, wenn ein Systemintegrator wie eine Linux-Distribution die nötige Build-Arbeit einmal übernimmt, statt dass jeder sie selbst verstehen muss
    • Der eigentliche Nachteil ist, dass transitive Abhängigkeiten nicht geteilt werden
    • Wenn lib A und lib B beide von Z abhängen, ist Deduplizierung nicht unmöglich, aber schwieriger; entweder Menschen müssen es direkt tun oder es braucht raffiniertere Werkzeuge
    • Auch wenn transitive Abhängigkeiten geteilt werden, entstehen Probleme; schon transitive Abhängigkeiten überhaupt zu haben, ist ein Teil des Problems
    • Bibliotheken zu erlauben, transitive Abhängigkeiten festzulegen, bedeutet, anderen Menschen Kontrolle über das eigene Programm zu überlassen

Analyse

  • Nicht jede Software kann diesen Ansatz verwenden
  • Redis vollständig zu vendoren und zu bauen, nur als Teil eines Webapp-Backend-Deployments, ist nicht besonders vernünftig
  • Wenn das Deployment allerdings bereits mit Ansible oder Docker-Images automatisiert ist, macht man womöglich faktisch schon etwas Ähnliches
  • Es gibt eine Obergrenze für die Komplexität, die dieser Ansatz aushält, aber große Monorepo-Unternehmen wie Google und Facebook zeigen, dass diese Grenze höher liegen kann als gedacht
  • An irgendeinem Punkt treffen Abhängigkeiten auf das Betriebssystem, und das Betriebssystem ist selbst eine große Abhängigkeit mit vielen eigenen Problemen
  • Die Idee von Unikernels für Web-Backends ist attraktiv, aber es gibt praktische Tooling-Probleme, und wir sind noch nicht an diesem Punkt
  • Linux-Distributionen und Build-Umgebungen

    • Dieser Ansatz ist keine Methode, um vollständige interaktive Systeme wie Linux-Distributionen oder BSD zu bauen
    • Solche Systeme bestehen aus vielen Programmen und Bibliotheken, die zusammenarbeiten müssen, und gehören daher zu einer anderen Problemklasse
    • Wenn man dieses Prinzip konsequent zu Ende denkt, nähert man sich Ansätzen wie Nix oder Guix
    • Das Konzept, eine „Build-Umgebung“ korrekt zusammensetzen zu müssen, ist eher eine träge und unzureichende Lösung für die Frage „Wie soll man Software bauen?“
    • Dieses Konzept ist ein Überbleibsel aus der Zeit, als Software auf irgendeinem Minicomputer einmal gebaut und dann als Binärdatei breit verteilt wurde
    • Heute wird sehr viel mehr Software direkt beim Bedarf gebaut als noch in den 1970ern
  • Wo es anwendbar ist

    • Dieser Ansatz ist keine Universallösung, aber auf viele Arten von Software anwendbar und vorteilhaft
    • Die meiste Software ist klein, und große Projekte müssen viele dieser Probleme ohnehin schon lösen
    • Es gibt viele Bibliotheken, die rein rechnen oder nur über grundlegendes, portables I/O wie Dateien und Netzwerk-Sockets mit der Außenwelt in Kontakt treten
    • Beispiele wie Kompressionsbibliotheken, libcurl, TUI-Bibliotheken oder Django lassen sich als Kandidaten für Vendoring behandeln
    • Durch Vendoring kann man weitgehend vermeiden, dass Deployments oder Builds auf neuen Systemen unerklärlich kaputtgehen, weil Versionskonflikte oder Bugs aus plötzlichen Patches hereingekommen sind
    • Das Ziel ist, Abhängigkeiten, die sich ohne Vorankündigung von außen ändern können, nicht auf 200–300, sondern auf höchstens 2–3 zu begrenzen

Fazit

  • Wenn automatische Dependency-Updates reduziert werden und Projekte den Source-Code ihrer Abhängigkeiten selbst mitführen, lässt sich die automatische Ausbreitung von Supply-Chain-Angriffen verlangsamen
  • Wenn die Nutzung von Abhängigkeiten etwas teurer und ihre Sichtbarkeit höher wird, lassen sich unnötige Wiederverwendung und versteckte Aufblähung leichter erkennen
  • Dieser Ansatz passt nicht zu jedem System, hat aber für kleine Software und viele Bibliotheken praktische Vorteile

1 Kommentare

 
GN⁺ 4 시간 전
Lobste.rs-Meinungen
  • Der Zig-Paketmanager scheint mir ein ziemlich guter Kompromiss zu sein
    Jedes Paket ist per Content-Hash festgelegt, hat also gewissermaßen standardmäßig eine Lockfile, wodurch man das Problem vermeidet, dass „ein Upstream-Repository plötzlich bösartig wird“, während das Problem „das Upstream-Repository verschwindet“ bestehen bleibt
    Allerdings gibt es sowohl globale als auch lokale Caches, und da alles auf Content-Hashes basiert, kann man, wenn das Upstream-Repository verschwindet, einfach den Tarball der lokalen Kopie dort hineinwerfen, wo er gebraucht wird
    Das wirkt wie ein guter Kompromiss zwischen „den Source vendoren“ und „einfacher, wiederverwendbarer Software“

    • Das ließe sich vielleicht auch auf sämtliche Software ausweiten und wäre ziemlich cool
      Man würde alle Quellen in einem content-adressierten Speicher ablegen, und jedes Programm könnte anhand der Hashes seiner Eingaben gehasht werden
    • Stimme im Großen und Ganzen zu, frage mich aber ein wenig, wie man dieses Setup angreifen könnte
      Vermutlich müsste man die Lockfile verändern oder einen Hash-Kollision finden, und beides wirkt nicht gerade einfach
      Allerdings bin ich an das cargo-Ökosystem gewöhnt, deshalb gefällt es mir nicht ganz. Wenn man eine Abhängigkeit hochzieht, werden ihre transitiven Abhängigkeiten oft stillschweigend mit angehoben, und andere Dinge innerhalb kompatibler semantischer Versionsbereiche ändern sich ebenfalls mit
  • Als „Lieferkettenangriff“ würde ich das nicht bezeichnen, denn ohne einen unterzeichneten Vertrag mit Angebot und Gegenleistung ist es keine Lieferkette
    Davon getrennt gilt: Wenn es darum geht zu garantieren, dass sich Abhängigkeiten nicht von unten her ändern, dann sind Lockfiles mit Hashes oder Gos Verfahren der minimalen Versionsauswahl dasselbe wie das Vendoren von Abhängigkeiten
    Ich verstehe den Unterschied, dass Vendoring Reibung erzeugt, aber wenn man das ins Extrem treibt, implementiert man am Ende selbst oder, schlimmer noch, macht Abhängigkeiten zu ad hoc erzeugtem Code; dann ist es besser, Software zu verwenden, die von Domain-Experten geschrieben und gründlich geprüft wurde
    Ich habe bei Facebook in diesem Bereich gearbeitet, und das dortige Management von Drittanbieter-Abhängigkeiten würde ich niemandem empfehlen. Für direkte Abhängigkeiten eines bestimmten Rust-Crates sind in ganz fbsource gleichzeitig höchstens zwei semantisch versionsinkompatible Versionen erlaubt. Wenn man eine Abhängigkeit aktualisieren will, lädt man sich die Last auf, ganz fbsource zu aktualisieren
    Das mag für Facebook passen, aber besonders großartig oder nachhaltig wirkt es auf mich nicht

    • Ich frage mich, warum es „höchstens zwei“ sind. Geht es darum, eine schrittweise Migration von einer alten auf eine neue Version zu ermöglichen?
      Ich vermute, „nicht besonders großartig oder nachhaltig“ ist eher eine Funktion der Größenordnung als der Richtlinie selbst. Wenn man mehrere Versionen zulässt, entstehen andere Probleme, denn die meisten modernen Sprachen außer TypeScript verwenden überwiegend oder ausschließlich nominale Typen, sodass jede Breaking Change die Wiederverwendung von Typen zwischen Versionen verhindert, sofern man nicht den „semver trick“ benutzt
      Bei Log4Shell ist mir deutlich in Erinnerung geblieben, dass Unternehmen mit vielen Versionen, die überall verstreut waren, beim Upgrade mehr zu kämpfen hatten als Unternehmen mit wenigen oder festgepinnten Versionen
    • Stimmt, dann nennen wir es eben einen Abhängigkeitsangriff <3
  • Laut The Third Networking Truth gilt: „Mit genügend Schub fliegen auch Schweine. Das heißt aber nicht, dass es eine gute Idee ist“
    Viele Praktiken, die von Google/Facebook und Ähnlichen zitiert werden, funktionieren nur deshalb, weil diese Unternehmen genug Schub hineinstecken können
    Ich weiß zum Beispiel von einigen solchen Firmen, dass sie zur Unterstützung ihrer Monorepo- und abhängigkeitsspezifischen Entscheidungen Teams einsetzen, die größer sind als die gesamte Belegschaft meines Unternehmens. Sie können sich das leisten, die meisten von uns eher nicht

  • Gute Sichtweise. Ich stimme stark zu, dass „das Vendoren aller Abhängigkeiten die Kosten der Nutzung von Abhängigkeiten erhöht“
    Allerdings sollte man libcurl nicht einfach kopieren und einfügen. Für die meisten Bibliotheken ist das eine brauchbare Strategie, aber für C-Programme, die mit feindseligen Eingaben umgehen, ist das kein guter Rat. Man wird kaum besser als das Betriebssystem darin sein, libcurl sicher zu halten
    Ein Punkt, über den ich nie nachgedacht hatte, ist, dass Endnutzer-Paketmanager wie apt zuerst kamen und Paketmanager auf Sprachebene erst später, was zumindest ein wenig seltsam ist
    Ich denke, das hat tatsächlich viele Probleme verursacht. Wenn man sich rubygems aus den frühen 2000ern ansieht, ist ziemlich klar, dass man versucht hat, eine Art „apt für Ruby“ zu bauen, bei dem systemweite Installation statt projektbezogener Verwaltung der Standard war. Es hat Jahrzehnte und die Ergänzung durch bundler gebraucht, um den Schaden dieses Fehlers rückgängig zu machen; hätte man die Notwendigkeit von Projektisolierung von Anfang an anerkannt, wäre bundler nicht nötig gewesen
    Python räumt dieses Chaos noch immer auf, und bei Perl ist es vermutlich ähnlich, aber da kenne ich die Details nicht

    • Also gibt es wohl doch Grenzen :-) Schwierig ist nur, genau zu bestimmen, wo man die Linie zieht
      Historisch waren Paketmanager ursprünglich die Art, Systeme zu bauen, und solche Systeme hatten mehrere Benutzer, Desktop-Umgebungen und viele Softwarekomponenten, die zusammenarbeiten mussten
      Das Bauen von Software kostete viel Zeit und Speicher, und es gab sehr viel Software im Verhältnis zu Festplattenplatz und RAM, daher war die Wiederverwendung von Bibliotheken wichtig
      Mit dem Aufstieg von Web-Apps wurden die meisten wichtigen Rechner zu Servern, die ihr ganzes Leben lang nur wenige Programme ausführen, und Festplatten sowie RAM wurden billig genug, dass die Größe von Code-Binaries weniger wichtig wurde
      Die Werkzeuge zum Bauen von Systemen haben mit diesem Wandel nicht wirklich Schritt gehalten, und deshalb brauchen die meisten Menschen, die Software bauen, nur Werkzeuge, um ein einzelnes Programm gut zu bauen, statt ein riesiges, eng gekoppeltes System mit vielen Shared Libraries
      Parallel zu dieser Geschichte gibt es auch die Linie „C hat kein ordentliches Modulsystem“, aber das ist hier weniger wichtig
  • Vielleicht liege ich falsch, aber es scheint auch den Nachteil zu geben, dass Scanner Fehler in hineinkopierten Abhängigkeiten nicht erkennen können
    Dann könnten potenzielle Probleme, über die man sonst benachrichtigt worden wäre, still im Projekt verbleiben

    • Wenn man sieht, wie viele False Positives solche Scanner produzieren, könnte das sogar ein Vorteil sein
      Scanner sind sehr nützlich, um auf potenzielle Probleme hinzuweisen, aber es ist äußerst lästig, wenn man plötzlich geplante Arbeit verschieben muss, um etwas zu beheben, das der Scanner für ein Problem hielt, es in Wirklichkeit aber nicht ist
  • Wenn man wie vorgeschlagen alle Abhängigkeiten in die Software aufnimmt, das Upstream-Quellmanagement in ein Git-Repository kopiert und committet und den Build-Tools die Arbeit automatisieren lässt, sobald man die Handarbeit leid ist, dreht man sich dann nicht einmal im Kreis und bindet wieder Drittanbieter-Software ein, ohne sie anzuschauen?

    • Wenn man weiterliest, heißt es, man könne denselben Effekt auch erzielen, wenn man im Build-System semantische Versionen oder die Vorstellung aufgibt, dass „zwei unterschiedliche Codebasen gleich funktionieren sollten“, und stattdessen alle Versionsnummern als jeweils einzigartig und unabhängig behandelt
      Dieses Vorgehen löst aber nicht das Problem verschwindender oder manipulierter Abhängigkeiten oder das Problem, dass jemand den Paketinhalt auf andere Weise verändert. Es ist eher eine Optimierung und meiner Meinung nach eine verfrühte Optimierung. Vielleicht kommt man irgendwann dorthin, aber man sollte dort nicht anfangen