153 lines
6.3 KiB
PHP
153 lines
6.3 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('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.
|
|
Carbon::setTestNow(now()->startOfSecond());
|
|
|
|
$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();
|
|
|
|
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);
|
|
});
|