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) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-14 10:23:28 +02:00
parent 615f440515
commit ed909edf3e
3 changed files with 47 additions and 13 deletions

View File

@ -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<int, array{key:string, label:string, detail:string, featureOn:bool, secure:bool, supported:bool, reason:?string}>
* @return array<int, array{key:string, label:string, detail:string, featureOn:bool, secure:bool, supported:bool, reason:?string, neutral:bool}>
*/
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),
];
}

View File

@ -178,29 +178,36 @@
<x-panel :title="__('servers.security_title')" :subtitle="__('servers.security_subtitle')" :padded="false">
<div class="divide-y divide-line">
@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. --}}
<div class="flex items-center gap-3.5 px-4 py-3 transition-colors hover:bg-raised/40 sm:px-5">
<span @class([
'grid h-9 w-9 shrink-0 place-items-center rounded-md border',
'border-online/25 bg-online/10 text-online' => $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,
])>
<x-icon :name="$supported && ! $check['secure'] ? 'lock-open' : 'lock'" class="h-4 w-4" />
<x-icon :name="$supported && ! $neutral && ! $check['secure'] ? 'lock-open' : 'lock'" class="h-4 w-4" />
</span>
<div class="min-w-0 flex-1">
<p class="flex items-center gap-2 truncate text-sm text-ink">
{{ $check['label'] }}
@if ($supported)
@if (! $supported)
<span class="shrink-0 font-mono text-[10px] uppercase tracking-wider text-ink-4">{{ __('common.unsupported') }}</span>
@elseif ($neutral)
<span class="shrink-0 font-mono text-[10px] uppercase tracking-wider text-ink-3">{{ $check['featureOn'] ? __('common.active') : __('common.inactive') }}</span>
@else
<span @class([
'shrink-0 font-mono text-[10px] uppercase tracking-wider',
'text-online' => $check['secure'],
'text-warning' => ! $check['secure'],
])>{{ $check['secure'] ? __('servers.check_secure') : __('servers.check_open') }}</span>
@else
<span class="shrink-0 font-mono text-[10px] uppercase tracking-wider text-ink-4">{{ __('common.unsupported') }}</span>
@endif
</p>
<p class="truncate font-mono text-[11px] text-ink-3">{{ $supported ? $check['detail'] : $check['reason'] }}</p>

View File

@ -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');
}
}