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
parent
615f440515
commit
ed909edf3e
|
|
@ -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),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue