OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

XdrProviderAndRulesTest.php

23 KB
<?php

declare(strict_types=1);

use OpenNIT\Xdr\AlertRules;
use OpenNIT\Xdr\DefenderProvider;
use OpenNIT\Xdr\TrendVisionProvider;
use OpenNIT\Xdr\XdrProviderInterface;

/**
 * Deckt die beiden Zusagen des XDR-Moduls ab, mit denen die Beta-Kennzeichnung
 * fällt: dass ein zweiter Anbieter ohne Schema-Änderung neben dem ersten läuft,
 * und dass Regeln neue Warnungen vorhersagbar behandeln.
 */
return static function (): void {

    $wurzel = dirname(__DIR__);
    $src    = static fn (string $datei): string => (string)file_get_contents($wurzel . $datei);

    /** Private/geschützte Methode eines Providers aufrufen. */
    $ruf = static function (object $obj, string $methode, array $args = []): mixed {
        $m = new \ReflectionMethod($obj, $methode);
        $m->setAccessible(true);
        return $m->invokeArgs($obj, $args);
    };

    // ----------------------------------------------------------------
    // Zweiter Anbieter
    // ----------------------------------------------------------------

    test('Beide Anbieter erfüllen dieselbe Schnittstelle', function () {
        foreach ([TrendVisionProvider::class, DefenderProvider::class] as $klasse) {
            assertTrue(
                is_subclass_of($klasse, XdrProviderInterface::class),
                "Kein XdrProviderInterface: {$klasse}"
            );
        }
    });

    test('Die Anbieter-Schlüssel sind verschieden', function () {
        // Die Eindeutigkeit einer Warnung hängt an provider + external_id.
        $trend    = new TrendVisionProvider('https://api.eu.xdr.trendmicro.com', 'key');
        $defender = new DefenderProvider('tenant', 'client', 'secret');

        assertSame('trendmicro', $trend->key());
        assertSame('defender', $defender->key());
    });

    test('Der neue Anbieter braucht keine neue Spalte', function () use ($ruf) {
        // Eine normalisierte Defender-Warnung darf nur Felder tragen, die
        // xdr_alerts schon kennt – daran hängt die Zusage, dass weitere
        // Anbieter ohne Schema-Änderung andocken.
        $defender = new DefenderProvider('tenant', 'client', 'secret');
        $roh = [
            'id'          => 'da1234',
            'severity'    => 'high',
            'status'      => 'new',
            'title'       => 'Verdächtiges PowerShell-Skript',
            'description' => 'Ein Skript hat sich in den Autostart eingetragen.',
            'createdDateTime' => '2026-09-14T08:30:00Z',
        ];

        $erwartet = [
            'external_id', 'alert_type', 'severity', 'provider_status', 'title', 'description',
            'endpoint_name', 'agent_guid', 'mac_address', 'ip_address', 'mitre_techniques',
            'score', 'detected_at', 'raw_payload',
        ];

        $normalisiert = $ruf($defender, 'normalize', [$roh]);
        assertSame($erwartet, array_keys($normalisiert));

        // Dieselbe Form liefert der erste Anbieter.
        $trend = new TrendVisionProvider('https://api.eu.xdr.trendmicro.com', 'key');
        assertSame($erwartet, array_keys($ruf($trend, 'normalizeWorkbench', [['id' => 'wb1']])));
    });

    test('Das Gerät wird aus der Beweisliste herausgelöst', function () use ($ruf) {
        // Graph mischt Geräte, Konten und Dateien; nur das Gerät trägt den
        // Anker für die Zuordnung zum Inventar.
        $defender = new DefenderProvider('tenant', 'client', 'secret');
        $normalisiert = $ruf($defender, 'normalize', [[
            'id'       => 'da1',
            'severity' => 'medium',
            'evidence' => [
                ['@odata.type' => '#microsoft.graph.security.userEvidence', 'userAccount' => ['accountName' => 'meier']],
                ['@odata.type' => '#microsoft.graph.security.deviceEvidence',
                 'deviceDnsName' => 'NB-0815.firma.local',
                 'mdeDeviceId'   => 'abc-123',
                 'ipInterfaces'  => ['fe80::1%3', '10.0.0.7']],
            ],
        ]]);

        assertSame('NB-0815.firma.local', $normalisiert['endpoint_name']);
        assertSame('abc-123', $normalisiert['agent_guid']);
        // Die Link-Local-Adresse mit Zonenindex ist keine brauchbare Angabe;
        // genommen wird die erste gültige Adresse der Liste.
        assertSame('10.0.0.7', $normalisiert['ip_address']);
    });

    test('Ohne Gerät im Beweismaterial bleibt die Warnung unzugeordnet', function () use ($ruf) {
        $defender = new DefenderProvider('tenant', 'client', 'secret');
        $normalisiert = $ruf($defender, 'normalize', [[
            'id'       => 'da2',
            'severity' => 'low',
            'evidence' => [['@odata.type' => '#microsoft.graph.security.mailboxEvidence']],
        ]]);

        assertSame(null, $normalisiert['endpoint_name']);
        assertSame(null, $normalisiert['agent_guid']);
        // Graph liefert an dieser Stelle keine MAC – das bleibt so, statt zu raten.
        assertSame(null, $normalisiert['mac_address']);
    });

    test('Die Schweregrade werden auf das gemeinsame Schema abgebildet', function () use ($ruf) {
        $defender = new DefenderProvider('tenant', 'client', 'secret');
        foreach (['high' => 'high', 'Medium' => 'medium', 'low' => 'low',
                  'informational' => 'info', 'unknown' => 'info', '' => 'info'] as $roh => $erwartet) {
            assertSame($erwartet, $ruf($defender, 'mapSeverity', [(string)$roh]), "Falsch: {$roh}");
        }
    });

    test('Das Geheimnis taucht in keiner Fehlermeldung auf', function () use ($ruf) {
        // Ohne Mandanten-Angabe scheitert der Token-Abruf, bevor eine Anfrage
        // gestellt wird – die Meldung darf den Schlüssel nicht mitführen.
        $defender = new DefenderProvider('', '', 'streng-geheim');
        assertSame(null, $ruf($defender, 'token'));

        foreach ($defender->getErrors() as $fehler) {
            assertFalse(str_contains($fehler, 'streng-geheim'), 'Secret in der Fehlermeldung');
        }
        assertTrue($defender->getErrors() !== [], 'Gar keine Meldung');
    });

    test('Der Anbieter wird aus den Einstellungen gebaut', function () use ($src) {
        $code = $src('/src/Xdr/XdrSettings.php');

        assertTrue(str_contains($code, 'xdr_defender_enabled'), 'Kein Schalter');
        assertTrue(str_contains($code, 'new DefenderProvider'), 'Wird nie gebaut');
        // Ohne Geheimnis kein Anbieter – sonst liefe jeder Lauf in einen Fehler.
        assertTrue(str_contains($code, "!empty(\$cfg['defender_secret_set'])"), 'Zugangsdaten ungeprüft');
        assertTrue(str_contains($code, 'CredentialCrypto::decrypt'), 'Geheimnis nicht verschlüsselt abgelegt');
    });

    test('Das Geheimnis wird verschlüsselt gespeichert und nicht protokolliert', function () use ($src) {
        $code = $src('/src/Controllers/Admin/XdrAdminController.php');
        $von  = strpos($code, 'public function save');
        $bis  = strpos($code, 'public function testConnection', (int)$von);
        $block = substr($code, (int)$von, (int)$bis - (int)$von);

        assertTrue(str_contains($block, "CredentialCrypto::encrypt(\$newSecret"), 'Unverschlüsselt');
        // Ein leeres Feld behält den bestehenden Schlüssel.
        assertTrue(str_contains($block, "if (\$newSecret !== '')"), 'Leeres Feld überschreibt');
        assertTrue(str_contains($block, "'defender_secret' => \$newSecret !== '' ? 'gesetzt' : 'unverändert'"), 'Kein Nur-Zustand im Audit-Log');
        assertFalse(str_contains($block, "'xdr_defender_client_secret' => \$newSecret"), 'Secret im Audit-Log');
    });

    test('Der Verbindungstest benennt jeden Anbieter einzeln', function () use ($src) {
        // Bei zwei Anbietern bliebe sonst offen, welcher klemmt.
        $code = $src('/src/Controllers/Admin/XdrAdminController.php');
        $von  = strpos($code, 'public function testConnection');
        $block = substr($code, (int)$von, 1600);

        assertTrue(str_contains($block, 'foreach ($providers as $provider)'), 'Nur ein Anbieter geprüft');
        assertTrue(str_contains($block, '$provider->label()'), 'Ohne Namen');
    });

    // ----------------------------------------------------------------
    // Regeln
    // ----------------------------------------------------------------

    /** Baut eine Regel mit Vorgabewerten. */
    $regel = static function (array $felder = []): array {
        return $felder + [
            'id' => 1, 'name' => 'Regel', 'enabled' => 1, 'sort_order' => 100,
            'match_provider' => null, 'match_alert_type' => null, 'match_min_severity' => null,
            'match_state' => 'any', 'match_title' => null, 'match_endpoint' => null,
            'action' => 'suppress', 'action_user_id' => null,
        ];
    };

    /** Baut eine normalisierte Warnung mit Vorgabewerten. */
    $warnung = static function (array $felder = []): array {
        return $felder + [
            'provider' => 'trendmicro', 'alert_type' => 'workbench', 'severity' => 'medium',
            'title' => 'Verdächtiger Prozess', 'endpoint_name' => 'NB-0815',
        ];
    };

    test('Eine Regel ohne Bedingungen trifft jede Warnung', function () use ($regel, $warnung) {
        assertTrue(AlertRules::trifft($regel(), $warnung(), 'matched'));
    });

    test('Jede gesetzte Bedingung muss zutreffen', function () use ($regel, $warnung) {
        $r = $regel(['match_provider' => 'defender', 'match_min_severity' => 'low']);

        assertFalse(AlertRules::trifft($r, $warnung(), 'matched'), 'Falscher Anbieter trifft');
        assertTrue(AlertRules::trifft($r, $warnung(['provider' => 'defender']), 'matched'));
    });

    test('Der Mindest-Schweregrad schließt nach unten ab', function () use ($regel, $warnung) {
        $r = $regel(['match_min_severity' => 'high']);

        assertFalse(AlertRules::trifft($r, $warnung(['severity' => 'medium']), 'matched'));
        assertTrue(AlertRules::trifft($r, $warnung(['severity' => 'high']), 'matched'));
        assertTrue(AlertRules::trifft($r, $warnung(['severity' => 'critical']), 'matched'), 'Kritisch fällt durch');
    });

    test('Die Zuordnungslage lässt sich einschränken', function () use ($regel, $warnung) {
        $nurOffen = $regel(['match_state' => 'unmatched']);
        assertTrue(AlertRules::trifft($nurOffen, $warnung(), 'unmatched'));
        // Auch eine mehrdeutige Zuordnung hängt an keinem Gerät.
        assertTrue(AlertRules::trifft($nurOffen, $warnung(), 'ambiguous'));
        assertFalse(AlertRules::trifft($nurOffen, $warnung(), 'matched'));

        $nurZugeordnet = $regel(['match_state' => 'matched']);
        assertTrue(AlertRules::trifft($nurZugeordnet, $warnung(), 'matched'));
        assertFalse(AlertRules::trifft($nurZugeordnet, $warnung(), 'unmatched'));
    });

    test('Ein Muster ohne Platzhalter sucht im Text', function () {
        assertTrue(AlertRules::musterTrifft('powershell', 'Verdächtiges PowerShell-Skript'));
        assertTrue(AlertRules::musterTrifft('PowerShell', 'verdächtiges powershell-skript'));
        assertFalse(AlertRules::musterTrifft('cmd', 'Verdächtiges PowerShell-Skript'));
    });

    test('Ein Muster mit Platzhalter beschreibt den ganzen Text', function () {
        assertTrue(AlertRules::musterTrifft('LAB-*', 'LAB-0815'));
        assertFalse(AlertRules::musterTrifft('LAB-*', 'NB-LAB-0815'), 'Nicht verankert');
        assertTrue(AlertRules::musterTrifft('NB-????', 'NB-0815'));
        assertFalse(AlertRules::musterTrifft('NB-????', 'NB-08'));
    });

    test('Ein leeres Muster schließt nichts aus', function () {
        assertTrue(AlertRules::musterTrifft('', 'irgendwas'));
        assertTrue(AlertRules::musterTrifft('   ', 'irgendwas'));
    });

    test('Die erste zutreffende Regel gewinnt', function () use ($regel, $warnung) {
        $regeln = [
            $regel(['id' => 1, 'sort_order' => 10, 'match_min_severity' => 'critical', 'action' => 'ticket']),
            $regel(['id' => 2, 'sort_order' => 20, 'match_endpoint' => 'NB-*', 'action' => 'assign', 'action_user_id' => 7]),
            $regel(['id' => 3, 'sort_order' => 30, 'action' => 'suppress']),
        ];

        $treffer = AlertRules::ersteTreffer($regeln, $warnung(), 'matched');
        assertSame(2, (int)$treffer['id']);

        $treffer = AlertRules::ersteTreffer($regeln, $warnung(['severity' => 'critical']), 'matched');
        assertSame(1, (int)$treffer['id'], 'Reihenfolge missachtet');

        // Ohne passende Einschränkung fängt die letzte Regel alles ab.
        $treffer = AlertRules::ersteTreffer($regeln, $warnung(['endpoint_name' => 'SRV-1']), 'matched');
        assertSame(3, (int)$treffer['id']);
    });

    test('Ohne Regeln bleibt jede Warnung unverändert', function () use ($warnung) {
        assertSame(null, AlertRules::ersteTreffer([], $warnung(), 'matched'));
    });

    test('Eine verworfene Warnung wird gar nicht erst als neu angelegt', function () use ($src) {
        // Sonst schlüge sie kurz als „neu" auf und löste Folgeschritte aus.
        $code = $src('/src/Xdr/XdrSyncService.php');
        $von  = strpos($code, 'private function upsertAlert');
        $bis  = strpos($code, 'private function matchAsset', (int)$von);
        $block = substr($code, (int)$von, (int)$bis - (int)$von);

        $regelPruefung = strpos($block, 'AlertRules::ersteTreffer');
        $einfuegen     = strpos($block, "\$this->db->insert('xdr_alerts'");
        assertTrue($regelPruefung !== false && $einfuegen !== false, 'Eine Hälfte fehlt');
        assertTrue($regelPruefung < $einfuegen, 'Regeln greifen erst nach dem Anlegen');

        assertTrue(str_contains($block, "\$aktion === 'suppress' ? 'dismissed' : 'new'"), 'Verwerfen ohne Wirkung');
        assertTrue(str_contains($block, "if (\$aktion !== 'suppress')"), 'Benachrichtigung trotz Verwerfen');
    });

    test('Regeln gelten nur für neue Warnungen', function () use ($src) {
        // Der Aktualisierungszweig darf eine laufende Bearbeitung nicht kippen.
        $code = $src('/src/Xdr/XdrSyncService.php');
        $von  = strpos($code, 'private function upsertAlert');
        $bis  = strpos($code, '// Neue Warnung', (int)$von);
        $aktualisieren = substr($code, (int)$von, (int)$bis - (int)$von);

        assertFalse(str_contains($aktualisieren, 'AlertRules::ersteTreffer'), 'Regel wirkt rückwirkend');
        assertFalse(str_contains($aktualisieren, 'local_status ='), 'Triage wird überschrieben');
    });

    test('Ein gescheiterter Anbieter verliert keine Warnungen', function () use ($src) {
        // Jeder Anbieter führt seinen eigenen Cursor und rückt ihn nur bei einem
        // fehlerfreien Abruf vor – sonst risse ein Ausfall eine Lücke, während
        // der andere Anbieter weiterläuft.
        $code = $src('/src/Xdr/XdrSyncService.php');
        $von  = strpos($code, 'public function sync');
        $bis  = strpos($code, 'private function cursorOf', (int)$von);
        $block = substr($code, (int)$von, (int)$bis - (int)$von);

        assertTrue(str_contains($block, '$fehlerVorher = count($this->errors)'), 'Fehler je Anbieter nicht getrennt');
        assertTrue(str_contains($block, 'if (count($this->errors) === $fehlerVorher)'), 'Cursor rückt trotz Fehler vor');
        assertTrue(str_contains($block, '$this->cursorOf($provider->key()'), 'Gemeinsamer Cursor für alle');
    });

    test('Ein Anbieter ohne eigenen Cursor erbt den bisherigen', function () use ($src) {
        // Bestandsinstanzen führen nur den gemeinsamen Cursor; ohne diesen
        // Rückfall liefe Trend Vision One nach dem Update 7 Tage neu ein.
        $code = $src('/src/Xdr/XdrSyncService.php');
        $von  = strpos($code, 'private function cursorOf');
        $block = substr($code, (int)$von, 1600);

        assertTrue(str_contains($block, 'self::CURSOR_PREFIX . $providerKey'), 'Kein eigener Cursor');
        assertTrue(str_contains($block, '$fallback !== null'), 'Kein Rückfall auf den bisherigen Cursor');
        assertTrue(str_contains($block, "new \\DateTimeImmutable('-7 days')"), 'Kein Erstlauf-Fenster');
        assertTrue(str_contains($block, '$this->overlapMinutes'), 'Overlap entfällt');
    });

    test('Ein erst jetzt eingeschalteter Anbieter beginnt nicht bei null', function () use ($src) {
        // Erbte er den gemeinsamen Cursor, holte sein erster Lauf fast nichts
        // und das Board bliebe leer - ohne Moeglichkeit, das nachzuholen.
        $code = $src('/src/Xdr/XdrSyncService.php');
        $von  = strpos($code, 'private function cursorOf');
        $block = substr($code, (int)$von, 1600);

        assertTrue(str_contains($block, 'hatteSchonWarnungen($providerKey)'), 'Erbt ungeprueft');

        $pruef = substr($code, (int)strpos($code, 'private function hatteSchonWarnungen'), 600);
        assertTrue(str_contains($pruef, 'FROM xdr_alerts WHERE provider = ?'), 'Falsche Pruefung');
        // Eine fehlende Tabelle darf den Abgleich nicht kippen.
        assertTrue(str_contains($pruef, 'catch (\\Throwable)'), 'Kein Schutz');
    });

    test('Eine Zuweisung benachrichtigt nur die zuständige Person', function () use ($src) {
        $code = $src('/src/Xdr/XdrSyncService.php');
        $von  = strpos($code, 'private function maybeNotify');
        $block = substr($code, (int)$von, 1400);

        assertTrue(str_contains($block, '?int $empfaenger'), 'Kein gezielter Empfänger');
        // Die ausdrückliche Zuweisung wiegt schwerer als die Schwelle.
        assertTrue(str_contains($block, '$empfaenger === null && !$isUnmatched && !$isSevere'), 'Schwelle blockt die Zuweisung');
        assertTrue(str_contains($block, 'FROM users WHERE id = ? AND is_active = 1'), 'Empfänger nicht eingegrenzt');
    });

    test('Eine Ticket-Regel wirkt unabhängig vom Schweregrad', function () use ($src) {
        $code = $src('/src/Cron/Handlers/AlertToTicketHandler.php');
        $von  = strpos($code, 'private function fromXdr');
        $block = substr($code, (int)$von, 2000);

        assertTrue(str_contains($block, "a.auto_action = 'ticket'"), 'Regel wird nicht berücksichtigt');
        // Auch ohne die Schwellwert-Automation zählt eine ausdrückliche Regel.
        assertTrue(str_contains($code, 'xdrTicketRegelAktiv'), 'Ohne Automation bliebe die Regel wirkungslos');
    });

    test('Eine stolpernde Quelle bringt die uebrigen nicht um ihren Lauf', function () use ($src) {
        // Die XDR-Abfrage nutzt eine Spalte aus einer neuen Migration; solange
        // die noch nicht angewendet ist, duerfen CVE und SSL trotzdem laufen.
        $code = $src('/src/Cron/Handlers/AlertToTicketHandler.php');
        $von  = strpos($code, 'public function execute');
        $block = substr($code, (int)$von, 2400);

        assertTrue(str_contains($block, 'catch (\\Throwable $e)'), 'Quellen laufen ungeschuetzt');
        assertTrue(str_contains($block, "\$fehler[] = \$name"), 'Fehlschlag wird verschwiegen');
        assertTrue(str_contains($block, 'Übersprungen'), 'Ergebnis benennt den Ausfall nicht');
    });

    test('Warnungen zweier Anbieter erzeugen nicht ein gemeinsames Ticket', function () use ($src) {
        // IDs sind nur innerhalb eines Anbieters eindeutig.
        $code = $src('/src/Cron/Handlers/AlertToTicketHandler.php');
        $von  = strpos($code, 'private function xdrRef');
        $block = substr($code, (int)$von, 700);

        assertTrue(str_contains($block, "\$provider === 'trendmicro' ? \$id"), 'Bestand nicht berücksichtigt');
        assertTrue(str_contains($block, "\$provider . ':' . \$id"), 'Kein Anbieter-Bezug');

        // Beide Seiten des Vergleichs müssen dieselbe Kennung bilden.
        $sql = substr($code, (int)strpos($code, 'private function fromXdr'), 2000);
        assertTrue(str_contains($sql, "IF(a.provider = 'trendmicro', a.external_id, LEFT(CONCAT(a.provider, ':', a.external_id), 255))"), 'Kennung weicht ab');
    });

    // ----------------------------------------------------------------
    // Migration und Oberfläche
    // ----------------------------------------------------------------

    test('Die Migration ist wiederholbar', function () use ($wurzel) {
        $sql = (string)file_get_contents($wurzel . '/database/migrations/213_xdr_defender_and_rules.sql');

        assertTrue(str_contains($sql, 'CREATE TABLE IF NOT EXISTS `xdr_alert_rules`'), 'Tabelle nicht bedingt');
        foreach (['applied_rule_id', 'auto_action', 'assigned_to'] as $spalte) {
            assertTrue(str_contains($sql, "ADD COLUMN `{$spalte}`"), "Spalte fehlt: {$spalte}");
        }
        // Ein zweiter Lauf darf nichts zerstören.
        assertFalse(str_contains($sql, 'TRUNCATE'), 'Zerstörendes Statement');
        assertFalse((bool)preg_match('/\bINSERT\s+INTO\b/i', $sql), 'Nacktes INSERT');
    });

    test('Regeln sind im Backend erreichbar', function () use ($src, $wurzel) {
        $routen = $src('/src/Core/Application.php');
        foreach (["'/admin/xdr/rules'", 'XdrAdminController@ruleSave', 'XdrAdminController@ruleDelete'] as $teil) {
            assertTrue(str_contains($routen, $teil), "Route fehlt: {$teil}");
        }

        assertTrue(is_file($wurzel . '/templates/admin/xdr/rules.php'), 'Keine Seite');

        // Der Menüeintrag hängt wie die Route am aktiven Modul und am Recht.
        $menue = $src('/templates/layouts/admin.php');
        assertTrue(str_contains($menue, '/admin/xdr/rules'), 'Nicht im Menü');
        $von = strpos($menue, "\$modules['xdr']");
        assertTrue(str_contains(substr($menue, (int)$von, 700), '/admin/xdr/rules'), 'Menüeintrag nicht am Modul gegated');
    });

    test('Jedes Formular der Regelseite trägt den CSRF-Token', function () use ($src) {
        $tpl = $src('/templates/admin/xdr/rules.php');

        assertSame(
            substr_count($tpl, '<form'),
            substr_count($tpl, 'name="_csrf"'),
            'Formular ohne Token'
        );
    });

    test('Die Einstellungen beider Anbieter liegen in einem Formular', function () use ($src) {
        // Zwei Formulare würden beim Speichern die Schalter des jeweils
        // anderen Anbieters als „aus" werten.
        $tpl = $src('/templates/admin/xdr/settings.php');

        assertSame(1, substr_count($tpl, '<form method="post" action="/admin/xdr/settings">'), 'Nicht genau ein Formular');
        foreach (['xdr_trendmicro_enabled', 'xdr_defender_enabled', 'xdr_max_alerts', 'xdr_ticket_enabled'] as $feld) {
            assertTrue(str_contains($tpl, 'name="' . $feld . '"'), "Feld fehlt: {$feld}");
        }
    });

    test('Die Beta-Kennzeichnung ist an allen drei Stellen weg', function () use ($src) {
        $stellen = [
            '/templates/admin/settings/index.php' => 'XDR-Sicherheitswarnungen',
            '/templates/admin/manual/index.php'   => "'XDR-Sicherheitswarnungen'",
            '/templates/admin/manual/pdf.php'     => "'XDR-Sicherheitswarnungen'",
        ];
        foreach ($stellen as $datei => $erwartet) {
            $code = $src($datei);
            assertTrue(str_contains($code, $erwartet), "Überschrift fehlt in {$datei}");
            assertFalse(str_contains($code, 'XDR-Sicherheitswarnungen (Beta)'), "Beta bleibt in {$datei}");
            assertFalse(
                (bool)preg_match('/XDR-Sicherheitswarnungen\s*<span class="badge badge-warning">/', $code),
                "Beta-Abzeichen bleibt in {$datei}"
            );
        }
    });
};