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
parent
5e4f29216d
commit
31852fc04c
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.',
|
||||||
|
];
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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.',
|
||||||
|
];
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue