fix(hardening): detect fail2ban/ufw as installed via binary or active service
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) <noreply@anthropic.com>feat/v1-foundation
parent
2930f16d12
commit
221e49f24a
|
|
@ -52,7 +52,26 @@ class HardeningService
|
||||||
$fw = FirewallTool::for($os);
|
$fw = FirewallTool::for($os);
|
||||||
|
|
||||||
// Shell CONDITIONS (exit 0 = true); `false` where the host lacks the tool.
|
// 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';
|
$auInst = $pm ? $pm->isInstalledTest($os->packageManager === 'dnf' ? 'dnf-automatic' : 'unattended-upgrades') : 'false';
|
||||||
$auActive = match ($os->packageManager) {
|
$auActive = match ($os->packageManager) {
|
||||||
'apt' => 'apt-config dump APT::Periodic::Unattended-Upgrade 2>/dev/null | grep -q \'"1"\'',
|
'apt' => 'apt-config dump APT::Periodic::Unattended-Upgrade 2>/dev/null | grep -q \'"1"\'',
|
||||||
|
|
@ -60,9 +79,11 @@ class HardeningService
|
||||||
default => 'false',
|
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) "; '
|
$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 "fail2ban $( '.$f2bInst.' && echo 1 || echo 0) $('.$f2bActive.' && echo active || echo inactive)"; '
|
||||||
.'echo "firewall $('.$fw->installedTest().' && echo 1 || echo 0) $('.$fw->activeTest().' && 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)"';
|
.'echo "autoupdate $('.$auInst.' && echo 1 || echo 0) $('.$auActive.' && echo active || echo inactive)"';
|
||||||
|
|
||||||
$res = $this->fleet->runPrivileged($server, $cmd);
|
$res = $this->fleet->runPrivileged($server, $cmd);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Server;
|
||||||
|
use App\Services\FirewallService;
|
||||||
|
use App\Services\FleetService;
|
||||||
|
use App\Services\HardeningService;
|
||||||
|
use App\Support\Os\OsDetector;
|
||||||
|
use App\Support\Os\OsProfile;
|
||||||
|
use Mockery;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class HardeningServiceTest extends TestCase
|
||||||
|
{
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
Mockery::close();
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function debianProfile(): OsProfile
|
||||||
|
{
|
||||||
|
return new OsProfile(
|
||||||
|
family: 'debian',
|
||||||
|
id: 'debian',
|
||||||
|
prettyName: 'Debian GNU/Linux 13',
|
||||||
|
packageManager: 'apt',
|
||||||
|
firewallTool: 'ufw',
|
||||||
|
serviceManager: 'systemd',
|
||||||
|
available: ['apt-get', 'dpkg', 'systemctl', 'fail2ban-client', 'ufw'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The checklist must agree with the fail2ban/firewall panels: a component counts as
|
||||||
|
* installed when the package manager lists it (^ii/rpm) OR its binary is on PATH OR
|
||||||
|
* the service is running — not via dpkg `^ii` alone (the false "nicht installiert").
|
||||||
|
*/
|
||||||
|
public function test_installed_probe_falls_back_to_binary_and_service(): void
|
||||||
|
{
|
||||||
|
$os = $this->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue