diff --git a/app/Livewire/Posture/Index.php b/app/Livewire/Posture/Index.php new file mode 100644 index 0000000..db0136f --- /dev/null +++ b/app/Livewire/Posture/Index.php @@ -0,0 +1,63 @@ +> server uuid → score result (or ['error' => msg]) */ + public array $scores = []; + + public bool $ready = false; + + public function mount(): void + { + abort_unless(Auth::user()?->can('operate'), 403); + } + + public function title(): string + { + return __('posture.title'); + } + + public function scan(PostureService $posture): void + { + abort_unless(Auth::user()?->can('operate'), 403); + + $this->scores = []; + foreach (Server::withActiveCredential()->orderBy('name')->get() as $server) { + try { + $this->scores[$server->uuid] = $posture->score($server); + } catch (Throwable $e) { + $this->scores[$server->uuid] = ['error' => $e->getMessage()]; + } + } + + $this->ready = true; + } + + public function render(): View + { + $scored = array_filter($this->scores, fn ($s) => isset($s['score'])); + $avg = $scored !== [] ? (int) round(array_sum(array_column($scored, 'score')) / count($scored)) : null; + + return view('livewire.posture.index', [ + 'servers' => Server::withActiveCredential()->orderBy('name')->get(['uuid', 'name']), + 'average' => $avg, + ])->title($this->title()); + } +} diff --git a/app/Services/PostureService.php b/app/Services/PostureService.php new file mode 100644 index 0000000..10b0932 --- /dev/null +++ b/app/Services/PostureService.php @@ -0,0 +1,68 @@ +} + */ + public function score(Server $server): array + { + $items = $this->hardening->state($server); // throws if the host can't be read + + $checks = []; + $passed = 0; + $applicable = 0; + + foreach ($items as $it) { + // A check counts toward the score only when the OS supports it AND it's a real security + // gate (not the neutral auto-updates preference). + $counts = $it['supported'] && ! $it['neutral']; + if ($counts) { + $applicable++; + if ($it['secure']) { + $passed++; + } + } + + $checks[] = [ + 'key' => $it['key'], + 'label' => $it['label'], + 'detail' => $it['detail'], + 'secure' => (bool) $it['secure'], + 'supported' => (bool) $it['supported'], + 'neutral' => (bool) $it['neutral'], + 'counts' => $counts, + ]; + } + + $score = $applicable > 0 ? (int) round($passed / $applicable * 100) : 100; + + return [ + 'score' => $score, + 'rating' => $this->rating($score), + 'passed' => $passed, + 'applicable' => $applicable, + 'checks' => $checks, + ]; + } + + /** strong ≥90 · fair ≥60 · weak below — mapped to the status triad in the UI. */ + private function rating(int $score): string + { + return $score >= 90 ? 'strong' : ($score >= 60 ? 'fair' : 'weak'); + } +} diff --git a/lang/de/posture.php b/lang/de/posture.php new file mode 100644 index 0000000..5569db2 --- /dev/null +++ b/lang/de/posture.php @@ -0,0 +1,29 @@ + 'Sicherheit', + 'title' => 'Sicherheitslage', + 'subtitle' => 'Härtungs-Score je Server über die Flotte', + + 'scan_hint' => 'Prüfe Server …', + 'fleet_avg' => 'Flotten-Schnitt', + 'no_servers_title' => 'Keine Server', + 'no_servers' => 'Keine Server mit aktivem Credential zum Prüfen.', + + // Rating + 'rating_strong' => 'Stark', + 'rating_fair' => 'Mittel', + 'rating_weak' => 'Schwach', + + // Per server + 'passed_of' => ':passed/:applicable Prüfungen bestanden', + 'view_server' => 'Server öffnen', + 'scan_error' => 'Prüfung fehlgeschlagen', + + // Checks + 'check_secure' => 'OK', + 'check_open' => 'Offen', + 'check_neutral' => 'Info', + 'check_unsupported' => 'n. v.', + 'remediation_hint' => 'Offene Punkte auf der Server-Detailseite härten.', +]; diff --git a/lang/de/shell.php b/lang/de/shell.php index 1920085..1163b4d 100644 --- a/lang/de/shell.php +++ b/lang/de/shell.php @@ -25,6 +25,7 @@ return [ 'nav_docker' => 'Docker', 'nav_commands' => 'Befehle', 'nav_alerts' => 'Alarme', + 'nav_posture' => 'Sicherheitslage', 'nav_threats' => 'Bedrohungen', 'nav_versions' => 'Version', 'nav_help' => 'Hilfe', diff --git a/lang/en/posture.php b/lang/en/posture.php new file mode 100644 index 0000000..20371dc --- /dev/null +++ b/lang/en/posture.php @@ -0,0 +1,29 @@ + 'Security', + 'title' => 'Security posture', + 'subtitle' => 'Per-server hardening score across the fleet', + + 'scan_hint' => 'Scanning servers …', + 'fleet_avg' => 'Fleet average', + 'no_servers_title' => 'No servers', + 'no_servers' => 'No servers with an active credential to scan.', + + // Rating + 'rating_strong' => 'Strong', + 'rating_fair' => 'Fair', + 'rating_weak' => 'Weak', + + // Per server + 'passed_of' => ':passed/:applicable checks passed', + 'view_server' => 'Open server', + 'scan_error' => 'Scan failed', + + // Checks + 'check_secure' => 'OK', + 'check_open' => 'Open', + 'check_neutral' => 'Info', + 'check_unsupported' => 'n/a', + 'remediation_hint' => 'Harden the open items on the server details page.', +]; diff --git a/lang/en/shell.php b/lang/en/shell.php index 999f8e6..632e687 100644 --- a/lang/en/shell.php +++ b/lang/en/shell.php @@ -25,6 +25,7 @@ return [ 'nav_docker' => 'Docker', 'nav_commands' => 'Commands', 'nav_alerts' => 'Alerts', + 'nav_posture' => 'Security posture', 'nav_threats' => 'Threats', 'nav_versions' => 'Version', 'nav_help' => 'Help', diff --git a/resources/views/components/sidebar.blade.php b/resources/views/components/sidebar.blade.php index 5bd3d82..8407d8d 100644 --- a/resources/views/components/sidebar.blade.php +++ b/resources/views/components/sidebar.blade.php @@ -60,6 +60,9 @@

{{ __('shell.group_account') }}

{{ __('shell.nav_settings') }} {{ __('shell.nav_system') }} + @can('operate') + {{ __('shell.nav_posture') }} + @endcan @can('manage-panel') {{ __('shell.nav_alerts') }} {{ __('shell.nav_threats') }} diff --git a/resources/views/livewire/posture/index.blade.php b/resources/views/livewire/posture/index.blade.php new file mode 100644 index 0000000..991c6aa --- /dev/null +++ b/resources/views/livewire/posture/index.blade.php @@ -0,0 +1,76 @@ +@php + $ratingStatus = ['strong' => 'online', 'fair' => 'warning', 'weak' => 'offline']; + $scoreColor = fn (int $s) => $s >= 90 ? 'text-online' : ($s >= 60 ? 'text-warning' : 'text-offline'); +@endphp + +
+ {{-- Header --}} +
+
+

{{ __('posture.eyebrow') }}

+

{{ __('posture.title') }}

+

{{ __('posture.subtitle') }}

+
+ @if ($ready && ! is_null($average)) +
+

{{ __('posture.fleet_avg') }}

+

{{ $average }}/100

+
+ @endif +
+ + @if ($servers->isEmpty()) + +
+

{{ __('posture.no_servers_title') }}

+

{{ __('posture.no_servers') }}

+
+
+ @elseif (! $ready) +
+ @foreach ($servers as $s) +
+ @endforeach +

{{ __('posture.scan_hint') }}

+
+ @else +
+ @foreach ($servers as $s) + @php($r = $scores[$s->uuid] ?? null) + + @if (! $r || isset($r['error'])) +
+ + {{ $s->name }} + {{ __('posture.scan_error') }} +
+ @else +
+
+

{{ $r['score'] }}

+ {{ __('posture.rating_'.$r['rating']) }} +
+
+ {{ $s->name }} +

{{ __('posture.passed_of', ['passed' => $r['passed'], 'applicable' => $r['applicable']]) }}

+
+ @foreach ($r['checks'] as $c) + @if ($c['counts']) + $c['secure'], + 'border-offline/20 bg-offline/10 text-offline' => ! $c['secure'], + ])> + {{ $c['label'] }} + + @endif + @endforeach +
+
+
+ @endif +
+ @endforeach +
+ @endif +
diff --git a/routes/web.php b/routes/web.php index f807840..8d9918a 100644 --- a/routes/web.php +++ b/routes/web.php @@ -13,6 +13,7 @@ use App\Livewire\Dashboard; use App\Livewire\Docker; use App\Livewire\Files; use App\Livewire\Help; +use App\Livewire\Posture; use App\Livewire\Release; use App\Livewire\Servers; use App\Livewire\Services; @@ -237,6 +238,7 @@ Route::middleware('auth')->group(function () { Route::get('/settings', Settings\Index::class)->name('settings'); Route::get('/versions', Versions\Index::class)->name('versions'); Route::get('/system', System\Index::class)->name('system'); + Route::get('/posture', Posture\Index::class)->middleware('can:operate')->name('posture'); Route::get('/help', Help\Index::class)->name('help'); Route::get('/wireguard', Wireguard\Index::class)->middleware('can:manage-network')->name('wireguard'); Route::get('/terminal', Terminal\Index::class)->name('terminal'); diff --git a/tests/Feature/PostureComponentTest.php b/tests/Feature/PostureComponentTest.php new file mode 100644 index 0000000..994e2c5 --- /dev/null +++ b/tests/Feature/PostureComponentTest.php @@ -0,0 +1,91 @@ +create(['must_change_password' => false]); + } + + private function operator(): User + { + return User::factory()->operator()->create(['must_change_password' => false]); + } + + private function viewer(): User + { + return User::factory()->viewer()->create(['must_change_password' => false]); + } + + private function server(string $name = 'box', string $ip = '10.0.0.1'): Server + { + $s = Server::create(['name' => $name, 'ip' => $ip, 'ssh_port' => 22, 'status' => 'online']); + $s->credential()->create(['username' => 'root', 'auth_type' => 'password', 'secret' => 'x']); + + return $s; + } + + public function test_viewer_cannot_open_posture(): void + { + $this->actingAs($this->viewer())->get('/posture')->assertForbidden(); + } + + public function test_operator_can_open_posture(): void + { + $this->actingAs($this->operator())->get('/posture')->assertOk(); + } + + public function test_scan_scores_each_active_credential_server(): void + { + $this->actingAs($this->operator()); + $this->server('web1', '10.0.0.1'); + + $posture = Mockery::mock(PostureService::class); + $posture->shouldReceive('score')->once()->andReturn([ + 'score' => 75, 'rating' => 'fair', 'passed' => 3, 'applicable' => 4, 'checks' => [], + ]); + app()->instance(PostureService::class, $posture); + + Livewire::test(Index::class) + ->call('scan') + ->assertOk() + ->assertSet('ready', true) + ->assertSee('75') + ->assertSee(__('posture.rating_fair')); + } + + public function test_a_server_that_cannot_be_scanned_shows_an_error_row_not_a_crash(): void + { + $this->actingAs($this->operator()); + $this->server('down', '10.0.0.9'); + + $posture = Mockery::mock(PostureService::class); + $posture->shouldReceive('score')->once()->andThrow(new \RuntimeException('ssh unreachable')); + app()->instance(PostureService::class, $posture); + + Livewire::test(Index::class) + ->call('scan') + ->assertOk() + ->assertSet('ready', true) + ->assertSee(__('posture.scan_error')); + } +} diff --git a/tests/Feature/PostureServiceTest.php b/tests/Feature/PostureServiceTest.php new file mode 100644 index 0000000..f84ba4e --- /dev/null +++ b/tests/Feature/PostureServiceTest.php @@ -0,0 +1,91 @@ + 'box', 'ip' => '10.0.0.1', 'ssh_port' => 22, 'status' => 'online']); + } + + /** @param array> $state */ + private function scoreWith(array $state): array + { + $hardening = Mockery::mock(HardeningService::class); + $hardening->shouldReceive('state')->andReturn($state); + + return (new PostureService($hardening))->score($this->server()); + } + + private function item(string $key, bool $secure, bool $supported = true, bool $neutral = false): array + { + return ['key' => $key, 'label' => $key, 'detail' => '', 'featureOn' => false, 'secure' => $secure, 'supported' => $supported, 'reason' => $supported ? null : 'x', 'neutral' => $neutral]; + } + + public function test_score_is_the_ratio_of_passing_applicable_gates(): void + { + $r = $this->scoreWith([ + $this->item('ssh_root', true), + $this->item('ssh_password', true), + $this->item('fail2ban', true), + $this->item('firewall', false), // 3 of 4 secure + ]); + + $this->assertSame(75, $r['score']); + $this->assertSame(3, $r['passed']); + $this->assertSame(4, $r['applicable']); + $this->assertSame('fair', $r['rating']); + } + + public function test_all_secure_is_a_strong_hundred(): void + { + $r = $this->scoreWith([$this->item('a', true), $this->item('b', true)]); + + $this->assertSame(100, $r['score']); + $this->assertSame('strong', $r['rating']); + } + + public function test_neutral_and_unsupported_items_do_not_count(): void + { + $r = $this->scoreWith([ + $this->item('ssh_root', true), // counts, secure + $this->item('unattended', false, neutral: true), // neutral → excluded + $this->item('firewall', false, supported: false), // unsupported → excluded + ]); + + $this->assertSame(1, $r['applicable']); // only ssh_root counts + $this->assertSame(100, $r['score']); // 1/1 secure + } + + public function test_all_failing_is_weak(): void + { + $r = $this->scoreWith([$this->item('a', false), $this->item('b', false)]); + + $this->assertSame(0, $r['score']); + $this->assertSame('weak', $r['rating']); + } + + public function test_no_applicable_checks_defaults_to_a_full_score(): void + { + $r = $this->scoreWith([$this->item('unattended', false, neutral: true)]); + + $this->assertSame(0, $r['applicable']); + $this->assertSame(100, $r['score']); // nothing to fail → not penalised + } +}