CluPilotCloud/tests/Feature/Security/ScanForIntrusionsTest.php

396 lines
17 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,
// 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);
});