instance(RemoteShell::class, new FakeRemoteShell); }); function protokollZeilen(string $ip, int $anzahl): string { return collect(range(1, $anzahl)) ->map(fn () => json_encode([ 'app' => 'core', 'message' => "Login failed: 'admin' (Remote IP: '{$ip}')", 'remoteAddr' => $ip, 'time' => now()->toIso8601String(), ])) ->implode("\n"); } /** * Eine aktive Instanz mit vmid braucht in Wahrheit immer einen Host — ohne * Platzierung gäbe es keinen Gastagenten zu fragen (siehe * IssueInstanceAdminAccess, das genau deshalb auf `$instance->host === null` * prüft). Der Auftragszettel ließ host_id in seinen Fixturen weg; das hätte * FailedLoginReader::fromInstance() nie erreicht, weil sie ohne Host abbricht. */ function aktiveInstanz(array $attributes = []): Instance { return Instance::factory()->create(array_merge([ 'status' => 'active', 'vmid' => 101, 'host_id' => Host::factory()->active()->create()->id, // Versatz 1 statt 0, und das ist kein Detail: 0 heisst seit der // Fix-Welle woertlich „noch nie gelesen" und laesst den Lauf NUR den // Startwert holen, ohne das Protokoll zu uebertragen (siehe den // eigenen Testfall dafuer weiter unten). Eine Instanz, die schon // einmal gelesen wurde, ist der Normalfall dieser Datei. 'security_log_offset' => 1, ], $attributes)); } it('sperrt ab zehn Fehlversuchen im Fenster', function () { $pve = new FakeProxmoxClient; $pve->guestScripts['nextcloud.log'] = ['out-data' => protokollZeilen('203.0.113.7', 10), 'exitcode' => 0]; app()->instance(\App\Services\Proxmox\ProxmoxClient::class, $pve); aktiveInstanz(); app(ScanForIntrusions::class)->handle(); expect(SecurityBlock::where('ip', '203.0.113.7')->exists())->toBeTrue(); }); it('sperrt bei neun Fehlversuchen nicht', function () { $pve = new FakeProxmoxClient; $pve->guestScripts['nextcloud.log'] = ['out-data' => protokollZeilen('203.0.113.7', 9), 'exitcode' => 0]; app()->instance(\App\Services\Proxmox\ProxmoxClient::class, $pve); aktiveInstanz(); app(ScanForIntrusions::class)->handle(); expect(SecurityBlock::count())->toBe(0); }); it('sperrt nicht, wenn sich die Versuche ueber zwei Fenster verteilen', function () { // Zehn Versuche sind erst dann zehn, wenn sie im selben Fenster liegen. Wer // langsam durchprobiert, laeuft absichtlich durch — das ist der Preis // dafuer, dass ein vertippter Mitarbeiter nicht ausgesperrt wird. $alt = collect(range(1, 6))->map(fn () => json_encode([ 'message' => "Login failed: 'admin' (Remote IP: '203.0.113.7')", 'remoteAddr' => '203.0.113.7', 'time' => now()->subMinutes(30)->toIso8601String(), ]))->implode("\n"); $pve = new FakeProxmoxClient; $pve->guestScripts['nextcloud.log'] = ['out-data' => $alt."\n".protokollZeilen('203.0.113.7', 6), 'exitcode' => 0]; app()->instance(\App\Services\Proxmox\ProxmoxClient::class, $pve); aktiveInstanz(); app(ScanForIntrusions::class)->handle(); expect(SecurityBlock::count())->toBe(0); }); it('sperrt auch, wenn sich die zehn Versuche ueber mehrere Laeufe verteilen', function () { // Der geduldige Angreifer. Drei Versuche je Lauf, vier Laeufe — zwoelf // Versuche innerhalb des Zehn-Minuten-Fensters. Zaehlte jeder Lauf nur fuer // sich (nur den eigenen Zuwachs seit dem letzten Mal), kaeme dieser // Angreifer nie ueber die Schwelle, und die Zusage "10 in 10 Minuten" // waere in Wahrheit "10 im Zuwachs eines einzelnen Laufs" — meist eine // Minute. aktiveInstanz(); $pve = new FakeProxmoxClient; app()->instance(\App\Services\Proxmox\ProxmoxClient::class, $pve); $offset = 0; foreach (range(1, 4) as $lauf) { // Je Lauf NEUE Zeilen im Protokoll, nicht dieselben noch einmal — // 'stat -c %s' zuerst registriert (wie im Rotations-Test), damit die // Groessen-Abfrage nicht denselben Treffer wie 'tail' bekommt. $neu = protokollZeilen('203.0.113.7', 3); $pve->guestScripts['stat -c %s'] = ['out-data' => ($offset + strlen($neu))."\n", 'exitcode' => 0]; $pve->guestScripts['nextcloud.log'] = ['out-data' => $neu, 'exitcode' => 0]; app(ScanForIntrusions::class)->handle(); $offset += strlen($neu); } expect(SecurityBlock::where('ip', '203.0.113.7')->count())->toBe(1); }); it('sperrt nicht, wenn neun Versuche ueber mehrere Laeufe unter der Schwelle bleiben', function () { // Die Gegenprobe zum Test oben: ohne sie waere der auch dann gruen, wenn // ScanForIntrusions gar nicht mehr zaehlte, sondern bei jedem Lauf ab dem // ersten Treffer sperrte. aktiveInstanz(); $pve = new FakeProxmoxClient; app()->instance(\App\Services\Proxmox\ProxmoxClient::class, $pve); $offset = 0; foreach ([2, 2, 2, 3] as $anzahl) { $neu = protokollZeilen('203.0.113.7', $anzahl); $pve->guestScripts['stat -c %s'] = ['out-data' => ($offset + strlen($neu))."\n", 'exitcode' => 0]; $pve->guestScripts['nextcloud.log'] = ['out-data' => $neu, 'exitcode' => 0]; app(ScanForIntrusions::class)->handle(); $offset += strlen($neu); } expect(SecurityBlock::count())->toBe(0); }); it('faengt bei einem rotierten Protokoll wieder bei null an', function () { // Ist die Datei kleiner als der gemerkte Versatz, wurde rotiert. Ohne diese // Behandlung liest der nächste Lauf ins Leere und sieht nie wieder etwas. $pve = new FakeProxmoxClient; $pve->guestScripts['stat -c %s'] = ['out-data' => "50\n", 'exitcode' => 0]; $pve->guestScripts['nextcloud.log'] = ['out-data' => protokollZeilen('203.0.113.7', 10), 'exitcode' => 0]; app()->instance(\App\Services\Proxmox\ProxmoxClient::class, $pve); $instance = aktiveInstanz(['security_log_offset' => 999999]); app(ScanForIntrusions::class)->handle(); expect($instance->fresh()->security_log_offset)->toBeLessThan(999999) ->and(SecurityBlock::count())->toBe(1); }); it('traegt eine noch gueltige Sperre mit der RESTLAUFZEIT wieder ein', function () { // Nach einem Neustart des Hosts ist die nftables-Menge leer — sie lebt im // Speicher. Würde die ursprüngliche Dauer erneut gesetzt, verlängerte sich // eine Sperre bei jedem Neustart. // // Uhr eingefroren, UND auf die volle Sekunde — beides zaehlt. Ohne das // Einfrieren verstreicht zwischen dem Anlegen der Sperre und dem // Wiedereintragen echte Zeit; ohne die volle Sekunde traegt `now()` // Mikrosekunden, die `expires_at` nach dem Runden-durch-die-Datenbank // (die Spalte kennt keine Bruchteile) nicht mehr hat — genau die // Differenz, um die 1200s sonst auf 1199s abrundet. // finally: bricht eine Zusicherung weiter unten ab, bleibt die Uhr sonst // fuer alle Folgetests eingefroren stehen, und jemand sucht stundenlang // am falschen Ende. Carbon::setTestNow(now()->startOfSecond()); try { $shell = new \App\Services\Ssh\FakeRemoteShell; app()->instance(\App\Services\Ssh\RemoteShell::class, $shell); // Der Auftragszettel legt die Sperre ohne Host an — SecurityBlockFactory // haengt sie sonst an eine Instanz ohne host_id, und ohne Host gibt es // nichts, wo HostFirewall::block() etwas eintragen koennte. Mit `forHost` // (Aufgabe 3) direkt an einem echten Host, wie es eine host_ssh-Sperre // ohnehin waere. $host = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:abc']); $block = SecurityBlock::factory()->forHost($host)->create([ 'expires_at' => now()->addMinutes(20), 'blocked_at' => now()->subMinutes(40), ]); app(ScanForIntrusions::class)->handle(); expect($shell->ran('timeout 1200s'))->toBeTrue() ->and($shell->ran('timeout 3600s'))->toBeFalse(); } finally { Carbon::setTestNow(); } }); it('laesst einen werfenden Gast den Lauf nicht abbrechen', function () { // Ein abgeschalteter Gast wirft, statt einen Fehlercode zu liefern — // guestExec() ruft ->throw() auf jede Antwort. Vorher riss das den ganzen // Minutentakt mit: die uebrigen Instanzen, die Hosts, und vor allem das // Wiedereintragen der noch gueltigen Sperren. // // Uhr eingefroren wie im Restlaufzeit-Test darueber, aus demselben Grund: // die zweite Zusicherung prueft eine exakte Sekundenzahl. Carbon::setTestNow(now()->startOfSecond()); try { $pve = new FakeProxmoxClient; app()->instance(\App\Services\Proxmox\ProxmoxClient::class, $pve); $shell = new \App\Services\Ssh\FakeRemoteShell; app()->instance(\App\Services\Ssh\RemoteShell::class, $shell); // Erste Instanz: guestExec wirft (abgeschalteter Gast, kein sauberer // Fehlercode). aktiveInstanz(['vmid' => 201]); $pve->guestThrows[201] = new RuntimeException('guest agent unreachable'); // Zweite Instanz: zehn Fehlversuche im Protokoll — muss trotz der // ersten weiterhin gelesen und gesperrt werden. aktiveInstanz(['vmid' => 202]); $pve->guestScripts['nextcloud.log'] = ['out-data' => protokollZeilen('203.0.113.7', 10), 'exitcode' => 0]; // Dazu eine noch gueltige, UNBETEILIGTE Sperre (andere Adresse, eigener // Host), die wiedereingetragen werden muss — mit ihrer Restlaufzeit, // nicht mit der Basisdauer einer frisch ausgeloesten Sperre. Nur so // beweist die zweite Zusicherung wirklich, dass reapplyActiveBlocks() // gelaufen ist, statt bloss zufaellig mit der neu ausgeloesten Sperre // (die selbst schon "timeout" enthaelt) zusammenzufallen. $andererHost = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:def']); SecurityBlock::factory()->forHost($andererHost)->create([ 'ip' => '198.51.100.9', 'expires_at' => now()->addMinutes(20), 'blocked_at' => now()->subMinutes(40), ]); app(ScanForIntrusions::class)->handle(); // Der Lauf ist durchgelaufen: die zweite Instanz wurde gesperrt UND die // bestehende, unbeteiligte Sperre wurde erneut eingetragen. expect(SecurityBlock::where('ip', '203.0.113.7')->exists())->toBeTrue() ->and($shell->ran('timeout 1200s'))->toBeTrue(); } finally { Carbon::setTestNow(); } }); it('liest beim ERSTEN Lauf gar nichts, sondern merkt sich nur die Groesse', function () { // Versatz 0 heisst woertlich „von vorn": ohne diesen Zweig tailt der // allererste Lauf die KOMPLETTE Protokolldatei jeder Instanz durch den // Gastagenten, alle Instanzen im selben Auftrag, in derselben Minute. // Gesperrt wuerde dabei ohnehin nichts — das Zehn-Minuten-Fenster wirft // jede aeltere Zeile weg —, es waere also eine grosse Uebertragung fuer ein // garantiert leeres Ergebnis. $pve = new FakeProxmoxClient; $pve->guestScripts['stat -c %s'] = ['out-data' => "8123\n", 'exitcode' => 0]; $pve->guestScripts['nextcloud.log'] = ['out-data' => protokollZeilen('203.0.113.7', 50), 'exitcode' => 0]; app()->instance(\App\Services\Proxmox\ProxmoxClient::class, $pve); $instance = aktiveInstanz(['security_log_offset' => 0]); app(ScanForIntrusions::class)->handle(); expect($instance->fresh()->security_log_offset)->toBe(8123) ->and(SecurityBlock::count())->toBe(0) // Und die Uebertragung selbst hat nicht stattgefunden — sonst waere // dieser Test auch dann gruen, wenn nur das Zaehlen unterbliebe. ->and($pve->guestRan('tail -c'))->toBeFalse(); }); it('faengt beim ZWEITEN Lauf dort an, wo der erste aufgehoert hat', function () { // Die Gegenprobe: der Startwert darf die Instanz nicht dauerhaft blind // machen. Nach dem ersten Lauf wird wieder gelesen und gesperrt. $pve = new FakeProxmoxClient; $pve->guestScripts['stat -c %s'] = ['out-data' => "8123\n", 'exitcode' => 0]; app()->instance(\App\Services\Proxmox\ProxmoxClient::class, $pve); aktiveInstanz(['security_log_offset' => 0]); app(ScanForIntrusions::class)->handle(); $neu = protokollZeilen('203.0.113.7', 10); $pve->guestScripts['stat -c %s'] = ['out-data' => (8123 + strlen($neu))."\n", 'exitcode' => 0]; $pve->guestScripts['nextcloud.log'] = ['out-data' => $neu, 'exitcode' => 0]; app(ScanForIntrusions::class)->handle(); expect(SecurityBlock::where('ip', '203.0.113.7')->exists())->toBeTrue(); }); // ---- Was aus dem Gast-Protokoll kommt, ist keine Adresse, bis es geprueft ist ---- it('sperrt nicht auf eine Adresse, in der ein Semikolon steht, und fuehrt nichts aus', function () { // `remoteAddr` ist eine beliebige Zeichenkette aus einer Datei IM GAST. Der // Weg von hier fuehrt in ein `nft`-Kommando, das als root auf dem Host // laeuft — ein Semikolon darin waere ein zweiter Befehl. $boese = '203.0.113.7; nft flush ruleset'; $shell = new FakeRemoteShell; app()->instance(RemoteShell::class, $shell); $pve = new FakeProxmoxClient; $pve->guestScripts['nextcloud.log'] = ['out-data' => protokollZeilen($boese, 20), 'exitcode' => 0]; app()->instance(\App\Services\Proxmox\ProxmoxClient::class, $pve); aktiveInstanz(); app(ScanForIntrusions::class)->handle(); expect(SecurityBlock::count())->toBe(0) ->and($shell->ran('nft'))->toBeFalse() ->and($shell->ran('flush ruleset'))->toBeFalse(); }); it('sperrt im Instanz-Zweig keine private Adresse, eine oeffentliche schon', function () { // Vorsichtsmassnahme, nicht Sicherheitsgrenze: TRUSTED_PROXIES steht in der // .env des Gastes nicht, waehrend Traefik auf dem Host sitzt. Was Nextcloud // als remoteAddr protokolliert, ist damit entweder die Brueckenadresse des // Hosts oder ein X-Forwarded-For, das der Client selbst waehlt — beides ist // privat oder reserviert, ein echter Angreifer ist es nicht. $pve = new FakeProxmoxClient; $pve->guestScripts['nextcloud.log'] = [ 'out-data' => protokollZeilen('172.17.0.1', 20)."\n".protokollZeilen('203.0.113.7', 10), 'exitcode' => 0, ]; app()->instance(\App\Services\Proxmox\ProxmoxClient::class, $pve); aktiveInstanz(); app(ScanForIntrusions::class)->handle(); expect(SecurityBlock::where('ip', '172.17.0.1')->exists())->toBeFalse() ->and(SecurityBlock::where('ip', '203.0.113.7')->exists())->toBeTrue(); }); it('faengt einen werfenden Host ab, statt den ganzen Lauf abzubrechen', function () { // Derselbe Ring wie bei den Instanzen. Wirft hier etwas, liefe // reapplyActiveBlocks() nicht — jede Minute erneut. Carbon::setTestNow(now()->startOfSecond()); try { $shell = new FakeRemoteShell; app()->instance(RemoteShell::class, $shell); // Ein Host, dessen Lesen wirft — stellvertretend fuer alles, was // NACH FailedLoginReader::fromHost()s eigenem catch noch schiefgehen // kann: SecurityBlock::create(), Settings::set(), Spaties // PermissionDoesNotExist, $host->save(). Host::factory()->active()->create(['ssh_host_key' => 'SHA256:abc']); app()->bind(\App\Services\Security\FailedLoginReader::class, fn () => new class extends \App\Services\Security\FailedLoginReader { public function __construct() {} public function fromInstance(Instance $instance): ?array { return null; } public function fromHost(Host $host): ?array { throw new RuntimeException('Host-Lesen kaputt'); } }); // Eine noch gueltige, unbeteiligte Sperre, die trotzdem wieder // eingetragen werden muss. $andererHost = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:def']); SecurityBlock::factory()->forHost($andererHost)->create([ 'ip' => '198.51.100.9', 'expires_at' => now()->addMinutes(20), 'blocked_at' => now()->subMinutes(40), ]); app(ScanForIntrusions::class)->handle(); expect($shell->ran('timeout 1200s'))->toBeTrue(); } finally { Carbon::setTestNow(); } }); it('ueberspringt einen Gast, der nicht antwortet, ohne den Versatz zu verlieren', function () { $pve = new FakeProxmoxClient; $pve->guestScripts['nextcloud.log'] = ['exitcode' => 1]; app()->instance(\App\Services\Proxmox\ProxmoxClient::class, $pve); $instance = aktiveInstanz(['security_log_offset' => 4711]); app(ScanForIntrusions::class)->handle(); expect($instance->fresh()->security_log_offset)->toBe(4711); }); it('nimmt eine aufgehobene Sperre nachtraeglich aus der Firewall', function () { // Die Luecke, um die es geht: war der Host im Moment des Aufhebens nicht // erreichbar, steht der Datensatz auf aufgehoben und die Regel noch drin. // Portal und Konsole zeigen dann "Aufgehoben", waehrend die Adresse weiter // ausgesperrt bleibt — bis zum Ablauf der urspruenglichen Sperrzeit, ohne // dass es dem Menschen davor jemand sagt. $shell = new FakeRemoteShell; app()->instance(RemoteShell::class, $shell); $host = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:def']); SecurityBlock::factory()->forHost($host)->create([ 'ip' => '198.51.100.9', 'blocked_at' => now()->subMinutes(40), 'expires_at' => now()->addMinutes(20), // noch nicht abgelaufen 'released_at' => now()->subMinute(), // aber schon aufgehoben ]); app(ScanForIntrusions::class)->handle(); expect($shell->ran('delete element inet clupilot_filter clupilot_blocked { 198.51.100.9 }'))->toBeTrue(); }); it('ruehrt eine abgelaufene Sperre nicht mehr an', function () { // Nach der Ablaufzeit hat der Kernel den Eintrag selbst herausgenommen // (`flags timeout`). Jede Minute erneut loeschen zu wollen, was seit Wochen // weg ist, waere unnoetiger SSH-Verkehr auf demselben Arbeiter, der die // bezahlte Bereitstellung faehrt. $shell = new FakeRemoteShell; app()->instance(RemoteShell::class, $shell); $host = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:def']); SecurityBlock::factory()->forHost($host)->create([ 'ip' => '198.51.100.9', 'blocked_at' => now()->subDays(3), 'expires_at' => now()->subDays(2), 'released_at' => now()->subDays(2), ]); app(ScanForIntrusions::class)->handle(); expect($shell->ran('delete element'))->toBeFalse(); }); it('laesst eine Adresse stehen, die eine ANDERE aktive Sperre noch traegt', function () { // Dieselbe Adresse kann an zwei Subjekten desselben Hosts haengen (Host und // Instanz, oder zwei Instanzen). Wer eine davon aufhebt, darf die andere // nicht gleich mit aufheben — sonst oeffnet das Aufheben einer harmlosen // Sperre die Tuer, die eine ganz andere zugehalten hat. $shell = new FakeRemoteShell; app()->instance(RemoteShell::class, $shell); $host = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:def']); $instanz = Instance::factory()->create(['host_id' => $host->id]); SecurityBlock::factory()->forHost($host)->create([ 'ip' => '198.51.100.9', 'blocked_at' => now()->subMinutes(40), 'expires_at' => now()->addMinutes(20), 'released_at' => now()->subMinute(), ]); SecurityBlock::factory()->for($instanz)->create([ 'ip' => '198.51.100.9', // dieselbe Adresse, noch aktiv 'blocked_at' => now()->subMinutes(10), 'expires_at' => now()->addMinutes(50), ]); app(ScanForIntrusions::class)->handle(); expect($shell->ran('delete element'))->toBeFalse() ->and($shell->ran('add element inet clupilot_filter clupilot_blocked { 198.51.100.9'))->toBeTrue(); });