feat(firewall): complete operational reliability phase
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -304,6 +304,154 @@ class FirewallStoreTest extends TestCase
|
||||
self::assertSame(0, $claimIndex['expireAfterSeconds']);
|
||||
}
|
||||
|
||||
#[TestDox('Automatic block extensions persist policy and expiration history')]
|
||||
public function testAutomaticBlockExtensionPersistence(): void
|
||||
{
|
||||
$events = $this->createMock(EventDispatcherInterface::class);
|
||||
$manager = new FirewallRuleManager($this->store, new FirewallRuleCache($this->store), $events);
|
||||
$scope = FirewallRuleScope::tenant('tenant-a');
|
||||
$rule = $manager->blockIp(
|
||||
$scope,
|
||||
'203.0.113.10',
|
||||
'Initial attack',
|
||||
null,
|
||||
60,
|
||||
FirewallRuleManager::ORIGIN_AUTOMATIC,
|
||||
[
|
||||
'failureThreshold' => 5,
|
||||
'failureWindowSeconds' => 300,
|
||||
'lastFailureCount' => 5,
|
||||
'blockDurationSeconds' => 60,
|
||||
]
|
||||
);
|
||||
$originalExpiry = $rule->getExpiresAt();
|
||||
|
||||
$extended = $manager->blockIp(
|
||||
$scope,
|
||||
'203.0.113.10',
|
||||
'Continued attack',
|
||||
null,
|
||||
3600,
|
||||
FirewallRuleManager::ORIGIN_AUTOMATIC,
|
||||
[
|
||||
'failureThreshold' => 5,
|
||||
'failureWindowSeconds' => 300,
|
||||
'lastFailureCount' => 9,
|
||||
'blockDurationSeconds' => 3600,
|
||||
]
|
||||
);
|
||||
$persisted = $this->store->fetchRule($rule->getId());
|
||||
|
||||
self::assertSame($rule->getId(), $extended->getId());
|
||||
self::assertNotNull($persisted);
|
||||
self::assertGreaterThan($originalExpiry, $persisted->getExpiresAt());
|
||||
self::assertSame(9, $persisted->getMetadata()['lastFailureCount']);
|
||||
self::assertCount(1, $persisted->getMetadata()['extensions']);
|
||||
}
|
||||
|
||||
#[TestDox('Cleanup removes only expired rules, old logs, and expired claims')]
|
||||
public function testCleanupBoundaries(): void
|
||||
{
|
||||
$this->store->depositRule(
|
||||
$this->rule('expired', FirewallRuleObject::SCOPE_TENANT, 'tenant-a')
|
||||
->setExpiresAt(new \DateTimeImmutable('-1 minute'))
|
||||
);
|
||||
$this->store->depositRule(
|
||||
$this->rule('active', FirewallRuleObject::SCOPE_TENANT, 'tenant-a')
|
||||
->setExpiresAt(new \DateTimeImmutable('+1 hour'))
|
||||
);
|
||||
foreach (['-31 days', '-29 days'] as $age) {
|
||||
$this->store->createLog(
|
||||
(new FirewallLogObject())
|
||||
->setTenantId('tenant-a')
|
||||
->setEventType(FirewallLogObject::EVENT_ACCESS_CHECK)
|
||||
->setResult(FirewallLogObject::RESULT_ALLOWED)
|
||||
->setTimestamp(new \DateTimeImmutable($age))
|
||||
);
|
||||
}
|
||||
self::assertTrue($this->store->claimBruteForce('tenant-a', '203.0.113.10', 3600));
|
||||
$claimId = hash('sha256', "tenant-a\0"."203.0.113.10");
|
||||
$this->dataStore->selectCollection('firewall_brute_force_claims')->updateOne(
|
||||
['_id' => $claimId],
|
||||
['$set' => ['expiresAt' => UTCDateTime::fromDateTime(new \DateTimeImmutable('-1 minute'))]]
|
||||
);
|
||||
|
||||
self::assertSame(1, $this->store->cleanupExpiredRules());
|
||||
self::assertSame(1, $this->store->cleanupOldLogs());
|
||||
self::assertSame(1, $this->store->cleanupExpiredBruteForceClaims());
|
||||
self::assertCount(1, $this->store->listRules('tenant-a'));
|
||||
self::assertCount(1, $this->store->listLogs('tenant-a'));
|
||||
}
|
||||
|
||||
#[TestDox('Maintenance outcomes are persisted with BSON timestamps')]
|
||||
public function testMaintenanceStatusPersistence(): void
|
||||
{
|
||||
$this->store->recordMaintenanceStatus(
|
||||
new \DateTimeImmutable('-1 second'),
|
||||
new \DateTimeImmutable(),
|
||||
'success',
|
||||
['expiredRules' => 2, 'oldLogs' => 3, 'expiredBruteForceClaims' => 4]
|
||||
);
|
||||
|
||||
$status = $this->store->maintenanceStatus();
|
||||
$raw = $this->dataStore->selectCollection('firewall_maintenance')
|
||||
->getMongoCollection()
|
||||
->findOne(['_id' => 'cleanup']);
|
||||
|
||||
self::assertSame('success', $status['status']);
|
||||
self::assertSame(3, $status['result']['oldLogs']);
|
||||
self::assertInstanceOf(\MongoDB\BSON\UTCDateTime::class, $raw['startedAt']);
|
||||
self::assertInstanceOf(\MongoDB\BSON\UTCDateTime::class, $raw['completedAt']);
|
||||
}
|
||||
|
||||
#[TestDox('Firewall query shapes select their intended indexes')]
|
||||
public function testIndexedQueryShapes(): void
|
||||
{
|
||||
$this->store->ensureIndexes();
|
||||
$this->store->depositRule($this->rule('indexed', FirewallRuleObject::SCOPE_TENANT, 'tenant-a'));
|
||||
$this->store->createLog(
|
||||
(new FirewallLogObject())
|
||||
->setTenantId('tenant-a')
|
||||
->setIpAddress('203.0.113.10')
|
||||
->setEventType(FirewallLogObject::EVENT_AUTH_FAILURE)
|
||||
->setResult(FirewallLogObject::RESULT_BLOCKED)
|
||||
->setTimestamp(new \DateTimeImmutable())
|
||||
);
|
||||
|
||||
$database = $this->dataStore->getDatabase()->getMongoDatabase();
|
||||
$now = new \MongoDB\BSON\UTCDateTime();
|
||||
$rulePlan = $database->command([
|
||||
'explain' => [
|
||||
'find' => 'firewall_rules',
|
||||
'filter' => [
|
||||
'scope' => FirewallRuleObject::SCOPE_TENANT,
|
||||
'tenantId' => 'tenant-a',
|
||||
'type' => FirewallRuleObject::TYPE_IP,
|
||||
'value' => '203.0.113.10',
|
||||
'action' => FirewallRuleObject::ACTION_BLOCK,
|
||||
'enabled' => true,
|
||||
'$or' => [['expiresAt' => null], ['expiresAt' => ['$gt' => $now]]],
|
||||
],
|
||||
],
|
||||
'verbosity' => 'queryPlanner',
|
||||
])->toArray()[0];
|
||||
$failurePlan = $database->command([
|
||||
'explain' => [
|
||||
'find' => 'firewall_logs',
|
||||
'filter' => [
|
||||
'tenantId' => 'tenant-a',
|
||||
'ipAddress' => '203.0.113.10',
|
||||
'eventType' => FirewallLogObject::EVENT_AUTH_FAILURE,
|
||||
'timestamp' => ['$gte' => new \MongoDB\BSON\UTCDateTime(0)],
|
||||
],
|
||||
],
|
||||
'verbosity' => 'queryPlanner',
|
||||
])->toArray()[0];
|
||||
|
||||
self::assertContains('rules_exact_lookup', self::indexNames($rulePlan));
|
||||
self::assertContains('logs_auth_failures', self::indexNames($failurePlan));
|
||||
}
|
||||
|
||||
private function rule(
|
||||
string $reason,
|
||||
string $scope,
|
||||
@@ -319,4 +467,21 @@ class FirewallStoreTest extends TestCase
|
||||
->setCreatedAt(new \DateTimeImmutable())
|
||||
->setEnabled(true);
|
||||
}
|
||||
|
||||
private static function indexNames(mixed $value): array
|
||||
{
|
||||
if (is_object($value)) {
|
||||
$value = (array)$value;
|
||||
}
|
||||
if (!is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$names = isset($value['indexName']) ? [(string)$value['indexName']] : [];
|
||||
foreach ($value as $child) {
|
||||
$names = [...$names, ...self::indexNames($child)];
|
||||
}
|
||||
|
||||
return array_values(array_unique($names));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXT\Unit\Console\Firewall;
|
||||
|
||||
use KTXC\Console\Firewall\FirewallMaintenanceCommand;
|
||||
use KTXC\Console\Firewall\FirewallSetupCommand;
|
||||
use KTXC\Service\FirewallService;
|
||||
use KTXC\Stores\FirewallStore;
|
||||
use PHPUnit\Framework\Attributes\TestDox;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
final class FirewallCommandsTest extends TestCase
|
||||
{
|
||||
#[TestDox('Setup verifies every firewall database index')]
|
||||
public function testSetup(): void
|
||||
{
|
||||
$store = $this->createMock(FirewallStore::class);
|
||||
$store->expects(self::once())->method('ensureIndexes')->willReturn(array_fill(0, 7, 'index'));
|
||||
$tester = new CommandTester(new FirewallSetupCommand($store));
|
||||
|
||||
self::assertSame(Command::SUCCESS, $tester->execute([]));
|
||||
self::assertStringContainsString('7 indexes verified', $tester->getDisplay());
|
||||
}
|
||||
|
||||
#[TestDox('Maintenance reports cleanup counts for schedulers')]
|
||||
public function testMaintenance(): void
|
||||
{
|
||||
$firewall = $this->createMock(FirewallService::class);
|
||||
$firewall->expects(self::once())->method('cleanup')->willReturn([
|
||||
'expiredRules' => 2,
|
||||
'oldLogs' => 3,
|
||||
'expiredBruteForceClaims' => 4,
|
||||
]);
|
||||
$tester = new CommandTester(new FirewallMaintenanceCommand($firewall));
|
||||
|
||||
self::assertSame(Command::SUCCESS, $tester->execute([]));
|
||||
self::assertStringContainsString('2 expired rules, 3 old logs', $tester->getDisplay());
|
||||
self::assertStringContainsString('4 expired', $tester->getDisplay());
|
||||
self::assertStringContainsString('claims removed', $tester->getDisplay());
|
||||
}
|
||||
|
||||
#[TestDox('Maintenance returns failure to its scheduler')]
|
||||
public function testMaintenanceFailure(): void
|
||||
{
|
||||
$firewall = $this->createStub(FirewallService::class);
|
||||
$firewall->method('cleanup')->willThrowException(new \RuntimeException('database unavailable'));
|
||||
$tester = new CommandTester(new FirewallMaintenanceCommand($firewall));
|
||||
|
||||
self::assertSame(Command::FAILURE, $tester->execute([]));
|
||||
self::assertStringContainsString('database unavailable', $tester->getDisplay());
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace KTXT\Unit\Module;
|
||||
|
||||
use KTXC\Console\Firewall\FirewallMaintenanceCommand;
|
||||
use KTXC\Console\Firewall\FirewallSetupCommand;
|
||||
use KTXC\Console\Event\EventsDebugCommand;
|
||||
use KTXC\Module\Module;
|
||||
use KTXC\Service\FirewallService;
|
||||
@@ -28,7 +30,7 @@ final class CoreModuleTest extends TestCase
|
||||
$module->boot();
|
||||
$definitions = $registry->definitions();
|
||||
|
||||
self::assertCount(9, $definitions);
|
||||
self::assertCount(10, $definitions);
|
||||
self::assertSame(['core'], array_values(array_unique(array_column($definitions, 'module'))));
|
||||
self::assertSame(
|
||||
FirewallService::class,
|
||||
@@ -39,6 +41,7 @@ final class CoreModuleTest extends TestCase
|
||||
SecurityEvent::RATE_LIMIT_EXCEEDED,
|
||||
SecurityEvent::SUSPICIOUS_ACTIVITY,
|
||||
SecurityEvent::FIREWALL_RULE_CREATED,
|
||||
SecurityEvent::FIREWALL_RULE_EXTENDED,
|
||||
SecurityEvent::FIREWALL_RULE_DISABLED,
|
||||
SecurityEvent::FIREWALL_RULE_REMOVED,
|
||||
] as $event) {
|
||||
@@ -57,6 +60,8 @@ final class CoreModuleTest extends TestCase
|
||||
$module = new Module(new EventListenerRegistry());
|
||||
|
||||
self::assertContains(EventsDebugCommand::class, $module->registerCI());
|
||||
self::assertContains(FirewallSetupCommand::class, $module->registerCI());
|
||||
self::assertContains(FirewallMaintenanceCommand::class, $module->registerCI());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
|
||||
@@ -167,4 +167,80 @@ class FirewallRuleManagerTest extends TestCase
|
||||
self::assertSame('operator', $events[SecurityEvent::FIREWALL_RULE_DISABLED]->getIdentityId());
|
||||
self::assertSame('operator', $events[SecurityEvent::FIREWALL_RULE_REMOVED]->getIdentityId());
|
||||
}
|
||||
|
||||
#[TestDox('Continued attacks extend automatic blocks and retain their audit history')]
|
||||
public function testAutomaticBlockExtension(): void
|
||||
{
|
||||
$originalExpiry = new \DateTimeImmutable('+5 minutes');
|
||||
$rule = (new FirewallRuleObject())
|
||||
->setId('rule-123')
|
||||
->setScope(FirewallRuleObject::SCOPE_TENANT)
|
||||
->setTenantId('tenant-a')
|
||||
->setType(FirewallRuleObject::TYPE_IP)
|
||||
->setAction(FirewallRuleObject::ACTION_BLOCK)
|
||||
->setValue('203.0.113.10')
|
||||
->setExpiresAt($originalExpiry)
|
||||
->setMetadata([
|
||||
'origin' => FirewallRuleManager::ORIGIN_AUTOMATIC,
|
||||
'originalExpiresAt' => $originalExpiry->format(\DateTimeInterface::ATOM),
|
||||
'extensions' => [],
|
||||
]);
|
||||
$this->store->method('findExactIpRule')->willReturn($rule);
|
||||
$this->store->expects(self::once())
|
||||
->method('depositRule')
|
||||
->with(self::callback(static function (FirewallRuleObject $extended) use ($originalExpiry): bool {
|
||||
$metadata = $extended->getMetadata();
|
||||
return $extended->getExpiresAt() > $originalExpiry
|
||||
&& $metadata['failureThreshold'] === 5
|
||||
&& $metadata['failureWindowSeconds'] === 300
|
||||
&& $metadata['lastFailureCount'] === 8
|
||||
&& $metadata['originalExpiresAt'] === $originalExpiry->format(\DateTimeInterface::ATOM)
|
||||
&& count($metadata['extensions']) === 1;
|
||||
}))
|
||||
->willReturnArgument(0);
|
||||
$this->events->expects(self::once())
|
||||
->method('dispatch')
|
||||
->with(self::callback(static fn(\KTXF\Event\Event $event): bool =>
|
||||
$event->getName() === SecurityEvent::FIREWALL_RULE_EXTENDED
|
||||
&& $event->get('lastFailureCount') === 8
|
||||
));
|
||||
|
||||
$extended = $this->manager->blockIp(
|
||||
FirewallRuleScope::tenant('tenant-a'),
|
||||
'203.0.113.10',
|
||||
'Continued attack',
|
||||
null,
|
||||
3600,
|
||||
FirewallRuleManager::ORIGIN_AUTOMATIC,
|
||||
[
|
||||
'failureThreshold' => 5,
|
||||
'failureWindowSeconds' => 300,
|
||||
'lastFailureCount' => 8,
|
||||
'blockDurationSeconds' => 3600,
|
||||
]
|
||||
);
|
||||
|
||||
self::assertSame('rule-123', $extended->getId());
|
||||
}
|
||||
|
||||
#[TestDox('Automatic detection never extends a manual block')]
|
||||
public function testManualBlockIsNotExtended(): void
|
||||
{
|
||||
$rule = (new FirewallRuleObject())
|
||||
->setScope(FirewallRuleObject::SCOPE_TENANT)
|
||||
->setTenantId('tenant-a')
|
||||
->setMetadata(['origin' => FirewallRuleManager::ORIGIN_MANUAL]);
|
||||
$this->store->method('findExactIpRule')->willReturn($rule);
|
||||
$this->store->expects(self::never())->method('depositRule');
|
||||
$this->events->expects(self::never())->method('dispatch');
|
||||
|
||||
self::assertSame($rule, $this->manager->blockIp(
|
||||
FirewallRuleScope::tenant('tenant-a'),
|
||||
'203.0.113.10',
|
||||
null,
|
||||
null,
|
||||
3600,
|
||||
FirewallRuleManager::ORIGIN_AUTOMATIC
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,7 +394,7 @@ class FirewallServiceTest extends TestCase
|
||||
->willReturn(5);
|
||||
$this->store->expects($this->once())
|
||||
->method('claimBruteForce')
|
||||
->with('tenant-event', '203.0.113.10', 3600)
|
||||
->with('tenant-event', '203.0.113.10', 300)
|
||||
->willReturn(true);
|
||||
$this->store->expects($this->once())
|
||||
->method('findExactIpRule')
|
||||
@@ -407,9 +407,14 @@ class FirewallServiceTest extends TestCase
|
||||
$this->store->expects($this->once())
|
||||
->method('depositRule')
|
||||
->with(self::callback(static function (FirewallRuleObject $rule): bool {
|
||||
$metadata = $rule->getMetadata();
|
||||
return $rule->getScope() === FirewallRuleObject::SCOPE_TENANT
|
||||
&& $rule->getTenantId() === 'tenant-event'
|
||||
&& $rule->getExpiresAt() !== null;
|
||||
&& $rule->getExpiresAt() !== null
|
||||
&& $metadata['failureThreshold'] === 5
|
||||
&& $metadata['failureWindowSeconds'] === 300
|
||||
&& $metadata['lastFailureCount'] === 5
|
||||
&& $metadata['blockDurationSeconds'] === 3600;
|
||||
}))
|
||||
->willReturnArgument(0);
|
||||
|
||||
@@ -445,7 +450,7 @@ class FirewallServiceTest extends TestCase
|
||||
->willReturn(5);
|
||||
$this->store->expects($this->once())
|
||||
->method('claimBruteForce')
|
||||
->with('tenant-a', '203.0.113.10', 3600)
|
||||
->with('tenant-a', '203.0.113.10', 300)
|
||||
->willReturn(false);
|
||||
$this->store->expects($this->never())->method('depositRule');
|
||||
$this->events->expects($this->never())->method('dispatch');
|
||||
@@ -500,6 +505,50 @@ class FirewallServiceTest extends TestCase
|
||||
self::assertSame($eventId, $event->getEventId());
|
||||
}
|
||||
|
||||
#[TestDox('Cleanup records successful maintenance counts')]
|
||||
public function testCleanupStatus(): void
|
||||
{
|
||||
$this->store->method('cleanupExpiredRules')->willReturn(2);
|
||||
$this->store->method('cleanupOldLogs')->with(30)->willReturn(3);
|
||||
$this->store->method('cleanupExpiredBruteForceClaims')->willReturn(4);
|
||||
$this->store->expects(self::once())
|
||||
->method('recordMaintenanceStatus')
|
||||
->with(
|
||||
self::isInstanceOf(\DateTimeImmutable::class),
|
||||
self::isInstanceOf(\DateTimeImmutable::class),
|
||||
'success',
|
||||
[
|
||||
'expiredRules' => 2,
|
||||
'oldLogs' => 3,
|
||||
'expiredBruteForceClaims' => 4,
|
||||
]
|
||||
);
|
||||
|
||||
self::assertSame([
|
||||
'expiredRules' => 2,
|
||||
'oldLogs' => 3,
|
||||
'expiredBruteForceClaims' => 4,
|
||||
], $this->service->cleanup());
|
||||
}
|
||||
|
||||
#[TestDox('Cleanup failures are recorded and rethrown')]
|
||||
public function testCleanupFailureStatus(): void
|
||||
{
|
||||
$this->store->method('cleanupExpiredRules')->willThrowException(new \RuntimeException('cleanup failed'));
|
||||
$this->store->expects(self::once())
|
||||
->method('recordMaintenanceStatus')
|
||||
->with(
|
||||
self::isInstanceOf(\DateTimeImmutable::class),
|
||||
self::isInstanceOf(\DateTimeImmutable::class),
|
||||
'failed',
|
||||
[],
|
||||
'cleanup failed'
|
||||
);
|
||||
$this->expectExceptionMessage('cleanup failed');
|
||||
|
||||
$this->service->cleanup();
|
||||
}
|
||||
|
||||
private function rule(
|
||||
string $id,
|
||||
string $scope,
|
||||
|
||||
Reference in New Issue
Block a user