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