From 221e49f24a8e6c8cbca5c2e2e328ec7066da5424 Mon Sep 17 00:00:00 2001 From: boban Date: Sun, 14 Jun 2026 09:22:28 +0200 Subject: [PATCH] fix(hardening): detect fail2ban/ufw as installed via binary or active service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hardening checklist read "installed" from the package manager alone (dpkg `^ii` / rpm `-q`), so it disagreed with the live fail2ban/firewall panels, which use `command -v` + `systemctl is-active`. On a host where the component is genuinely present but not recorded as an apt `^ii` package — installed from source, or left in dpkg `rc` (config-only) state — the checklist showed `nicht installiert` while the panel reported it active with real bans (the reported contradiction). The installed probe now ORs three conditions: the package-manager test, the client binary on PATH, and the service already running. Since a component can never be active without being installed, this only removes FALSE negatives — it can never produce a false positive. Detection is now consistent with Fail2banService::status and FirewallTool. Applies to both fail2ban and the firewall (ufw/firewalld). Adds HardeningServiceTest covering the Debian/apt source-install fallback: the emitted probe gains `command -v fail2ban-client` and `dpkg -l ufw`, and an active component reads as featureOn. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/Services/HardeningService.php | 27 ++++++++-- tests/Feature/HardeningServiceTest.php | 69 ++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 tests/Feature/HardeningServiceTest.php diff --git a/app/Services/HardeningService.php b/app/Services/HardeningService.php index 2ac01dd..d9206ab 100644 --- a/app/Services/HardeningService.php +++ b/app/Services/HardeningService.php @@ -52,7 +52,26 @@ class HardeningService $fw = FirewallTool::for($os); // Shell CONDITIONS (exit 0 = true); `false` where the host lacks the tool. - $f2bInst = $pm ? $pm->isInstalledTest('fail2ban') : 'false'; + // "Installed" is read CONSISTENTLY with Fail2banService/FirewallTool: the package + // manager (dpkg ^ii / rpm), OR the client binary on PATH, OR the service already + // running — ANY one proves the component is present. So a fail2ban/ufw installed + // from source or left in dpkg `rc` (config-only) state no longer reads as "nicht + // installiert" while its own live panel reports it active. A component can never be + // active without being installed, so this only removes FALSE negatives. + $anyOf = fn (array $tests): string => '('.implode(' || ', array_filter($tests)).')'; + + $f2bActive = 'systemctl is-active --quiet fail2ban'; + $f2bInst = $anyOf([ + $pm?->isInstalledTest('fail2ban'), + 'command -v fail2ban-client >/dev/null 2>&1', + $f2bActive, + ]); + $fwInst = $anyOf([ + $pm?->isInstalledTest($os->firewallTool === 'firewalld' ? 'firewalld' : 'ufw'), + $fw->installedTest(), + $fw->activeTest(), + ]); + $auInst = $pm ? $pm->isInstalledTest($os->packageManager === 'dnf' ? 'dnf-automatic' : 'unattended-upgrades') : 'false'; $auActive = match ($os->packageManager) { 'apt' => 'apt-config dump APT::Periodic::Unattended-Upgrade 2>/dev/null | grep -q \'"1"\'', @@ -60,9 +79,11 @@ class HardeningService default => 'false', }; + // NOTE the space after each `$(` that precedes a `(`: `$( (` is a command + // substitution of a subshell, whereas `$((` would be ARITHMETIC expansion. $cmd = 'sshd -T 2>/dev/null | grep -iE "^(permitrootlogin|passwordauthentication) "; ' - .'echo "fail2ban $('.$f2bInst.' && echo 1 || echo 0) $(systemctl is-active --quiet fail2ban && echo active || echo inactive)"; ' - .'echo "firewall $('.$fw->installedTest().' && echo 1 || echo 0) $('.$fw->activeTest().' && echo active || echo inactive)"; ' + .'echo "fail2ban $( '.$f2bInst.' && echo 1 || echo 0) $('.$f2bActive.' && echo active || echo inactive)"; ' + .'echo "firewall $( '.$fwInst.' && echo 1 || echo 0) $('.$fw->activeTest().' && echo active || echo inactive)"; ' .'echo "autoupdate $('.$auInst.' && echo 1 || echo 0) $('.$auActive.' && echo active || echo inactive)"'; $res = $this->fleet->runPrivileged($server, $cmd); diff --git a/tests/Feature/HardeningServiceTest.php b/tests/Feature/HardeningServiceTest.php new file mode 100644 index 0000000..31b38e8 --- /dev/null +++ b/tests/Feature/HardeningServiceTest.php @@ -0,0 +1,69 @@ +debianProfile(); + + $detector = Mockery::mock(OsDetector::class); + $detector->shouldReceive('detect')->andReturn($os); + + $captured = ''; + $fleet = Mockery::mock(FleetService::class); + $fleet->shouldReceive('runPrivileged') + ->andReturnUsing(function ($server, $cmd) use (&$captured) { + $captured = $cmd; + + // fail2ban running from a source/`rc` install: present + active. + return ['ok' => true, 'output' => "fail2ban 1 active\nfirewall 1 active\nautoupdate 0 inactive"]; + }); + + $service = new HardeningService($fleet, Mockery::mock(FirewallService::class), $detector); + + $rows = $service->state(new Server); + + // fail2ban installed-probe gains the binary fallback (was dpkg ^ii only — the bug). + $this->assertStringContainsString('command -v fail2ban-client', $captured); + // firewall installed-probe gains the package-manager fallback (was command -v only). + $this->assertStringContainsString('dpkg -l ufw', $captured); + + $f2b = collect($rows)->firstWhere('key', 'fail2ban'); + $this->assertTrue($f2b['featureOn'], 'fail2ban active must read as on'); + } +}