diff --git a/core/lib/Module/Module.php b/core/lib/Module/Module.php index 9fa59b8..813c8a7 100644 --- a/core/lib/Module/Module.php +++ b/core/lib/Module/Module.php @@ -39,6 +39,8 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M SecurityEvent::AUTH_SUCCESS, SecurityEvent::ACCESS_DENIED, SecurityEvent::BRUTE_FORCE_DETECTED, + SecurityEvent::RATE_LIMIT_EXCEEDED, + SecurityEvent::SUSPICIOUS_ACTIVITY, ] as $event) { $this->events->listen( 'core', diff --git a/tests/php/Unit/Module/CoreModuleTest.php b/tests/php/Unit/Module/CoreModuleTest.php index 2c95a74..00dd4a2 100644 --- a/tests/php/Unit/Module/CoreModuleTest.php +++ b/tests/php/Unit/Module/CoreModuleTest.php @@ -28,12 +28,18 @@ final class CoreModuleTest extends TestCase $module->boot(); $definitions = $registry->definitions(); - self::assertCount(5, $definitions); + self::assertCount(7, $definitions); self::assertSame(['core'], array_values(array_unique(array_column($definitions, 'module')))); self::assertSame( FirewallService::class, $registry->listeners(SecurityEvent::AUTH_FAILURE, DeliveryMode::Immediate)[0]->service, ); + foreach ([SecurityEvent::RATE_LIMIT_EXCEEDED, SecurityEvent::SUSPICIOUS_ACTIVITY] as $event) { + $listeners = $registry->listeners($event, DeliveryMode::Deferred); + self::assertCount(1, $listeners); + self::assertSame(FirewallService::class, $listeners[0]->service); + self::assertSame('logSecurityEvent', $listeners[0]->method); + } self::assertFalse($registry->frozen()); } diff --git a/tests/php/Unit/Service/FirewallServiceTest.php b/tests/php/Unit/Service/FirewallServiceTest.php index 486229e..fa24121 100644 --- a/tests/php/Unit/Service/FirewallServiceTest.php +++ b/tests/php/Unit/Service/FirewallServiceTest.php @@ -256,6 +256,59 @@ class FirewallServiceTest extends TestCase ); } + #[TestDox('Rate-limit events retain their request and threshold audit data')] + public function testRateLimitAudit(): void + { + $this->store->expects($this->once()) + ->method('createLog') + ->with(self::callback(static function (FirewallLogObject $log): bool { + $metadata = $log->getMetadata(); + return $log->getEventType() === FirewallLogObject::EVENT_RATE_LIMIT + && $log->getResult() === FirewallLogObject::RESULT_BLOCKED + && $log->getIpAddress() === '203.0.113.10' + && $log->getRequestPath() === '/login' + && $metadata['requestCount'] === 101 + && $metadata['windowSeconds'] === 60; + })) + ->willReturnArgument(0); + $event = \KTXF\Event\SecurityEvent::rateLimitExceeded( + '203.0.113.10', + 101, + 60, + '/login' + ); + $event->setTenantId('tenant-a'); + + $this->service->logSecurityEvent($event); + } + + #[TestDox('Suspicious-activity events retain request and detection metadata')] + public function testSuspiciousActivityAudit(): void + { + $this->store->expects($this->once()) + ->method('createLog') + ->with(self::callback(static function (FirewallLogObject $log): bool { + return $log->getEventType() === FirewallLogObject::EVENT_SUSPICIOUS + && $log->getResult() === FirewallLogObject::RESULT_BLOCKED + && $log->getIpAddress() === '203.0.113.20' + && $log->getRequestPath() === '/admin' + && $log->getRequestMethod() === 'POST' + && $log->getMetadata()['detector'] === 'payload-signature'; + })) + ->willReturnArgument(0); + $event = \KTXF\Event\SecurityEvent::create( + \KTXF\Event\SecurityEvent::SUSPICIOUS_ACTIVITY, + '203.0.113.20', + null, + ['detector' => 'payload-signature'] + ); + $event->setTenantId('tenant-a') + ->setRequestPath('/admin') + ->setRequestMethod('POST'); + + $this->service->logSecurityEvent($event); + } + #[TestDox('Typed tenant firewall settings drive brute-force thresholds')] public function testFirewallConfiguration(): void {