From ed909edf3ee5390e96b65850c03b69758503239f Mon Sep 17 00:00:00 2001 From: boban Date: Sun, 14 Jun 2026 10:23:28 +0200 Subject: [PATCH] feat(hardening): treat auto-updates as a neutral operator preference Automatic updates are a choice, not a security gate: the unattended row now reports secure=true always and carries a `neutral` flag. The checklist renders it muted (aktiv/inaktiv) instead of the green/orange SICHER/OFFEN verdict, so "off" is never flagged as insecure. Detection is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/Services/HardeningService.php | 12 ++++++--- .../views/livewire/servers/show.blade.php | 25 ++++++++++++------- tests/Feature/HardeningServiceTest.php | 23 +++++++++++++++++ 3 files changed, 47 insertions(+), 13 deletions(-) diff --git a/app/Services/HardeningService.php b/app/Services/HardeningService.php index d9206ab..365b9e3 100644 --- a/app/Services/HardeningService.php +++ b/app/Services/HardeningService.php @@ -43,7 +43,7 @@ class HardeningService * package/firewall/auto-update probes are built per-OS (dpkg vs rpm, ufw vs * firewalld, apt periodic vs dnf-automatic). * - * @return array + * @return array */ public function state(Server $server): array { @@ -125,10 +125,10 @@ class HardeningService // stale config (e.g. apt periodic = 1 after removal) must not read as secure. $on = fn (array $st): bool => $st['installed'] && $st['active']; - $row = fn (string $key, string $label, string $detail, bool $featureOn, bool $secure, ?string $reason): array => [ + $row = fn (string $key, string $label, string $detail, bool $featureOn, bool $secure, ?string $reason, bool $neutral = false): array => [ 'key' => $key, 'label' => $label, 'detail' => $detail, 'featureOn' => $featureOn, 'secure' => $secure, - 'supported' => $reason === null, 'reason' => $reason, + 'supported' => $reason === null, 'reason' => $reason, 'neutral' => $neutral, ]; $fwName = $os->firewallTool === 'firewalld' ? 'firewalld' : 'ufw'; @@ -140,7 +140,11 @@ class HardeningService $row('ssh_password', __('backend.label_ssh_password'), 'PasswordAuthentication '.$pwauth, $pwauth !== 'no', $pwauth === 'no', $os->supports('ssh')), $row('fail2ban', 'fail2ban', $detail('fail2ban.service', $f2b), $on($f2b), $on($f2b), $os->supports('fail2ban')), $row('firewall', $os->firewallLabel(), $detail($fwName, $fw), $on($fw), $on($fw), $os->supports('firewall')), - $row('unattended', __('backend.label_auto_updates'), $detail($auName, $upg), $on($upg), $on($upg), $os->supports('auto_updates')), + // Auto-updates are an operator PREFERENCE, not a security gate: never an + // insecure verdict (secure = true), and flagged `neutral` so the UI shows a + // muted aktiv/inaktiv state instead of SICHER/OFFEN. featureOn still drives + // the Aktivieren/Deaktivieren toggle from the real host state. + $row('unattended', __('backend.label_auto_updates'), $detail($auName, $upg), $on($upg), true, $os->supports('auto_updates'), true), ]; } diff --git a/resources/views/livewire/servers/show.blade.php b/resources/views/livewire/servers/show.blade.php index 8b4eb1a..394b400 100644 --- a/resources/views/livewire/servers/show.blade.php +++ b/resources/views/livewire/servers/show.blade.php @@ -178,29 +178,36 @@
@foreach ($hardening as $check) - @php $supported = $check['supported'] ?? true; @endphp + @php + $supported = $check['supported'] ?? true; + // Neutral rows (auto-updates) are a preference, not a security + // verdict: muted glyph + aktiv/inaktiv, never SICHER/OFFEN. + $neutral = $check['neutral'] ?? false; + @endphp {{-- One calm state signal: a lock glyph (geschlossen = sicher, offen = offen); - unsupported items render muted with a German reason and no toggle. --}} + unsupported/neutral items render muted, no warning tone. --}}
$supported && $check['secure'], - 'border-warning/25 bg-warning/10 text-warning' => $supported && ! $check['secure'], - 'border-line bg-raised text-ink-4' => ! $supported, + 'border-online/25 bg-online/10 text-online' => $supported && ! $neutral && $check['secure'], + 'border-warning/25 bg-warning/10 text-warning' => $supported && ! $neutral && ! $check['secure'], + 'border-line bg-raised text-ink-4' => ! $supported || $neutral, ])> - +

{{ $check['label'] }} - @if ($supported) + @if (! $supported) + {{ __('common.unsupported') }} + @elseif ($neutral) + {{ $check['featureOn'] ? __('common.active') : __('common.inactive') }} + @else $check['secure'], 'text-warning' => ! $check['secure'], ])>{{ $check['secure'] ? __('servers.check_secure') : __('servers.check_open') }} - @else - {{ __('common.unsupported') }} @endif

{{ $supported ? $check['detail'] : $check['reason'] }}

diff --git a/tests/Feature/HardeningServiceTest.php b/tests/Feature/HardeningServiceTest.php index 31b38e8..244935a 100644 --- a/tests/Feature/HardeningServiceTest.php +++ b/tests/Feature/HardeningServiceTest.php @@ -66,4 +66,27 @@ class HardeningServiceTest extends TestCase $f2b = collect($rows)->firstWhere('key', 'fail2ban'); $this->assertTrue($f2b['featureOn'], 'fail2ban active must read as on'); } + + public function test_autoupdate_row_is_neutral_and_never_insecure(): void + { + $os = $this->debianProfile(); + + $detector = Mockery::mock(OsDetector::class); + $detector->shouldReceive('detect')->andReturn($os); + + $fleet = Mockery::mock(FleetService::class); + $fleet->shouldReceive('runPrivileged')->andReturn([ + 'ok' => true, + // auto-update reported INACTIVE by the host: + 'output' => "fail2ban 1 active\nfirewall 1 active\nautoupdate 1 inactive", + ]); + + $service = new HardeningService($fleet, Mockery::mock(FirewallService::class), $detector); + $rows = $service->state(new Server); + + $au = collect($rows)->firstWhere('key', 'unattended'); + $this->assertTrue($au['secure'], 'auto-updates must never read as insecure'); + $this->assertTrue($au['neutral'] ?? false, 'auto-updates row must be flagged neutral'); + $this->assertFalse($au['featureOn'], 'inactive auto-updates → toggle shows enable'); + } }