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, ], $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('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); });