From e223ae7543e6b81a4a64cb1b27b6f2d482fe22f2 Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Wed, 5 Aug 2026 23:31:13 -0400 Subject: [PATCH] refactor(security): add typed brute-force detection event Signed-off-by: Sebastian Krupinski --- core/lib/Module/Module.php | 3 +- .../Event/BruteForceDetectedEvent.php | 91 +++++++++++++++++++ core/lib/Security/Event/SecurityEvent.php | 20 ---- core/lib/Service/FirewallService.php | 5 +- .../Event/BruteForceDetectedEventTest.php | 56 ++++++++++++ tests/php/Unit/Module/CoreModuleTest.php | 2 + .../php/Unit/Service/FirewallServiceTest.php | 12 ++- 7 files changed, 165 insertions(+), 24 deletions(-) create mode 100644 core/lib/Security/Event/BruteForceDetectedEvent.php create mode 100644 tests/php/Unit/Event/BruteForceDetectedEventTest.php diff --git a/core/lib/Module/Module.php b/core/lib/Module/Module.php index 19c8dd5..5219e89 100644 --- a/core/lib/Module/Module.php +++ b/core/lib/Module/Module.php @@ -12,6 +12,7 @@ use KTXC\Service\TenantFirewallLogService; use KTXC\Service\TenantFirewallRuleService; use KTXC\Service\TenantFirewallStatusService; use KTXC\Security\Event\AuthenticationFailedEvent; +use KTXC\Security\Event\BruteForceDetectedEvent; use KTXC\Security\Event\SecurityEvent; use KTXF\Event\DeliveryMode; use KTXF\Event\EventListenerRegistrarInterface; @@ -45,7 +46,7 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M foreach ([ SecurityEvent::AUTH_SUCCESS, SecurityEvent::ACCESS_DENIED, - SecurityEvent::BRUTE_FORCE_DETECTED, + BruteForceDetectedEvent::class, SecurityEvent::RATE_LIMIT_EXCEEDED, SecurityEvent::SUSPICIOUS_ACTIVITY, SecurityEvent::FIREWALL_RULE_CREATED, diff --git a/core/lib/Security/Event/BruteForceDetectedEvent.php b/core/lib/Security/Event/BruteForceDetectedEvent.php new file mode 100644 index 0000000..9c9fddb --- /dev/null +++ b/core/lib/Security/Event/BruteForceDetectedEvent.php @@ -0,0 +1,91 @@ +reason = sprintf( + '%d failed attempts in %d seconds', + $failureCount, + $windowSeconds, + ); + + parent::__construct( + self::class, + ['failureCount' => $failureCount, 'windowSeconds' => $windowSeconds], + $tenantId, + ); + } + + public function getIpAddress(): string + { + return $this->ipAddress; + } + + public function getFailureCount(): int + { + return $this->failureCount; + } + + public function getWindowSeconds(): int + { + return $this->windowSeconds; + } + + public function getDeviceFingerprint(): ?string + { + return null; + } + + public function getUserAgent(): ?string + { + return null; + } + + public function getRequestPath(): ?string + { + return null; + } + + public function getRequestMethod(): ?string + { + return null; + } + + public function getUserId(): ?string + { + return null; + } + + public function getReason(): string + { + return $this->reason; + } + + public function getSeverity(): int + { + return SecurityEvent::SEVERITY_CRITICAL; + } +} diff --git a/core/lib/Security/Event/SecurityEvent.php b/core/lib/Security/Event/SecurityEvent.php index 83da6b5..d2de9ae 100644 --- a/core/lib/Security/Event/SecurityEvent.php +++ b/core/lib/Security/Event/SecurityEvent.php @@ -20,7 +20,6 @@ final class SecurityEvent extends Event implements SecurityRequestEventInterface public const ACCESS_DENIED = 'security.access.denied'; public const ACCESS_GRANTED = 'security.access.granted'; - public const BRUTE_FORCE_DETECTED = 'security.brute_force.detected'; public const RATE_LIMIT_EXCEEDED = 'security.rate_limit.exceeded'; public const SUSPICIOUS_ACTIVITY = 'security.suspicious.activity'; @@ -114,24 +113,6 @@ final class SecurityEvent extends Event implements SecurityRequestEventInterface ); } - /** - * Create a brute force detection event - */ - public static function bruteForceDetected( - string $ipAddress, - int $failureCount, - int $windowSeconds, - ?string $tenantId = null, - ): self { - return self::create( - self::BRUTE_FORCE_DETECTED, - $ipAddress, - data: ['failureCount' => $failureCount, 'windowSeconds' => $windowSeconds], - tenantId: $tenantId, - reason: sprintf('%d failed attempts in %d seconds', $failureCount, $windowSeconds), - ); - } - /** * Create a rate limit exceeded event */ @@ -196,7 +177,6 @@ final class SecurityEvent extends Event implements SecurityRequestEventInterface self::RATE_LIMIT_EXCEEDED, self::SUSPICIOUS_ACTIVITY => self::SEVERITY_ERROR, - self::BRUTE_FORCE_DETECTED, self::IP_BLOCKED, self::DEVICE_BLOCKED => self::SEVERITY_CRITICAL, diff --git a/core/lib/Service/FirewallService.php b/core/lib/Service/FirewallService.php index caf1ce6..773e730 100644 --- a/core/lib/Service/FirewallService.php +++ b/core/lib/Service/FirewallService.php @@ -11,6 +11,7 @@ use KTXC\Models\Firewall\FirewallLogObject; use KTXC\Stores\FirewallStore; use KTXC\Context\TenantContextInterface; use KTXC\Security\Event\AuthenticationFailedEvent; +use KTXC\Security\Event\BruteForceDetectedEvent; use KTXC\Security\Event\SecurityEvent; use KTXC\Security\Event\SecurityEventInterface; use KTXC\Security\Event\SecurityRequestEventInterface; @@ -200,7 +201,7 @@ class FirewallService int $blockDuration ): void { // Publish brute force event - $event = SecurityEvent::bruteForceDetected( + $event = new BruteForceDetectedEvent( $ipAddress, $failureCount, $windowSeconds, @@ -280,7 +281,7 @@ class FirewallService return match ($eventName) { AuthenticationFailedEvent::class => FirewallLogObject::EVENT_AUTH_FAILURE, SecurityEvent::AUTH_SUCCESS => FirewallLogObject::EVENT_ACCESS_CHECK, - SecurityEvent::BRUTE_FORCE_DETECTED => FirewallLogObject::EVENT_BRUTE_FORCE, + BruteForceDetectedEvent::class => FirewallLogObject::EVENT_BRUTE_FORCE, SecurityEvent::RATE_LIMIT_EXCEEDED => FirewallLogObject::EVENT_RATE_LIMIT, SecurityEvent::ACCESS_DENIED => FirewallLogObject::EVENT_RULE_MATCH, SecurityEvent::SUSPICIOUS_ACTIVITY => FirewallLogObject::EVENT_SUSPICIOUS, diff --git a/tests/php/Unit/Event/BruteForceDetectedEventTest.php b/tests/php/Unit/Event/BruteForceDetectedEventTest.php new file mode 100644 index 0000000..44eb024 --- /dev/null +++ b/tests/php/Unit/Event/BruteForceDetectedEventTest.php @@ -0,0 +1,56 @@ +getName()); + self::assertSame('203.0.113.10', $event->getIpAddress()); + self::assertSame(5, $event->getFailureCount()); + self::assertSame(300, $event->getWindowSeconds()); + self::assertSame('tenant-a', $event->getTenantId()); + self::assertSame('5 failed attempts in 300 seconds', $event->getReason()); + self::assertSame( + ['failureCount' => 5, 'windowSeconds' => 300], + $event->getData(), + ); + self::assertSame(SecurityEvent::SEVERITY_CRITICAL, $event->getSeverity()); + } + + #[Test] + #[TestDox('Brute-force detection rejects invalid measurements')] + public function rejectsInvalidMeasurements(): void + { + foreach ([ + ['', 5, 300], + ['203.0.113.10', 0, 300], + ['203.0.113.10', 5, 0], + ] as $arguments) { + try { + new BruteForceDetectedEvent(...$arguments); + self::fail('Invalid brute-force measurements were accepted.'); + } catch (\InvalidArgumentException) { + $this->addToAssertionCount(1); + } + } + } +} diff --git a/tests/php/Unit/Module/CoreModuleTest.php b/tests/php/Unit/Module/CoreModuleTest.php index 737a7e6..09fff81 100644 --- a/tests/php/Unit/Module/CoreModuleTest.php +++ b/tests/php/Unit/Module/CoreModuleTest.php @@ -17,6 +17,7 @@ use KTXC\Service\SystemFirewallStatusService; use KTXC\Service\TenantFirewallRuleService; use KTXC\Event\EventListenerRegistry; use KTXC\Security\Event\AuthenticationFailedEvent; +use KTXC\Security\Event\BruteForceDetectedEvent; use KTXC\Security\Event\SecurityEvent; use KTXF\Event\DeliveryMode; use PHPUnit\Framework\Attributes\Test; @@ -43,6 +44,7 @@ final class CoreModuleTest extends TestCase ); self::assertSame([], $registry->listeners(AuthenticationFailedEvent::class, DeliveryMode::Deferred)); foreach ([ + BruteForceDetectedEvent::class, SecurityEvent::RATE_LIMIT_EXCEEDED, SecurityEvent::SUSPICIOUS_ACTIVITY, SecurityEvent::FIREWALL_RULE_CREATED, diff --git a/tests/php/Unit/Service/FirewallServiceTest.php b/tests/php/Unit/Service/FirewallServiceTest.php index f876ad2..5d0c99b 100644 --- a/tests/php/Unit/Service/FirewallServiceTest.php +++ b/tests/php/Unit/Service/FirewallServiceTest.php @@ -11,6 +11,7 @@ use KTXC\Models\Firewall\FirewallRuleObject; use KTXC\Models\Firewall\FirewallLogObject; use KTXC\Models\Tenant\TenantConfiguration; use KTXC\Security\Event\AuthenticationFailedEvent; +use KTXC\Security\Event\BruteForceDetectedEvent; use KTXC\Service\FirewallService; use KTXC\Service\FirewallRuleCache; use KTXC\Service\FirewallRuleManager; @@ -496,13 +497,18 @@ class FirewallServiceTest extends TestCase $publishedTenants = []; $lifecycleOrigin = null; + $bruteForceEvent = null; $this->events->expects($this->exactly(3)) ->method('dispatch') ->willReturnCallback(static function (\KTXF\Event\Event $event) use ( &$publishedTenants, - &$lifecycleOrigin + &$lifecycleOrigin, + &$bruteForceEvent, ): void { $publishedTenants[] = $event->getTenantId(); + if ($event instanceof BruteForceDetectedEvent) { + $bruteForceEvent = $event; + } if ($event->getName() === \KTXC\Security\Event\SecurityEvent::FIREWALL_RULE_CREATED) { $lifecycleOrigin = $event->get('origin'); } @@ -512,6 +518,10 @@ class FirewallServiceTest extends TestCase $this->service->handleAuthFailure($event); self::assertSame(['tenant-event', 'tenant-event', 'tenant-event'], $publishedTenants); + self::assertInstanceOf(BruteForceDetectedEvent::class, $bruteForceEvent); + self::assertSame('203.0.113.10', $bruteForceEvent->getIpAddress()); + self::assertSame(5, $bruteForceEvent->getFailureCount()); + self::assertSame(300, $bruteForceEvent->getWindowSeconds()); self::assertSame(FirewallRuleManager::ORIGIN_AUTOMATIC, $lifecycleOrigin); }