Quelltext des aktuellen Standes
<?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}"
);
}
});
};