Ein junger Mann in Jeans und einem weißen Langarmhemd, in Schwarz-Weiß dargestellt, schwingt einen großen grünen Schild mit einem leuchtend roten Häkchen darauf und schlägt damit eine Flut zerknüllter Papierbälle vor einem sanften pfirsichfarbenen Hintergrund weg – eine Metapher dafür, wie Tests als aktive Verteidigung eingesetzt werden, um Sicherheitslücken auszuschalten, bevor sie in die Produktion gelangen können.

Dieser Artikel basiert auf einem Vortrag, den ich heute auf der International PHP Conference in Berlin zum ersten Mal gehalten habe.

Was wäre, wenn sich jede Sicherheitslücke in deiner PHP-Anwendung auf einen fehlenden Test zurückführen ließe? Das ist eine provokante Frage, und eine, die ich dem Publikum stelle, wenn ich meinen Vortrag „Test-Driven Security“ halte. Die Antwort liegt, so meine Überzeugung, näher an „ja“, als viele wahrhaben wollen.

Über Jahrzehnte haben wir Sicherheit als eigenständige Disziplin behandelt. Anwendungscode wird von Entwicklerinnen und Entwicklern geschrieben, und anschließend wird Sicherheit von Spezialisten „hinzugefügt“, die Scanner laufen lassen, Audits durchführen oder Penetrationstests vornehmen. Diese Trennung hat Werkzeuge, organisatorische Strukturen und sogar unser Denken über den Softwareentwicklungszyklus geprägt. Sie hat auch einen stetigen Strom an Schwachstellen hervorgebracht, die in dem Moment hätten verhindert werden können, in dem der Code geschrieben wurde, von genau den Personen, die ihn geschrieben haben, mit Werkzeugen, die sie längst hatten.

Die These, die ich in diesem Artikel vertreten möchte, ist einfach: Sicherheit ist keine eigene Phase, sondern ein Testproblem. Und wenn es ein Testproblem ist, dann lässt sich die Disziplin, die wir bereits auf die funktionale Korrektheit anwenden, Test-Driven Development, unmittelbar darauf übertragen.

Fehlschlagende Tests als Beweis

Die meisten Diskussionen über Anwendungssicherheit beginnen mit einer Schwachstelle und enden mit einer Korrektur. Die Schwachstelle wird beschrieben, der Exploit demonstriert, der Patch eingespielt, und alle gehen zur Tagesordnung über. Was in diesem vertrauten Ablauf fehlt, ist das, was den Vorfall überdauern sollte: ein Test, der fehlschlägt, wenn die Schwachstelle vorhanden ist, und besteht, wenn sie behoben wurde.

Ein fehlschlagender Test ist nicht bloß ein Hilfsmittel beim Debugging. Er ist ein Beweis: eine präzise, ausführbare, in der Versionskontrolle abgelegte Aussage, die besagt: „Genau diese Schwachstelle existiert an genau dieser Stelle, und so lässt sie sich erkennen.“ Ist ein solcher Test einmal vorhanden, kann die Schwachstelle nicht klammheimlich zurückkehren. Eine Regression färbt den Test rot, und der Build bricht ab. Der Test wird Teil des Sicherheitsvertrags, den der Code zu erfüllen hat.

Dieser Perspektivwechsel, von „Sicherheit als Audit“ zu „Sicherheit als Zusicherung“, ist der Kern von Test-Driven Security. Statt zu fragen „Ist dieser Code sicher?“, was eine offene und letztlich unbeantwortbare Frage ist, fragen wir: „Welche konkreten Schwachstellen behaupte ich, dass dieser Code nicht hat, und wo ist der Test, der das beweist?“

CWE als Checkliste

Der Einwand, den ich an dieser Stelle am häufigsten höre, ist berechtigt: Wenn ich nicht weiß, worauf ich testen soll, wie kann ich den Test schreiben? Funktionale Tests sind im Prinzip einfach, weil Entwicklerinnen und Entwickler wissen, was die Funktion tun soll. Sicherheitstests setzen Wissen darüber voraus, was die Funktion nicht erlauben soll, und dieses Wissen ist in Entwicklungsteams ungleich verteilt.

Glücklicherweise stellt die Sicherheits-Community genau dieses Wissen seit langer Zeit zusammen. Die Common Weakness Enumeration, die von MITRE gepflegt wird, ist ein Katalog von Software-Schwachstellentypen. Jeder Eintrag beschreibt eine Klasse von Fehlern, die Bedingungen, unter denen sie auftreten, und die Folgen, wenn sie ausgenutzt werden. Die CWE-Liste ist kein Marketingprodukt. Sie ist das strukturierte, peer-reviewte Vokabular, mit dem Forschende und Tool-Hersteller über Software-Schwachstellen sprechen.

Für Entwicklerinnen und Entwickler, die Test-Driven Security praktizieren, hat die CWE-Liste einen sehr praktischen Nutzen: Sie ist eine Checkliste. Für jedes Stück Code, das eine Datenbank, ein Template, die Shell, das Dateisystem oder eine Autorisierungsentscheidung berührt, gibt es eine kleine Auswahl an CWE-Einträgen, die beschreiben, was schiefgehen kann. Jeder dieser Einträge lässt sich in einen oder mehrere Tests übersetzen. Die Liste ist lang, aber endlich, und die meisten Anwendungen interagieren nur mit einem kleinen Teil davon.

Vier Schwachstellen, ein Muster

In der Präsentation arbeite ich mich durch vier Schwachstellen, die zusammen den Großteil dessen abdecken, was in einer typischen PHP-Webanwendung schiefgeht: SQL Injection (CWE-89), Cross-Site Scripting (CWE-79), OS Command Injection (CWE-78) und Improper Authorization (CWE-285). Die Beispielanwendung, eine kleine Symfony-Notiz-App namens NoteHub, enthält je eine bewusst eingebaute Instanz davon. Für jede zeige ich den Fehler, schreibe einen Test, der ihn aufdeckt, beobachte, wie der Test fehlschlägt, wende die Korrektur an und beobachte, wie der Test grün wird.

Ich werde die Beispiele hier nicht wiederholen. Die Präsentationsmaterialien und die zugehörige Beispielanwendung sind der richtige Ort, um sie in Aktion zu sehen, denn der Rhythmus von Red/Fix/Green ist der entscheidende Punkt, und ein statischer Artikel kann ihn nicht reproduzieren. Hervorheben möchte ich aber das Muster, das alle vier Schwachstellen teilen, denn das Muster ist wichtiger als jedes einzelne Beispiel.

In jedem Fall ist die Schwachstelle kein geheimnisvoller Seiteneffekt eines obskuren Sprachfeatures. Sie ist das Ergebnis eines wohlverstandenen Fehlers: Benutzereingaben werden in einen Kontext (eine SQL-Anfrage, ein HTML-Dokument, eine Shell-Kommandozeile, eine Autorisierungsentscheidung) hineingegeben, ohne zuvor als nicht vertrauenswürdig behandelt zu werden. Und die Lösung ist ebenso wohlverstanden: parametrisierte Queries, automatisches Output-Escaping, Argument-Escaping, eine explizite Eigentümerprüfung. Nichts davon ist neu für jemanden, der einen Leitfaden zu sicherer Programmierung gelesen hat.

Was durchgängig unterschätzt wird, ist, wie einfach sich jede dieser Schwachstellen als fehlschlagender Test ausdrücken lässt. Der Test für SQL Injection ist ein einzelner Aufruf des Repositories mit einer Eingabe, die nicht alle Zeilen zurückliefern darf. Der Test für Stored XSS ist ein einzelnes Template-Rendering, dessen Ausgabe kein literales <script>-Tag enthalten darf. Der Test für Command Injection ist ein einzelner Service-Aufruf, dessen Ausgabe nicht den Marker enthalten darf, den das injizierte Kommando erzeugt hätte. Der Test für Improper Authorization ist ein einzelner Controller-Aufruf, in dem ein Benutzer versucht, eine Aktion auf den Daten eines anderen Benutzers auszuführen. Jeder dieser Tests passt bequem auf einen Bildschirm. Jeder von ihnen verhindert, einmal geschrieben, eine ganze Klasse von Regressionen für immer.

Eingabevalidierung ist nicht alles

Die ersten drei Schwachstellen in meiner Liste (Injektion in SQL, HTML und die Shell) sind allesamt Variationen eines einzigen Themas: Nicht vertrauenswürdige Eingaben dürfen den Datenkontext, zu dem sie gehören, verlassen und die Struktur eines Befehls beeinflussen. Die Lösung hat stets dieselbe Form, auch wenn der Mechanismus sich unterscheidet: Daten und Code voneinander trennen. Wer dieses Prinzip verinnerlicht hat, greift instinktiv zu parametrisierten Queries, Escaping-Helfern und Template-Engines, die standardmäßig escapen. Auch statische Analysewerkzeuge erkennen viele Vorkommen dieser Kategorie, weil die Muster syntaktischer Natur sind.

Die vierte Schwachstelle, Improper Authorization, ist anders, und dieser Unterschied ist von Bedeutung. Sie ist kein Problem der Eingabevalidierung. Sie ist ein Logikfehler. Der Code tut genau das, wozu er geschrieben wurde; nur ist das, wozu er geschrieben wurde, falsch. Kein noch so umfassendes Escaping korrigiert einen Controller, der einen Datensatz löscht, ohne vorher zu prüfen, ob der anfragende Benutzer dazu berechtigt ist. Kein statischer Analysator kann aus der Syntax eines Löschvorgangs ableiten, dass die Geschäftsregel „Nur die Autorin oder der Autor darf eine Notiz löschen“ gilt.

Genau hier entfalten Tests den größten Nutzen. Tests können Geschäftsregeln kodieren, die kein universelles Werkzeug erraten kann. Sie können in ausführbarer Form jene Autorisierungsentscheidungen ausdrücken, die eine nützliche Anwendung von einem Datenleck unterscheiden. Bei der Autorisierung ist der Test nicht nur ein Sicherheitsnetz. Er ist die Spezifikation. An keiner anderen Stelle im System ist die Regel „Bob darf Alices Notiz nicht löschen“ in einer Form niedergeschrieben, die eine Maschine verifizieren kann.

TDD angewendet auf Sicherheit

Sind die Tests einmal vorhanden, ist der Rest der Praxis schlicht Test-Driven Development mit anderem Schwerpunkt. Die Disziplin ändert sich nicht. Du schreibst einen Test, der das gewünschte Verhalten ausdrückt, beobachtest, wie er aus dem richtigen Grund fehlschlägt, schreibst die kleinstmögliche Codemenge, die nötig ist, damit er besteht, und refaktorisierst. Neu ist allein die Quelle der Anforderung. Statt aus einem Feature-Wunsch oder einer User Story stammt sie aus einem CWE-Eintrag, einem Threat Model oder einem Vorfallsbericht.

So gerahmt wird klar, warum Test-Driven Security keine neue Methodik ist. Sie verlangt weder ein gesondertes Werkzeug noch ein eigenes Team noch einen parallelen Lebenszyklus. Sie verlangt lediglich, dass Entwicklerinnen und Entwickler Sicherheitsanforderungen als gleichrangige Anforderungen behandeln und dass sie diese Anforderungen in derselben Sprache ausdrücken, in der sie funktionale Anforderungen ohnehin ausdrücken: in der Sprache automatisierter Tests.

In einer Organisation, die TDD bereits praktiziert, ist die Einführung von Test-Driven Security im Wesentlichen kostenlos. Infrastruktur, Fähigkeiten und die kulturelle Akzeptanz von „kein Code ohne Test“ sind bereits vorhanden. Was fehlt, ist die Gewohnheit, sicherheitsorientierte Tests gemeinsam mit funktionalen zu schreiben, und das geringe Maß an Fachwissen, das nötig ist, um zu wissen, worüber sie zu schreiben sind. Die CWE-Liste schließt diese Wissenslücke.

Sicherheit ist eine Eigenschaft, keine Phase

Der eigentliche Punkt, den ich mitgeben möchte, ist, dass Sicherheit eine Eigenschaft von Software ist, keine Phase der Entwicklung. Eine Eigenschaft ist etwas, das das System zu jeder Zeit besitzt, gepflegt durch dieselben Mechanismen, die jede andere Eigenschaft pflegen, die uns wichtig ist: Typen, Tests, Code Reviews, statische Analyse, Dokumentation und die Disziplin der Menschen, die den Code geschrieben haben. Eine Phase ist etwas, das zu einem bestimmten Zeitpunkt von bestimmten Personen geschieht und endet. Phasen erzeugen Lücken zwischen sich; Eigenschaften, ihrem Wesen nach, nicht.

Sicherheit als Phase zu behandeln, ist genau das, was das vertraute Muster hervorbringt, in dem eine Schwachstelle gefunden, behoben und sechs Monate später durch ein Refactoring wieder eingeführt wird, das niemand mit dem ursprünglichen Vorfall in Verbindung bringt. Sicherheit als Eigenschaft zu behandeln, kodiert in Tests, die bei jedem Commit laufen, durchbricht diesen Kreislauf. Er vergisst nicht, er wird nicht umorganisiert, und er verlässt das Unternehmen nicht, wenn die Menschen gehen, die ihn geschrieben haben. Er bleibt im Repository, und bei jedem Build stellt er dieselbe Frage: Ist diese Schwachstelle vorhanden? Wenn ja, schlägt der Build fehl, und die Person, die sie eingeführt hat, erfährt es innerhalb von Sekunden.

Das ist es, was ich meine, wenn ich sage, dass jede Sicherheitslücke auf einen fehlenden Test zurückgeführt werden könnte. Damit will ich nicht sagen, dass Tests zu schreiben hinreichend sei, um Software sicher zu machen. Ich meine, dass es für jede Schwachstelle, die wir in Produktion finden, einen Test gibt, der sie verhindert hätte, wenn er existiert hätte. Der Test ist nicht immer einfach zu schreiben, und für manche Schwachstellenklassen erfordert es echtes Fachwissen, ihn gut zu schreiben. Aber es ist fast immer möglich, und einmal geschrieben, ist er fast immer günstig zu pflegen.

Die Werkzeuge, die wir brauchen, sind die Werkzeuge, die wir bereits haben. PHPUnit kann jeden in der Präsentation beschriebenen Test ausdrücken, ohne Erweiterungen, Plugins oder spezialisierte Sicherheits-Frameworks. Symfony, Twig, Doctrine und der Rest des modernen PHP-Ökosystems liefern sichere Standardeinstellungen, die es leicht machen, die Tests zu erfüllen, sobald sie existieren. Die Frage ist nicht, ob wir die Mittel haben. Die Frage ist, ob wir die Disziplin haben, sie einzusetzen, und ob wir bereit sind, Sicherheitsschwachstellen mit derselben Ernsthaftigkeit zu behandeln wie funktionale Fehler.

Wenn du sehen möchtest, wie das in der Praxis aussieht, komm zur Präsentation. Beobachte, wie die Tests fehlschlagen, wie sie grün werden, und frage dich, wie viele der Schwachstellen in deiner eigenen Codebasis durch eine Handvoll Tests aufgedeckt würden, die du heute Nachmittag schreiben könntest. Meine Erfahrung ist, dass die Antwort unangenehm ist, und dass genau dieses Unbehagen die Veränderung antreibt.