From 4ee91a79188f11430850bc4f3e478d379aef8e7a Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Thu, 30 Jul 2026 23:39:37 -0400 Subject: [PATCH] feat(firewall): complete operational reliability phase Signed-off-by: Sebastian Krupinski --- .../Firewall/FirewallMaintenanceCommand.php | 40 +++++ .../Console/Firewall/FirewallSetupCommand.php | 35 ++++ core/lib/Db/ObjectId.php | 2 +- .../lib/Models/Firewall/FirewallLogObject.php | 1 + core/lib/Module/Module.php | 5 + core/lib/Service/FirewallRuleManager.php | 62 ++++++- core/lib/Service/FirewallService.php | 49 ++++-- core/lib/Stores/FirewallStore.php | 40 ++++- deploy/cron/firewall-maintenance.cron | 2 + shared/lib/Event/SecurityEvent.php | 1 + .../Integration/Stores/FirewallStoreTest.php | 165 ++++++++++++++++++ .../Console/Firewall/FirewallCommandsTest.php | 56 ++++++ tests/php/Unit/Module/CoreModuleTest.php | 7 +- .../Unit/Service/FirewallRuleManagerTest.php | 76 ++++++++ .../php/Unit/Service/FirewallServiceTest.php | 55 +++++- 15 files changed, 574 insertions(+), 22 deletions(-) create mode 100644 core/lib/Console/Firewall/FirewallMaintenanceCommand.php create mode 100644 core/lib/Console/Firewall/FirewallSetupCommand.php create mode 100644 deploy/cron/firewall-maintenance.cron create mode 100644 tests/php/Unit/Console/Firewall/FirewallCommandsTest.php diff --git a/core/lib/Console/Firewall/FirewallMaintenanceCommand.php b/core/lib/Console/Firewall/FirewallMaintenanceCommand.php new file mode 100644 index 0000000..18487db --- /dev/null +++ b/core/lib/Console/Firewall/FirewallMaintenanceCommand.php @@ -0,0 +1,40 @@ +firewall->cleanup(); + } catch (\Throwable $error) { + $io->error('Firewall maintenance failed: '.$error->getMessage()); + return Command::FAILURE; + } + + $io->success(sprintf( + 'Firewall maintenance complete: %d expired rules, %d old logs, and %d expired claims removed.', + $result['expiredRules'], + $result['oldLogs'], + $result['expiredBruteForceClaims'] + )); + return Command::SUCCESS; + } +} diff --git a/core/lib/Console/Firewall/FirewallSetupCommand.php b/core/lib/Console/Firewall/FirewallSetupCommand.php new file mode 100644 index 0000000..9b3e963 --- /dev/null +++ b/core/lib/Console/Firewall/FirewallSetupCommand.php @@ -0,0 +1,35 @@ +store->ensureIndexes(); + } catch (\Throwable $error) { + $io->error('Firewall database setup failed: '.$error->getMessage()); + return Command::FAILURE; + } + + $io->success(sprintf('Firewall database setup complete. %d indexes verified.', count($indexes))); + return Command::SUCCESS; + } +} diff --git a/core/lib/Db/ObjectId.php b/core/lib/Db/ObjectId.php index 4b53e49..10dae15 100644 --- a/core/lib/Db/ObjectId.php +++ b/core/lib/Db/ObjectId.php @@ -66,6 +66,6 @@ class ObjectId */ public static function isValid(string $id): bool { - return MongoObjectId::isValid($id); + return preg_match('/^[a-f0-9]{24}$/iD', $id) === 1; } } diff --git a/core/lib/Models/Firewall/FirewallLogObject.php b/core/lib/Models/Firewall/FirewallLogObject.php index 3053a62..d2c0fd4 100644 --- a/core/lib/Models/Firewall/FirewallLogObject.php +++ b/core/lib/Models/Firewall/FirewallLogObject.php @@ -22,6 +22,7 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable public const EVENT_RULE_MATCH = 'rule_match'; public const EVENT_ACCESS_CHECK = 'access_check'; public const EVENT_RULE_CREATED = 'rule_created'; + public const EVENT_RULE_EXTENDED = 'rule_extended'; public const EVENT_RULE_DISABLED = 'rule_disabled'; public const EVENT_RULE_REMOVED = 'rule_removed'; diff --git a/core/lib/Module/Module.php b/core/lib/Module/Module.php index c2d599a..03a6a57 100644 --- a/core/lib/Module/Module.php +++ b/core/lib/Module/Module.php @@ -2,6 +2,8 @@ namespace KTXC\Module; +use KTXC\Console\Firewall\FirewallMaintenanceCommand; +use KTXC\Console\Firewall\FirewallSetupCommand; use KTXC\Service\FirewallService; use KTXC\Service\SystemFirewallRuleService; use KTXC\Service\TenantFirewallRuleService; @@ -41,6 +43,7 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M 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) { @@ -158,6 +161,8 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M public function registerCI(): array { return [ + FirewallSetupCommand::class, + FirewallMaintenanceCommand::class, \KTXC\Console\Event\EventsDebugCommand::class, \KTXC\Console\Module\ModuleListCommand::class, \KTXC\Console\Module\ModuleEnableCommand::class, diff --git a/core/lib/Service/FirewallRuleManager.php b/core/lib/Service/FirewallRuleManager.php index 96a40a0..edde77f 100644 --- a/core/lib/Service/FirewallRuleManager.php +++ b/core/lib/Service/FirewallRuleManager.php @@ -34,7 +34,8 @@ final class FirewallRuleManager ?string $reason, ?string $createdBy, ?int $durationSeconds = null, - string $origin = self::ORIGIN_MANUAL + string $origin = self::ORIGIN_MANUAL, + array $metadata = [] ): FirewallRuleObject { $ipAddress = FirewallRuleValidator::ipAddress($ipAddress); FirewallRuleValidator::duration($durationSeconds); @@ -46,6 +47,14 @@ final class FirewallRuleManager $scope->scope ); if ($existing) { + if ( + $origin === self::ORIGIN_AUTOMATIC + && ($existing->getMetadata()['origin'] ?? null) === self::ORIGIN_AUTOMATIC + && $durationSeconds !== null + ) { + return $this->extendAutomaticBlock($existing, $durationSeconds, $metadata); + } + return $existing; } @@ -57,7 +66,8 @@ final class FirewallRuleManager $reason ?? 'Blocked by administrator', $createdBy, $durationSeconds, - $origin + $origin, + $metadata ); $this->publishIpEvent(SecurityEvent::IP_BLOCKED, $scope, $ipAddress, $reason); @@ -171,7 +181,8 @@ final class FirewallRuleManager string $reason, ?string $createdBy, ?int $durationSeconds = null, - string $origin = self::ORIGIN_MANUAL + string $origin = self::ORIGIN_MANUAL, + array $metadata = [] ): FirewallRuleObject { if (!in_array($origin, [self::ORIGIN_MANUAL, self::ORIGIN_AUTOMATIC], true)) { throw new \InvalidArgumentException("Invalid firewall rule origin: {$origin}"); @@ -186,12 +197,17 @@ final class FirewallRuleManager ->setReason($reason) ->setCreatedBy($createdBy) ->setCreatedAt(new \DateTimeImmutable()) - ->setMetadata(['origin' => $origin]) ->setEnabled(true); if ($durationSeconds !== null) { $rule->setExpiresAt((new \DateTimeImmutable())->modify("+{$durationSeconds} seconds")); } + $metadata = [...$metadata, 'origin' => $origin]; + if ($origin === self::ORIGIN_AUTOMATIC && $rule->getExpiresAt() !== null) { + $metadata['originalExpiresAt'] = $rule->getExpiresAt()->format(\DateTimeInterface::ATOM); + $metadata['extensions'] = []; + } + $rule->setMetadata($metadata); $this->store->depositRule($rule); $this->cache->invalidate(); @@ -200,6 +216,43 @@ final class FirewallRuleManager return $rule; } + private function extendAutomaticBlock( + FirewallRuleObject $rule, + int $durationSeconds, + array $policy + ): FirewallRuleObject { + $now = new \DateTimeImmutable(); + $previousExpiry = $rule->getExpiresAt(); + $newExpiry = $now->modify("+{$durationSeconds} seconds"); + if ($previousExpiry !== null && $newExpiry <= $previousExpiry) { + return $rule; + } + + $metadata = $rule->getMetadata() ?? []; + $extensions = is_array($metadata['extensions'] ?? null) ? $metadata['extensions'] : []; + $extensions[] = [ + 'extendedAt' => $now->format(\DateTimeInterface::ATOM), + 'previousExpiresAt' => $previousExpiry?->format(\DateTimeInterface::ATOM), + 'expiresAt' => $newExpiry->format(\DateTimeInterface::ATOM), + 'failureCount' => $policy['lastFailureCount'] ?? null, + ]; + $rule->setExpiresAt($newExpiry)->setMetadata([ + ...$metadata, + ...$policy, + 'origin' => self::ORIGIN_AUTOMATIC, + 'originalExpiresAt' => $metadata['originalExpiresAt'] + ?? $previousExpiry?->format(\DateTimeInterface::ATOM), + 'extensions' => $extensions, + 'lastExtendedAt' => $now->format(\DateTimeInterface::ATOM), + ]); + + $this->store->depositRule($rule); + $this->cache->invalidate(); + $this->publishLifecycleEvent(SecurityEvent::FIREWALL_RULE_EXTENDED, $rule); + + return $rule; + } + private function ownedRule(FirewallRuleScope $scope, string $ruleId): ?FirewallRuleObject { $rule = $this->store->fetchRule($ruleId); @@ -233,6 +286,7 @@ final class FirewallRuleManager 'reason' => $rule->getReason(), 'origin' => $rule->getMetadata()['origin'] ?? self::ORIGIN_MANUAL, 'expiresAt' => $rule->getExpiresAt()?->format(\DateTimeInterface::ATOM), + ...($rule->getMetadata() ?? []), ]); $event->setTenantId($rule->getTenantId()) ->setIdentityId($actorId ?? $rule->getCreatedBy()); diff --git a/core/lib/Service/FirewallService.php b/core/lib/Service/FirewallService.php index a2e7146..224cd1f 100644 --- a/core/lib/Service/FirewallService.php +++ b/core/lib/Service/FirewallService.php @@ -169,7 +169,8 @@ class FirewallService self::DEFAULT_AUTO_BLOCK_DURATION, self::MAX_AUTO_BLOCK_DURATION ); - if (!$this->store->claimBruteForce($tenantId, $ipAddress, $blockDuration)) { + $responseCooldown = min($windowSeconds, max(1, intdiv($blockDuration, 2))); + if (!$this->store->claimBruteForce($tenantId, $ipAddress, $responseCooldown)) { return; } @@ -204,7 +205,17 @@ class FirewallService sprintf('Auto-blocked: %d failed auth attempts in %d seconds', $failureCount, $windowSeconds), null, // System-created $blockDuration, - FirewallRuleManager::ORIGIN_AUTOMATIC + FirewallRuleManager::ORIGIN_AUTOMATIC, + [ + 'failureThreshold' => $this->getBoundedIntegerConfig( + self::CONFIG_MAX_FAILURES, + self::DEFAULT_MAX_AUTH_FAILURES, + self::MAX_AUTH_FAILURES + ), + 'failureWindowSeconds' => $windowSeconds, + 'lastFailureCount' => $failureCount, + 'blockDurationSeconds' => $blockDuration, + ] ); } @@ -257,6 +268,7 @@ class FirewallService SecurityEvent::ACCESS_DENIED => FirewallLogObject::EVENT_RULE_MATCH, SecurityEvent::SUSPICIOUS_ACTIVITY => FirewallLogObject::EVENT_SUSPICIOUS, SecurityEvent::FIREWALL_RULE_CREATED => FirewallLogObject::EVENT_RULE_CREATED, + SecurityEvent::FIREWALL_RULE_EXTENDED => FirewallLogObject::EVENT_RULE_EXTENDED, SecurityEvent::FIREWALL_RULE_DISABLED => FirewallLogObject::EVENT_RULE_DISABLED, SecurityEvent::FIREWALL_RULE_REMOVED => FirewallLogObject::EVENT_RULE_REMOVED, default => FirewallLogObject::EVENT_ACCESS_CHECK, @@ -272,6 +284,7 @@ class FirewallService SecurityEvent::AUTH_SUCCESS, SecurityEvent::ACCESS_GRANTED => FirewallLogObject::RESULT_ALLOWED, SecurityEvent::FIREWALL_RULE_CREATED, + SecurityEvent::FIREWALL_RULE_EXTENDED, SecurityEvent::FIREWALL_RULE_DISABLED, SecurityEvent::FIREWALL_RULE_REMOVED => FirewallLogObject::RESULT_RECORDED, default => FirewallLogObject::RESULT_BLOCKED, @@ -341,15 +354,31 @@ class FirewallService */ public function cleanup(): array { - $expiredRules = $this->store->cleanupExpiredRules(); - $oldLogs = $this->store->cleanupOldLogs(30); - $expiredClaims = $this->store->cleanupExpiredBruteForceClaims(); + $startedAt = new \DateTimeImmutable(); - return [ - 'expiredRules' => $expiredRules, - 'oldLogs' => $oldLogs, - 'expiredBruteForceClaims' => $expiredClaims, - ]; + try { + $result = [ + 'expiredRules' => $this->store->cleanupExpiredRules(), + 'oldLogs' => $this->store->cleanupOldLogs(30), + 'expiredBruteForceClaims' => $this->store->cleanupExpiredBruteForceClaims(), + ]; + $this->store->recordMaintenanceStatus($startedAt, new \DateTimeImmutable(), 'success', $result); + + return $result; + } catch (\Throwable $error) { + try { + $this->store->recordMaintenanceStatus( + $startedAt, + new \DateTimeImmutable(), + 'failed', + [], + $error->getMessage() + ); + } catch (\Throwable) { + // Preserve the cleanup failure when the status store is also unavailable. + } + throw $error; + } } } diff --git a/core/lib/Stores/FirewallStore.php b/core/lib/Stores/FirewallStore.php index 6a1ca68..eb13bd9 100644 --- a/core/lib/Stores/FirewallStore.php +++ b/core/lib/Stores/FirewallStore.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace KTXC\Stores; use KTXC\Db\DataStore; +use KTXC\Db\ObjectId; use KTXC\Db\UTCDateTime; use KTXC\Models\Firewall\FirewallRuleObject; use KTXC\Models\Firewall\FirewallLogObject; @@ -17,6 +18,7 @@ class FirewallStore protected const RULES_COLLECTION = 'firewall_rules'; protected const LOGS_COLLECTION = 'firewall_logs'; protected const BRUTE_FORCE_CLAIMS_COLLECTION = 'firewall_brute_force_claims'; + protected const MAINTENANCE_COLLECTION = 'firewall_maintenance'; public function __construct( protected readonly DataStore $dataStore @@ -180,7 +182,7 @@ class FirewallStore */ public function fetchRule(string $id): ?FirewallRuleObject { - $entry = $this->dataStore->selectCollection(self::RULES_COLLECTION)->findOne(['_id' => $id]); + $entry = $this->dataStore->selectCollection(self::RULES_COLLECTION)->findOne(self::ruleIdFilter($id)); if (!$entry) { return null; } @@ -259,7 +261,7 @@ class FirewallStore unset($data['id']); $this->dataStore->selectCollection(self::RULES_COLLECTION)->updateOne( - ['_id' => $id], + self::ruleIdFilter($id), ['$set' => $data] ); return $rule; @@ -274,7 +276,7 @@ class FirewallStore if (!$id) { return; } - $this->dataStore->selectCollection(self::RULES_COLLECTION)->deleteOne(['_id' => $id]); + $this->dataStore->selectCollection(self::RULES_COLLECTION)->deleteOne(self::ruleIdFilter($id)); } /** @@ -476,6 +478,33 @@ class FirewallStore return $result->getDeletedCount(); } + public function recordMaintenanceStatus( + \DateTimeImmutable $startedAt, + \DateTimeImmutable $completedAt, + string $status, + array $result, + ?string $error = null + ): void { + $this->dataStore->selectCollection(self::MAINTENANCE_COLLECTION)->updateOne( + ['_id' => 'cleanup'], + ['$set' => [ + 'startedAt' => self::bsonDate($startedAt), + 'completedAt' => self::bsonDate($completedAt), + 'status' => $status, + 'result' => $result, + 'error' => $error, + ]], + ['upsert' => true] + ); + } + + public function maintenanceStatus(): ?array + { + return $this->dataStore + ->selectCollection(self::MAINTENANCE_COLLECTION) + ->findOne(['_id' => 'cleanup']); + } + private static function ruleDocument(FirewallRuleObject $rule): array { $data = $rule->jsonSerialize(); @@ -502,4 +531,9 @@ class FirewallStore { return UTCDateTime::fromDateTime($date); } + + private static function ruleIdFilter(string $id): array + { + return ['_id' => ObjectId::isValid($id) ? ObjectId::fromString($id) : $id]; + } } diff --git a/deploy/cron/firewall-maintenance.cron b/deploy/cron/firewall-maintenance.cron new file mode 100644 index 0000000..683a62d --- /dev/null +++ b/deploy/cron/firewall-maintenance.cron @@ -0,0 +1,2 @@ +# Install in /etc/cron.d/ktrix-firewall after adjusting the user/path if needed. +*/15 * * * * www-data cd /var/www/ktrix/main && bin/console firewall:maintenance diff --git a/shared/lib/Event/SecurityEvent.php b/shared/lib/Event/SecurityEvent.php index 42c7370..3785748 100644 --- a/shared/lib/Event/SecurityEvent.php +++ b/shared/lib/Event/SecurityEvent.php @@ -27,6 +27,7 @@ class SecurityEvent extends Event public const IP_ALLOWED = 'security.ip.allowed'; public const DEVICE_BLOCKED = 'security.device.blocked'; public const FIREWALL_RULE_CREATED = 'security.firewall.rule.created'; + public const FIREWALL_RULE_EXTENDED = 'security.firewall.rule.extended'; public const FIREWALL_RULE_DISABLED = 'security.firewall.rule.disabled'; public const FIREWALL_RULE_REMOVED = 'security.firewall.rule.removed'; diff --git a/tests/php/Integration/Stores/FirewallStoreTest.php b/tests/php/Integration/Stores/FirewallStoreTest.php index 7f220c3..553d039 100644 --- a/tests/php/Integration/Stores/FirewallStoreTest.php +++ b/tests/php/Integration/Stores/FirewallStoreTest.php @@ -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)); + } } diff --git a/tests/php/Unit/Console/Firewall/FirewallCommandsTest.php b/tests/php/Unit/Console/Firewall/FirewallCommandsTest.php new file mode 100644 index 0000000..e3e7843 --- /dev/null +++ b/tests/php/Unit/Console/Firewall/FirewallCommandsTest.php @@ -0,0 +1,56 @@ +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()); + } +} diff --git a/tests/php/Unit/Module/CoreModuleTest.php b/tests/php/Unit/Module/CoreModuleTest.php index 74daceb..c9444e9 100644 --- a/tests/php/Unit/Module/CoreModuleTest.php +++ b/tests/php/Unit/Module/CoreModuleTest.php @@ -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] diff --git a/tests/php/Unit/Service/FirewallRuleManagerTest.php b/tests/php/Unit/Service/FirewallRuleManagerTest.php index 24e1d64..6cd8347 100644 --- a/tests/php/Unit/Service/FirewallRuleManagerTest.php +++ b/tests/php/Unit/Service/FirewallRuleManagerTest.php @@ -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 + )); + } } diff --git a/tests/php/Unit/Service/FirewallServiceTest.php b/tests/php/Unit/Service/FirewallServiceTest.php index e5c4917..5716bfe 100644 --- a/tests/php/Unit/Service/FirewallServiceTest.php +++ b/tests/php/Unit/Service/FirewallServiceTest.php @@ -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,