CluPilotCloud/tests/Feature/Security/ScanForIntrusionsTest.php

259 lines
11 KiB
PHP

<?php // tests/Feature/Security/ScanForIntrusionsTest.php
use App\Models\Host;
use App\Models\Instance;
use App\Models\SecurityBlock;
use App\Provisioning\Jobs\ScanForIntrusions;
use App\Services\Proxmox\FakeProxmoxClient;
use App\Services\Ssh\FakeRemoteShell;
use App\Services\Ssh\RemoteShell;
use Illuminate\Support\Carbon;
beforeEach(function () {
// Jede Instanz hat unten (aktiveInstanz()) einen echten Host — also kann
// BlockAddress ueber HostFirewall auch in Tests, denen es nur ums Lesen
// geht, eine SSH-Verbindung versuchen. Ohne einen gebundenen Fake liefe
// das gegen eine echte, nicht erreichbare Adresse und haengt am
// TCP-Verbindungsaufbau, statt sofort false zurueckzugeben. Tests, die den
// Shell-Verkehr selbst pruefen, binden ihren eigenen Fake und ueberschreiben
// diesen hier.
app()->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);
});