feat(security): fleet security-posture score (feature 5/8)

A per-server hardening SCORE + a fleet scoreboard, so an operator sees at a glance which servers
are hardened. Reuses the (already-reviewed) HardeningService::state gates rather than re-probing;
remediation for a failing check is the existing hardening toggle on the server-details page.

- PostureService::score(Server): aggregates the hardening state into { score 0-100, rating
  strong/fair/weak, passed, applicable, checks[] }. Only real security gates count — the neutral
  auto-updates preference and OS-unsupported items are excluded, so no server is penalised for a
  check it can't run.
- Posture\Index page (route /posture, `operate`-gated: reading a server's posture exposes its gaps).
  Lazy wire:init scan of every active-credential server, each guarded — one unreachable host shows an
  error row, never blocks the scan. Fleet-average KPI + per-server score/rating + the open gates.
- lang/{de,en}/posture.php + shell.nav_posture (de/en parity). Status via the triad, no emoji.

9 new tests: service (score = passing/applicable ratio, all-secure=strong-100, neutral+unsupported
excluded, all-fail=weak, no-applicable=full), component (route gating viewer 403 / operator ok, scan
scores each server, an unscannable server shows an error row not a crash). 709 tests green, Pint,
lang parity. (No destructive actions / new SSH — a read-only aggregation of Codex-validated hardening
state; self-reviewed for RBAC, scan-loop containment, and blade escaping.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-07-05 20:43:56 +02:00
parent 5e4f29216d
commit 31852fc04c
11 changed files with 454 additions and 0 deletions

View File

@ -0,0 +1,63 @@
<?php
namespace App\Livewire\Posture;
use App\Models\Server;
use App\Services\PostureService;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Throwable;
/**
* Fleet security-posture scoreboard. Scores each active-credential server from its hardening state
* (PostureService). Reading a server's posture exposes its hardening GAPS, so the page is
* `operate`-gated (route + mount). The scan is lazy (wire:init) and per-server guarded one
* unreachable host shows an error row instead of blocking the whole scan.
*/
#[Layout('layouts.app')]
class Index extends Component
{
/** @var array<string, array<string, mixed>> 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());
}
}

View File

@ -0,0 +1,68 @@
<?php
namespace App\Services;
use App\Models\Server;
/**
* Turns a server's hardening state into a single security-posture SCORE. Reuses
* HardeningService::state() (the CIS-relevant gates: SSH root/password, fail2ban, firewall) rather
* than re-probing. `neutral` items (auto-updates = an operator preference, not a gate) and
* OS-unsupported items don't count toward the score. The remediation for a failing check is the
* existing hardening toggle on the server-details page.
*/
class PostureService
{
public function __construct(private HardeningService $hardening) {}
/**
* @return array{score:int, rating:string, passed:int, applicable:int,
* checks:array<int, array{key:string,label:string,detail:string,secure:bool,supported:bool,neutral:bool,counts:bool}>}
*/
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');
}
}

29
lang/de/posture.php Normal file
View File

@ -0,0 +1,29 @@
<?php
return [
'eyebrow' => '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.',
];

View File

@ -25,6 +25,7 @@ return [
'nav_docker' => 'Docker', 'nav_docker' => 'Docker',
'nav_commands' => 'Befehle', 'nav_commands' => 'Befehle',
'nav_alerts' => 'Alarme', 'nav_alerts' => 'Alarme',
'nav_posture' => 'Sicherheitslage',
'nav_threats' => 'Bedrohungen', 'nav_threats' => 'Bedrohungen',
'nav_versions' => 'Version', 'nav_versions' => 'Version',
'nav_help' => 'Hilfe', 'nav_help' => 'Hilfe',

29
lang/en/posture.php Normal file
View File

@ -0,0 +1,29 @@
<?php
return [
'eyebrow' => '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.',
];

View File

@ -25,6 +25,7 @@ return [
'nav_docker' => 'Docker', 'nav_docker' => 'Docker',
'nav_commands' => 'Commands', 'nav_commands' => 'Commands',
'nav_alerts' => 'Alerts', 'nav_alerts' => 'Alerts',
'nav_posture' => 'Security posture',
'nav_threats' => 'Threats', 'nav_threats' => 'Threats',
'nav_versions' => 'Version', 'nav_versions' => 'Version',
'nav_help' => 'Help', 'nav_help' => 'Help',

View File

@ -60,6 +60,9 @@
<p class="px-3 pb-1 pt-3 font-mono text-[10px] uppercase tracking-widest text-ink-4">{{ __('shell.group_account') }}</p> <p class="px-3 pb-1 pt-3 font-mono text-[10px] uppercase tracking-widest text-ink-4">{{ __('shell.group_account') }}</p>
<x-nav-item icon="settings" href="/settings" :active="request()->is('settings*')" data-tour="settings">{{ __('shell.nav_settings') }}</x-nav-item> <x-nav-item icon="settings" href="/settings" :active="request()->is('settings*')" data-tour="settings">{{ __('shell.nav_settings') }}</x-nav-item>
<x-nav-item icon="shield" href="/system" :active="request()->is('system*')">{{ __('shell.nav_system') }}</x-nav-item> <x-nav-item icon="shield" href="/system" :active="request()->is('system*')">{{ __('shell.nav_system') }}</x-nav-item>
@can('operate')
<x-nav-item icon="shield-check" href="/posture" :active="request()->is('posture*')">{{ __('shell.nav_posture') }}</x-nav-item>
@endcan
@can('manage-panel') @can('manage-panel')
<x-nav-item icon="alert" href="/alerts" :active="request()->is('alerts*')" :badge="$alertCount > 0 ? $alertCount : null" :badge-title="$alertCount > 0 ? __('alerts.incidents_title') : null">{{ __('shell.nav_alerts') }}</x-nav-item> <x-nav-item icon="alert" href="/alerts" :active="request()->is('alerts*')" :badge="$alertCount > 0 ? $alertCount : null" :badge-title="$alertCount > 0 ? __('alerts.incidents_title') : null">{{ __('shell.nav_alerts') }}</x-nav-item>
<x-nav-item icon="shield-alert" href="/threats" :active="request()->is('threats*')" :badge="$threatCount > 0 ? $threatCount : null" :badge-title="$threatCount > 0 ? __('shell.threats_badge', ['count' => $threatCount]) : null">{{ __('shell.nav_threats') }}</x-nav-item> <x-nav-item icon="shield-alert" href="/threats" :active="request()->is('threats*')" :badge="$threatCount > 0 ? $threatCount : null" :badge-title="$threatCount > 0 ? __('shell.threats_badge', ['count' => $threatCount]) : null">{{ __('shell.nav_threats') }}</x-nav-item>

View File

@ -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
<div class="space-y-4" @if (! $ready) wire:init="scan" @endif>
{{-- Header --}}
<div class="flex flex-wrap items-end justify-between gap-3">
<div>
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">{{ __('posture.eyebrow') }}</p>
<h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">{{ __('posture.title') }}</h2>
<p class="mt-1 text-sm text-ink-3">{{ __('posture.subtitle') }}</p>
</div>
@if ($ready && ! is_null($average))
<div class="text-right">
<p class="font-mono text-[10px] uppercase tracking-wider text-ink-3">{{ __('posture.fleet_avg') }}</p>
<p class="tabular font-mono text-2xl font-semibold {{ $scoreColor($average) }}">{{ $average }}<span class="text-sm text-ink-3">/100</span></p>
</div>
@endif
</div>
@if ($servers->isEmpty())
<x-panel>
<div class="py-8 text-center">
<p class="font-display text-sm font-semibold text-ink">{{ __('posture.no_servers_title') }}</p>
<p class="mt-1 text-xs text-ink-3">{{ __('posture.no_servers') }}</p>
</div>
</x-panel>
@elseif (! $ready)
<div class="space-y-2">
@foreach ($servers as $s)
<div wire:key="sk-{{ $s->uuid }}" class="h-20 animate-pulse rounded-lg bg-raised/50"></div>
@endforeach
<p class="text-center font-mono text-[11px] text-ink-4">{{ __('posture.scan_hint') }}</p>
</div>
@else
<div class="space-y-3">
@foreach ($servers as $s)
@php($r = $scores[$s->uuid] ?? null)
<x-panel wire:key="pk-{{ $s->uuid }}">
@if (! $r || isset($r['error']))
<div class="flex items-center gap-3">
<x-status-dot status="offline" class="h-2.5 w-2.5" />
<span class="min-w-0 flex-1 truncate font-mono text-sm text-ink">{{ $s->name }}</span>
<span class="font-mono text-[11px] text-offline">{{ __('posture.scan_error') }}</span>
</div>
@else
<div class="flex flex-wrap items-center gap-4">
<div class="shrink-0 text-center">
<p class="tabular font-mono text-3xl font-semibold {{ $scoreColor($r['score']) }}">{{ $r['score'] }}</p>
<x-status-pill :status="$ratingStatus[$r['rating']]">{{ __('posture.rating_'.$r['rating']) }}</x-status-pill>
</div>
<div class="min-w-0 flex-1">
<a href="{{ route('servers.show', $s->uuid) }}" wire:navigate class="font-display text-sm font-semibold text-ink hover:text-accent-text">{{ $s->name }}</a>
<p class="mt-0.5 font-mono text-[11px] text-ink-3">{{ __('posture.passed_of', ['passed' => $r['passed'], 'applicable' => $r['applicable']]) }}</p>
<div class="mt-2 flex flex-wrap gap-1.5">
@foreach ($r['checks'] as $c)
@if ($c['counts'])
<span @class([
'inline-flex items-center gap-1 rounded-sm border px-1.5 py-0.5 font-mono text-[10px]',
'border-online/20 bg-online/10 text-online' => $c['secure'],
'border-offline/20 bg-offline/10 text-offline' => ! $c['secure'],
])>
{{ $c['label'] }}
</span>
@endif
@endforeach
</div>
</div>
</div>
@endif
</x-panel>
@endforeach
</div>
@endif
</div>

View File

@ -13,6 +13,7 @@ use App\Livewire\Dashboard;
use App\Livewire\Docker; use App\Livewire\Docker;
use App\Livewire\Files; use App\Livewire\Files;
use App\Livewire\Help; use App\Livewire\Help;
use App\Livewire\Posture;
use App\Livewire\Release; use App\Livewire\Release;
use App\Livewire\Servers; use App\Livewire\Servers;
use App\Livewire\Services; use App\Livewire\Services;
@ -237,6 +238,7 @@ Route::middleware('auth')->group(function () {
Route::get('/settings', Settings\Index::class)->name('settings'); Route::get('/settings', Settings\Index::class)->name('settings');
Route::get('/versions', Versions\Index::class)->name('versions'); Route::get('/versions', Versions\Index::class)->name('versions');
Route::get('/system', System\Index::class)->name('system'); 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('/help', Help\Index::class)->name('help');
Route::get('/wireguard', Wireguard\Index::class)->middleware('can:manage-network')->name('wireguard'); Route::get('/wireguard', Wireguard\Index::class)->middleware('can:manage-network')->name('wireguard');
Route::get('/terminal', Terminal\Index::class)->name('terminal'); Route::get('/terminal', Terminal\Index::class)->name('terminal');

View File

@ -0,0 +1,91 @@
<?php
namespace Tests\Feature;
use App\Livewire\Posture\Index;
use App\Models\Server;
use App\Models\User;
use App\Services\PostureService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Mockery;
use Tests\TestCase;
class PostureComponentTest extends TestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
private function admin(): User
{
return User::factory()->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'));
}
}

View File

@ -0,0 +1,91 @@
<?php
namespace Tests\Feature;
use App\Models\Server;
use App\Services\HardeningService;
use App\Services\PostureService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery;
use Tests\TestCase;
class PostureServiceTest extends TestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
private function server(): Server
{
return Server::create(['name' => 'box', 'ip' => '10.0.0.1', 'ssh_port' => 22, 'status' => 'online']);
}
/** @param array<int,array<string,mixed>> $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
}
}