Ein Türsteher in schwarzem Anzug, mit Ohrhörer, erhebt die Hand zu einer bestimmten „Stopp“-Geste am Eingang eines Clubs. Hinter ihm wartet eine Schlange hoffnungsvoller Gäste hinter einer goldenen Samtkordel. Eine bildliche Metapher für einen Wächter, der entscheidet, wer hineinkommt und wer nicht.

Lange Zeit war Composers Umgang mit Security-Advisories informierend, nicht durchsetzend: composer audit gab nach einer erfolgreichen Installation eine sauber formatierte Tabelle mit Advisories aus, aber der Resolver selbst prüfte nicht, ob eine Version verwundbar war.

Wenn du eine harte Garantie wolltest, dass eine bekanntermaßen unsichere Version nicht in deinem vendor-Verzeichnis landen konnte, musstest du dich auf ein Drittanbieter-Paket verlassen, meist auf roave/security-advisories. Und darauf vertrauen, dass dessen Liste der Konfliktregeln aktuell war.

Composer 2.9 hat das geändert. Der Resolver selbst weigert sich nun, Versionen mit bekannten Advisories überhaupt in Betracht zu ziehen. Standardmäßig. Jedes Mal, wenn der Abhängigkeitsgraph aufgelöst wird. Composer 2.10 wird dieselbe Maschinerie auf als Malware markierte Versionen ausweiten und dafür ein allgemeineres Konzept namens Filterlisten verwenden.

Dieser Artikel erklärt, wie die beiden Mechanismen tatsächlich funktionieren, wie sie sich zu den älteren Paketen verhalten, die sie ersetzen, und an welchen Stellen das neue Verhalten dich auf eine Weise treffen kann, wie es das alte, passive Audit nie getan hat.

Konfliktregeln als Sicherheitswerkzeug

Vor 2.9 bestand der Ansatz, verwundbare Installationen zu blockieren, in einem geschickten Missbrauch der bestehenden Konfliktauflösung von Composer. Zwei Repositories haben diesen Ansatz historisch geprägt.

FriendsOfPHP/security-advisories ist die Rohdatenquelle: eine gemeinfreie YAML-Datenbank mit PHP-Sicherheitsproblemen, eine Datei pro Advisory, organisiert nach Composer-Paketname. Jede Datei listet betroffene Branches und Versions-Constraints in Composers eigener Syntax (['>=2.0.0', '<2.0.17']). Der Contribution Guide beschreibt ausdrücklich die branches-Struktur, bei der jeder Branch ein time-Feld für den Zeitpunkt, an dem das Problem behoben wurde, und ein versions-Array mit den betroffenen Versions-Constraints enthält. Sie ist, wie in ihrer eigenen Dokumentation steht, nicht als maßgebliche Quelle gedacht. Sie ist ein praktischer Aggregator für Informationen, die anderswo leben (Hersteller-Advisories, CVE-Einträge, GitHub).

Roave/SecurityAdvisories macht aus diesen Daten etwas, womit der Composer-Resolver tatsächlich etwas anfangen kann. Das Paket liefert eine einzige composer.json, deren conflict-Abschnitt aus zwei Upstream-Quellen gespeist wird: der FriendsOfPHP-Datenbank und der GitHub Advisory Database. Sie wird stündlich neu gebaut. Du installierst es als roave/security-advisories:dev-latest unter require-dev, und ab da scheitert jeder Versuch, eine betroffene Version hinzuzufügen oder auf sie zu aktualisieren, mit einem gewöhnlichen Fehler über „widersprüchliche Constraints“. Das Paket selbst enthält keinen Code: Sein ganzer Zweck ist es, als eine Menge von Konflikten im Abhängigkeitsgraphen zu existieren.

Dieser Ansatz hat echte Grenzen. Er greift nur bei composer require und composer update. composer install aus einer gültigen Lock-Datei tut absichtlich nichts, was für reproduzierbare Deployments korrektes Verhalten ist, aber auch bedeutet, dass eine Lock-Datei, die vor der Veröffentlichung eines Advisories gepinnt wurde, die verwundbare Version auf ewig weiter installiert. Es kann nur auf der Wurzelebene des Projekts verlangt werden. Es lässt sich nicht pro Advisory konfigurieren. Es gibt keinen „dieses hier bitte ignorieren, es trifft auf uns nicht zu“-Schalter. Und die Fehlermeldung, wenn sie ausgelöst wird, sieht aus wie ein gewöhnlicher Versionskonflikt und nicht wie eine Sicherheitsmeldung, was die Analyse langsamer macht, als sie sein müsste.

Melden ohne Durchsetzen

Composer hat seit Version 2.4 einen audit-Befehl. Er liest deine composer.lock, fragt für jedes installierte Paket die Advisory-API von Packagist ab und gibt einen Bericht aus.

Die Advisory-API von Packagist.org aggregiert zwei Upstream-Quellen: die FriendsOfPHP-Datenbank und die GitHub Advisory Database. Andere Sammlungen, zum Beispiel der Security-Announce-Feed von Drupal, werden nicht direkt importiert; sie erreichen Endnutzende über separate Composer-Repositories wie packages.drupal.org, denn jedes Composer-Repository darf in seiner packages.json ausweisen, dass es Advisory-Daten anbietet. Drupal-SA-Einträge, die ohnehin in FriendsOfPHP oder der GitHub Advisory Database stehen, kommen damit auch über Packagist.org an. Advisories ohne CVE- oder GHSA-Kennung bekommen von Packagist eine synthetische Kennung mit dem Präfix PKSA-, damit sie konsistent referenziert werden können. Derselbe Datenstrom speist sowohl den audit-Befehl als auch, seit Composer 2.9, den unten beschriebenen Durchsetzungsmechanismus.

Automatic Security Blocking

Automatic Security Blocking, veröffentlicht in Composer 2.9, verschiebt die Durchsetzung von Advisories aus einem separaten Paket in den Resolver selbst. Der zugehörige Pull Request ist composer/composer#11956.

Der Mechanismus ist eine neue Stufe in der Pipeline der Abhängigkeitsauflösung, genannt SecurityAdvisoryPoolFilter. Composer baut einen Kandidaten-Pool aus jeder Version jedes Pakets auf, die die Constraints in der composer.json potenziell erfüllen könnte, und lässt dann einen SAT-Solver über diesen Pool laufen, um ein installierbares Set auszuwählen. Der neue Filter läuft zwischen diesen beiden Schritten: Nachdem der Pool gebaut wurde, aber bevor der Solver startet, fragt er jedes Repository nach Security-Advisories für die Pakete im Pool, gleicht sie mit jeder Kandidatenversion ab und entfernt jede betroffene Version. Der Solver sieht dann eine Welt, in der die verwundbaren Versionen schlicht nicht existieren.

Die ausführliche Ausgabe aus dem Pull Request macht die Stufen sichtbar:

Built pool.
Running security advisory pool filter.
Security advisory pool filter completed in 0.001 seconds
Found 105 package versions referenced in your dependency graph.
1 (1%) were filtered away.
Running pool optimizer.

Wenn das resultierende Solver-Problem nicht erfüllbar ist, weil jede Version eines benötigten Pakets herausgefiltert wurde, tut Composer nicht so, als wäre das ein allgemeiner Constraint-Fehler. Die Fehlermeldung sagt genau, was passiert ist:

Root composer.json requires doctrine/cache <=1.3.0,>=1.0.0,
found doctrine/cache[v1.0, v1.1, v1.2.0, v1.3.0] but these were not
loaded, because they are affected by security advisories.

To ignore the advisories, add ("PKSA-5g3y-77s3-wsh1") to the audit
"ignore" config. To turn the feature off entirely, you can set
"block-insecure" to false in your "audit" config.

Zwei Dinge sind zum Anwendungsbereich dieses Features erwähnenswert.

Erstens läuft er nur bei Befehlen, die den Abhängigkeitsgraphen auflösen: update, require, remove. composer install aus einer Lock-Datei ist davon unberührt, genau so, wie Christophe Coevoet es während des Reviews argumentiert hat: Filtern zur Install-Zeit würde die Reproduzierbarkeitsgarantie der Lock-Datei brechen, sobald ein Advisory erscheint, weil der Graph dann heimlich auf Maschinen neu aufgelöst würde, die ihn eigentlich nur wiedergeben sollen. Das ist eine bewusste Designentscheidung und kein Versäumnis.

Zweitens verlässt sich der Filter auf Advisories, die die konfigurierten Repositories zur Verfügung stellen. Für Packagist.org heißt das der aggregierte Feed aus FriendsOfPHP und GitHub Advisory Database. Für andere Composer-Repositories, egal ob öffentlich (wie packages.drupal.org) oder privat, heißt es das, was das Repository selbst bewirbt.

Weil das Feature im Resolver lebt, ersetzt es das, was roave/security-advisories tat. Die Release Notes zu Composer 2.9 sagen es direkt: „Wenn du derzeit roave/security-advisories nutzt, um Pakete mit bekannten Schwachstellen zu blockieren, ersetzt dieses Feature es vollständig, und du kannst die Abhängigkeit zu diesem Paket entfernen.“

Konfiguration

Das Blockieren wird über einen neuen audit-Abschnitt im config-Objekt der composer.json gesteuert. Die drei wichtigsten Einstellungen sind:

  • block-insecure (Default true)
    Auf false gesetzt fällt das Verhalten auf das von 2.8 zurück, in dem Advisories zwar gemeldet werden, aber nicht blockieren.
  • block-abandoned (Default false)
    Opt-in, um Pakete zu blockieren, die auf Packagist als „abandoned“ markiert wurden. Standardmäßig aus, weil Abandonment ein deutlich weicheres Signal ist als ein veröffentlichter CVE.
  • ignore
    Eine Map oder Liste von Advisory-IDs, die unterdrückt werden sollen. Advisories können über ihre CVE-ID, ihre GHSA-ID oder Packagists PKSA-ID referenziert werden. Die Map-Form erlaubt es, eine Begründung mitzugeben, was in der Praxis wichtig ist, weil unterdrückte Advisories im Audit-Bericht weiterhin erscheinen und die nächste Person in der Wartung wissen sollte, warum du sie unterdrückt hast.
{
  "config": {
    "audit": {
      "ignore": {
        "CVE-2026-xxxxx": "Windows-only; we deploy Linux",
        "GHSA-xxxx-xxxx-xxxx": "Patched inline via vendor override"
      }
    }
  }
}

Für einen einzelnen Lauf lässt sich das Blockieren auf zwei Wegen deaktivieren. Sowohl die Kommandozeilenoption --no-security-blocking als auch die Umgebungsvariable COMPOSER_NO_SECURITY_BLOCKING schalten es für diesen Aufruf ab. Beide sind ausdrücklich verschieden von --no-audit und COMPOSER_NO_AUDIT, die nur den Audit-Bericht unterdrücken.

In Composer 2.10 ziehen die hier beschriebenen Konfigurationsschlüssel in ein einheitliches config.policy-Objekt um, das Advisories, Malware, Abandonment und beliebige eigene Listen unter demselben Schema zusammenfasst (siehe Abschnitt „Filterlisten und Policy“ weiter unten). Die bisherigen config.audit.*-Schlüssel bleiben als Fallback erhalten, eine Deprecation-Warnung beim Lesen ist für Composer 2.11 angekündigt.

Filterlisten und Policy

Security-Advisories sind nicht der einzige Grund, warum man möchte, dass der Resolver eine bestimmte Version zurückweist. Die jüngsten Wellen von Supply-Chain-Angriffen im JavaScript-Ökosystem haben zum Beispiel deutlich gemacht, dass kompromittierte oder schlicht bösartige Releases einen separaten Datenkanal brauchen. Ein CVE bedeutet eine bekannte Schwäche in Code, den die Autorinnen und Autoren so schreiben wollten. Eine Malware-Markierung bedeutet, dass das Artefakt selbst eine Falle ist, die von einem Angreifer gestellt wurde, der an die Veröffentlichungs-Credentials gekommen ist. Beides sollte die Auflösung blockieren, aber sie kommen aus unterschiedlichen Feeds und haben unterschiedliche Vertrauensmodelle.

Composer 2.10 verallgemeinert die Pool-Filter-Stufe aus 2.9 zu einem einheitlichen config.policy-Objekt. Es fasst Security Advisories, Malware-Erkennung, aufgegebene Pakete und beliebige eigene Filterlisten unter demselben Schema zusammen. Spezifiziert wurde das Feature in composer/composer#12786. Die erste Iteration wurde als config.filter mit composer/composer#12766 veröffentlicht. Mit composer/composer#12804 wurde sie dann auf das einheitliche config.policy-Objekt umgestellt, das hier beschrieben wird. Wenn das Feature aktiviert ist, und das ist es standardmäßig, entfernt Composer jede Pool-Version, die auf einen Eintrag einer aktivierten Liste passt, und nutzt dafür dieselbe Pool-Filter-Stufe, die 2.9 für Advisories gebaut hat.

Drei eingebaute Listen werden mit Composer ausgeliefert: advisories für Security-Advisories, malware für als Malware markierte Versionen und abandoned für auf Packagist als aufgegeben markierte Pakete. Jede Liste, eingebaut oder benutzerdefiniert, folgt demselben Muster:

{
    "block": bool,                    // matching Versionen aus dem Pool entfernen?
    "audit": "ignore|report|fail",    // wie composer audit Treffer behandelt
    "ignore": { ... }                 // Ausnahmen pro Paket (universelles Format)
}

Die Defaults entsprechen den Erwartungen: advisories und malware blockieren und lassen composer audit fehlschlagen, abandoned meldet im Audit, blockiert aber nicht. Die Malware-Liste blockiert dabei nicht nur bei update/require/remove, sondern auch bei composer install. Das ist konfigurierbar über malware.block-scope mit den Werten "all" (Default), "update" oder "install". Eine Liste mit false auszustatten ist Kurzschreibweise für {"block": false, "audit": "ignore"}; true ist Kurzschreibweise für die Defaults der Liste. "policy": false deaktiviert das gesamte Feature.

Die Terminologie trennt drei Aspekte:

  • Sources sind Datenanbieter. Eingebaute Listen beziehen ihre Daten intern: Advisories über das AdvisoryProviderInterface, Abandonment aus den Paket-Metadaten. Ein Composer-Repository ist eine Source für die malware-Liste oder für eine benutzerdefinierte Liste genau dann, wenn es die jeweilige Liste in seiner packages.json bewirbt. Benutzerdefinierte Listen können zusätzlich oder ausschließlich aus expliziten HTTP-Endpunkten gespeist werden, die in der composer.json konfiguriert werden.
  • Lists sind die benannten Mengen. advisories, malware und abandoned sind eingebaut. Jeder andere Schlüssel unter policy definiert eine benutzerdefinierte Liste.
  • Entries sind die einzelnen Filter-Regeln innerhalb einer Liste.

Der erste produktive Einsatz auf Packagist.org ist die Integration mit dem Malware-Feed von Aikido, implementiert in composer/packagist#1681. Wenn eine Paket-Version auf Packagist als Malware markiert ist, bekommt die für dieses Paket ausgelieferte Metadaten-Datei einen filter-Block unter dem wohlbekannten Listennamen malware:

{
  "filter": {
    "malware": [{
      "constraint": "1.0 || 1.1 || 1.2",
      "url": "https://packagist.org/packages/vendor/name/filters",
      "reason": "Package contains malicious code",
      "id": "PKFE-xxxx-xxxx-xxxx",
      "source": "aikido"
    }]
  }
}

Die Top-Level-packages.json des Repositorys gibt an, welche Lists überhaupt unterstützt werden. metadata: true signalisiert, dass die per-Paket-Metadaten-Dateien filter-Blöcke tragen können. lists ist eine Map von Listennamen auf Objekte mit einem enabled-Flag:

{
  "filter": {
    "metadata": true,
    "lists": {
      "malware": {"enabled": true}
    },
    "summary-url": "/p2/filter-summary.json"
  }
}

Optional kann ein Repository zusätzlich eine summary-url oder eine api-url anbieten. Die summary-url liefert eine kompakte Map von Listenname auf Paketname auf Versions-Constraint, die Composer bei composer audit und composer install abholt. Bei composer update braucht Composer sie nicht, weil die Paket-Metadaten dort ohnehin per p2/-HTTP-Calls geladen werden und die filter-Einträge bereits enthalten. Die api-url akzeptiert einen POST mit den relevanten Paket-PURLs und konfigurierten Listennamen und liefert die passenden Filter-Einträge direkt zurück. Sie ist gedacht für Fälle, in denen die Summary zu groß wäre, um sie vollständig auszuliefern. Sind beide gesetzt, hat die api-url Vorrang.

Reservierte Namen dürfen weder von einem Repository als Liste angeboten noch in der eigenen config.policy als eigene Liste definiert werden. Für die eingebauten Listen sind advisories und abandoned reserviert. malware ist ausdrücklich nicht reserviert. Es ist ein wohlbekannter Name, von dem erwartet wird, dass Repositories ihn anbieten. Als eigene Liste umdefinieren darf man ihn aber nicht. Daneben gibt es eine Reihe von Namen, die für zukünftige Built-ins reserviert sind: package, packages, license, licence, licenses, licences, support, maintenance, security, minimum-release-age sowie alles, was mit ignore beginnt (der einzige unter policy erlaubte ignore-Schlüssel ist ignore-unreachable). Die aktuelle Liste pflegt die Custom-Lists-Sektion der Composer-Dokumentation.

Auf der Client-Seite kann jeder Repository-Eintrag in der composer.json einen eigenen filter-Block tragen. Damit lässt sich entweder das gesamte Repository als Filter-Source abschalten ("filter": false) oder es lassen sich einzelne, von diesem Repository angebotene Listen abwählen:

{
  "repositories": [
    {
      "type": "composer",
      "url": "https://example.org",
      "filter": {
        "untrusted-list": false
      }
    }
  ]
}

Dritte können eigene Sources anbinden, indem sie eine benutzerdefinierte Liste unter config.policy definieren und einen oder mehrere sources-Einträge mit type: "url" hinterlegen. Die Daten können sowohl aus einem Composer-Repository, das eine Liste dieses Namens anbietet, als auch aus expliziten sources-URLs kommen. Sind beide vorhanden, werden die Einträge gemischt.

{
  "config": {
    "policy": {
      "company-policy": {
        "sources": [
          {"type": "url", "url": "https://example.org/filter-list.json"}
        ],
        "block": true,
        "audit": "fail"
      }
    }
  }
}

Source-URLs müssen das https://-Schema verwenden. Einen secure-http-artigen Opt-Out gibt es nicht, weil Policy-Daten Blockieren und Audit-Reports steuern und der Transport entsprechend authentifiziert und manipulationssicher sein muss. Composer schickt an die konfigurierte URL einen POST mit den relevanten Paket-PURLs und den Namen der Listen, die dieser Source zugeordnet sind, in einem JSON-Body der Form {"packages": ["pkg://composer/vendor/name", …], "lists": ["company-policy"]}. Die Antwort liefert passende Filter-Einträge in derselben Form, die auch die Repository-Metadaten nutzen. Damit kann eine Organisation einen internen Malware- oder Policy-Feed betreiben (zum Beispiel für Lizenzverletzungen, bekannte schlechte Forks oder Pakete in Quarantäne während eines Incidents), ohne ein vollwertiges Composer-Repository hosten zu müssen. config.policy.ignore-unreachable steuert, ob nicht erreichbare Repositories und Policy-Sources den Vorgang abbrechen oder stillschweigend übersprungen werden. Der Default ist ["update", "install"], was bedeutet, dass composer audit hart fehlschlägt, wenn eine Source nicht erreichbar ist.

Gezielte Ausnahmen werden über das universelle ignore-Feld jeder Liste ausgedrückt. Die einfache Form ist ein Paketname (Wildcards wie vendor/legacy-* werden unterstützt) mit einer freitextlichen Begründung als Wert. Die detaillierte Objekt-Form ergänzt einen optionalen constraint sowie die Booleans on-block und on-audit (beide Default true). So kann ein Paket zum Beispiel aus Audit-Reports herausgehalten werden, während es beim Update weiterhin geblockt wird, oder umgekehrt.

{
  "config": {
    "policy": {
      "malware": {
        "ignore": {
          "vendor/package": "all versions, all contexts",
          "vendor/legacy-*": {"on-audit": false, "reason": "allow install but keep blocking in audit"},
          "vendor/pinned": {"constraint": "^2.0", "reason": "only v2 still in use"}
        }
      }
    }
  }
}

Die advisories-Liste ergänzt zwei spezifische Felder: ignore-id, das mit CVE-, GHSA- oder PKSA-IDs aus demselben universellen Format gespeist wird, und ignore-severity, das mit den Schlüsseln low, medium, high und critical umgeht. Die malware-Liste ergänzt ignore-source, ein Array von Source-/Repository-Namen, deren Malware-Daten ignoriert werden sollen. Das ist nützlich, wenn eine Source zu rauschig ist.

Wie eine Liste sich auf den Audit-Bericht auswirkt, steuert das audit-Feld der jeweiligen Liste mit den Werten ignore, report oder fail. fail bedeutet, dass ein Treffer einen Audit-Lauf mit einem von Null verschiedenen Exit-Code beendet. report meldet ohne zu scheitern. ignore schweigt.

Das gesamte Policy-Feature wird mit "config": {"policy": false} in der composer.json deaktiviert, oder mit COMPOSER_POLICY=0. Einzelne Listen können auf dieselbe Weise abgeschaltet werden, indem ihr Wert auf false gesetzt wird. Pro Lauf deaktiviert --no-blocking (oder COMPOSER_NO_BLOCKING=1) jegliches Blockieren für diesen einen Aufruf, ohne den Audit-Bericht zu unterdrücken. Der ältere Schalter --no-security-blocking und seine Umgebungsvariable COMPOSER_NO_SECURITY_BLOCKING bleiben als deprecated Alias erhalten. Sie deaktivieren in 2.10 aber nicht mehr nur das Advisory-Blockieren, sondern alles, um mit dem Hilfetext konsistent zu sein.

Die in Composer 2.9 eingeführten config.audit.*-Schlüssel funktionieren in 2.10 weiterhin als Fallback für Projekte, die noch nicht auf config.policy migriert haben. Der Fallback wird pro eingebauter Liste getrennt entschieden, und die beiden Listen advisories und abandoned sind dabei voneinander unabhängig. Sobald irgendetwas unter policy.advisories gesetzt ist, werden alle Advisory-bezogenen audit.*-Schlüssel (block-insecure, ignore, ignore-severity) komplett ignoriert; policy.abandoned bleibt davon unberührt und kann weiter aus audit.* gespeist werden. Spiegelbildlich verdrängt jeder Eintrag unter policy.abandoned die Abandonment-bezogenen audit.*-Schlüssel, lässt policy.advisories aber in Ruhe. Eine Mischung innerhalb derselben Liste wird nicht unterstützt. Wer also policy.advisories.block setzt, kann sich nicht darauf verlassen, dass audit.ignore-severity weiter greift. Migrationsunterstützung mit einer Deprecation-Warnung beim Lesen von audit.*-Schlüsseln wird voraussichtlich mit Composer 2.11 ausgeliefert.

Architektonisch ist das dieselbe Pool-Filter-Stufe, die mehrere Jobs erledigt: Advisories sind eine Datenart, die der Filter konsumiert, Malware-Markierungen eine andere, Abandonment eine dritte, und zukünftige Kategorien fügen sich in dieselbe Pipeline ein.

Wo passt der alte Stack hinein?

FriendsOfPHP/security-advisories existiert weiterhin und ist weiterhin wichtig, aber jetzt als Upstream-Datenquelle und nicht mehr als direkter Eingang in dein Projekt. Packagist.org nimmt die Daten (zusammen mit denen aus der GitHub Advisory Database) in die Advisory-API auf. Der Pool-Filter von Composer 2.9 liest diese API. Du bekommst dieselbe Abdeckung ohne eine Abhängigkeit in deiner composer.json. Mitwirkende bei FriendsOfPHP verbessern die Daten weiter, aber diese Arbeit erreicht Endnutzende jetzt über den Resolver und nicht mehr über eine neu gebaute Konflikt-Liste.

Roave/SecurityAdvisories wird ausdrücklich als überholt beschrieben. Es zusätzlich zum Blockieren von 2.9 installiert zu lassen, ist harmlos. Die beiden Mechanismen sind sich bei fast allen Versionen einig. Aber es fügt eine Abhängigkeit hinzu, verlangsamt die Auflösung ein wenig und bietet nichts, was das eingebaute Feature nicht bietet. Die Migration ist eine Entfernung in einer Zeile.

Das Einzige, was Roaves Paket noch besser kann, ist ein enger Randfall: das Blockieren zur composer require-Zeit mit einem veralteten Datensatz, auf einer Maschine, deren Composer-Version älter als 2.9 ist. Wenn du Composer nicht aktualisieren kannst, bleibt das alte Paket die beste Option. Überall sonst gilt die Empfehlung aus den Release Notes zu Composer 2.9: entferne es.

Ein Beispiel aus der Praxis

Eine nützliche Illustration für das Verhalten des Features, und für eine seiner scharfen Kanten, ist das, was heute im Zusammenhang mit einer Sicherheitslücke in PHPUnit 12.5.21 und PHPUnit 13.1.5, die ich gestern veröffentlicht habe, passiert ist. Heute Morgen erreichten mich per E-Mail, über Chatnachrichten und Telefonanrufe sowie hier und hier im Issue Tracker von PHPUnit Meldungen, dass PHPUnit 11 und frühere Versionen nicht mehr installiert werden könnten.

Die Abfolge, die dazu geführt hat, lohnt sich durchzugehen, denn sie zeigt, wie sich das Blockier-Feature mit normaler Constraint-Auflösung kombiniert:

  1. Ich veröffentliche PHPUnit 12.5.22 und PHPUnit 13.1.6 mit dem Sicherheitsfix.
  2. Ich veröffentliche Informationen über die Sicherheitslücke, die in PHPUnit 12.5.22 und PHPUnit 13.1.6 behoben ist, hier. Da nur PHPUnit 12.5.21 und PHPUnit 13.1.5 von dem Problem betroffen waren, trage ich 12.5.21 und 13.1.5 als betroffene Versionen ein.
  3. GitHub veröffentlicht das Security-Advisory offiziell in der GitHub Advisory Database.
  4. Packagist importiert das Security-Advisory aus der GitHub Advisory Database.
  5. Composer 2.9 beginnt, die Installation aller PHPUnit-Versionen mit einer Versionsnummer kleiner als 12.5.22 zu blockieren. Darunter sind auch alle Versionen von PHPUnit 11, obwohl keine von ihnen die Sicherheitslücke enthält.
  6. Mich erreichen Berichte, dass sich beispielsweise PHPUnit 11 nicht mehr installieren lässt. Die Ursache ist schnell gefunden: GitHub hat ohne ersichtlichen Grund und ohne Rücksprache mit mir die betroffenen Versionen zu <= 12.5.21 und >= 13.0.0, <= 13.1.5 geändert.
  7. Ich erstelle diesen Pull Request für die GitHub Advisory Database, um die Angabe der betroffenen Versionen zu korrigieren.
  8. Ich spreche mit Nils Adermann und Jordi Boggiano, den Maintainern von Composer und Packagist, über diese Situation und erfahre, dass Daten aus FriendsOfPHP/security-advisories Vorrang vor Daten aus der GitHub Advisory Database haben.
  9. Ich schicke einen Pull Request für FriendsOfPHP/security-advisories mit den korrekten Angaben zu den betroffenen Versionen, den Nils Adermann kurz darauf akzeptiert.
  10. Packagist importiert das Security-Advisory aus FriendsOfPHP/security-advisories.
  11. Jetzt lässt sich beispielsweise PHPUnit 11 wieder mit Composer 2.9 installieren. Jedenfalls dann, wenn roave/security-advisories nicht verwendet wird.

Auch wenn diese Situation mir einen Teil meines Wochenendes geraubt hat, zeigt sie, dass das mit Composer 2.9 eingeführte Automatic Security Blocking wie beabsichtigt funktioniert.

Ich hege keinen Groll gegen diejenigen, die das Problem an einem Samstag gemeldet haben, und ich hoffe, dass Nils und Jordi mir verzeihen, dass ich sie an einem Samstag um Hilfe gebeten habe. Was mich aber wirklich frustriert: GitHub hat die Angaben zu den „Affected versions“, die ich sorgfältig gemacht hatte, verändert, als sie mein Advisory veröffentlicht haben.

Es war natürlich genau diese Situation, die mich dazu gebracht hat, diesen Artikel an einem Samstag zu schreiben und zu veröffentlichen.

Praktische Hinweise

Für neue Projekte: nichts tun. Blockieren ist standardmäßig aktiv, die Defaults sind sinnvoll, und die Fehlermeldungen verweisen auf die Konfigurationsschlüssel, die du brauchst.

Für bestehende Projekte, die noch roave/security-advisories verwenden: nimm es beim nächsten Composer-Update aus require-dev heraus. Die nativen Features von Composer sind äquivalent oder besser.

Für Projekte, in denen ein Advisory ein Update blockiert, das du nicht sofort übernehmen kannst, weil der Fix in einer Major-Version liegt, auf die du noch nicht migriert bist, oder weil das Advisory deinen Anwendungsfall nicht trifft: verwende unter config.policy.advisories.ignore-id die ausführliche Form mit dem zu unterdrückenden Identifier, einem optionalen constraint und einer schriftlichen Begründung. Wer noch keine Migration auf config.policy vorgenommen hat, kann übergangsweise weiterhin config.audit.ignore verwenden.

Für CI-Pipelines, die während eines Incidents temporär umgehen müssen: nutze COMPOSER_NO_BLOCKING=1, statt die composer.json zu verändern. Es ist im Build-Log sichtbar, es bleibt nicht bestehen, und es wird nicht versehentlich committet. Die ältere Variable COMPOSER_NO_SECURITY_BLOCKING bleibt als deprecated Alias erhalten und tut in 2.10 dasselbe.

Für das Blockieren abgekündigter Pakete: überleg dir, es einzuschalten ("config": {"policy": {"abandoned": {"block": true}}}), sobald dein Projekt sauber ist. Es produziert mehr False Positives als das Blockieren per Advisory, aber es macht auch Supply-Chain-Risiken sichtbar, über die dir sonst nichts in deiner Toolchain etwas sagt.

Für Maintainerinnen und Maintainer privater Composer-Repositories: Das Metadaten-Format für Advisories (ab 2.9) und für Filterlisten (ab 2.10) ist dokumentiert und stabil. Interne Sicherheitsdaten darüber bereitzustellen, ist der richtige Integrationspfad, und genau das, was Private Packagist für seine Kunden tut.

Fazit

Was Composer 2.9 getan hat, ist auf dem Papier schmal: eine einzelne Filterstufe in den Resolver für Abhängigkeiten einzufügen. Aber in der Praxis verschiebt es einen Baustein der Supply-Chain-Sicherheit aus einem optionalen Drittanbieter-Paket in den Standard-Installationspfad jedes PHP-Projekts. Composer 2.10 wird denselben Mechanismus verallgemeinern, sodass Malware-Markierungen, und was auch immer als Nächstes kommt, sich in ihn einklinken können, ohne das Gerüst neu erfinden zu müssen.

Die alten Datenquellen sind nicht verschwunden. Das YAML von FriendsOfPHP ist immer noch das Rohmaterial hinter einem großen Teil der Advisories, die das neue System durchsetzt; es erreicht Endnutzende jetzt einfach über einen anderen Ausliefermechanismus. Roaves Paket hat seinen Job ein Jahrzehnt lang gemacht, und ein Teil dieses Jobs war, zu beweisen, dass das Ökosystem dieses Verhalten standardmäßig haben will. Was es jetzt endlich hat.

Update vom 19. April 2026
Zeitleiste zu GHSA-qrr6-mg7r-m243 um weitere Details ergänzt.
Feedback von Nils Adermann und Jordi Boggiano eingearbeitet.

Update vom 20. April 2026
Feedback von Christophe Coevoet eingearbeitet.

Update vom 21. April 2026
Durch die oben beschriebenen Ereignisse veranlasst, habe ich die Security Policy für PHPUnit grundlegend überarbeitet.

Update vom 21. Mai 2026
Der Abschnitt „Filterlisten und Policy“ wurde nach der Veröffentlichung von Composer 2.10.0-RC2 überarbeitet, um den aktuellen Stand zu berücksichtigen.

Update vom 21. Mai 2026
Feedback von Stephan Vock eingearbeitet.