From f4df769b3c3d43c620655bf1df6439f4ca82d960 Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Wed, 5 Aug 2026 23:38:59 -0400 Subject: [PATCH] refactor(security): add typed suspicious-activity event Signed-off-by: Sebastian Krupinski --- core/lib/Module/Module.php | 3 +- core/lib/Security/Event/SecurityEvent.php | 4 - .../Event/SuspiciousActivityEvent.php | 100 ++++++++++++++++++ core/lib/Service/FirewallService.php | 3 +- tests/php/Unit/Event/SecurityEventTest.php | 4 - .../Event/SuspiciousActivityEventTest.php | 66 ++++++++++++ tests/php/Unit/Module/CoreModuleTest.php | 3 +- .../php/Unit/Service/FirewallServiceTest.php | 9 +- 8 files changed, 176 insertions(+), 16 deletions(-) create mode 100644 core/lib/Security/Event/SuspiciousActivityEvent.php create mode 100644 tests/php/Unit/Event/SuspiciousActivityEventTest.php diff --git a/core/lib/Module/Module.php b/core/lib/Module/Module.php index dcb7127..d4d5f26 100644 --- a/core/lib/Module/Module.php +++ b/core/lib/Module/Module.php @@ -15,6 +15,7 @@ use KTXC\Security\Event\AccessDeniedEvent; use KTXC\Security\Event\AuthenticationFailedEvent; use KTXC\Security\Event\BruteForceDetectedEvent; use KTXC\Security\Event\RateLimitExceededEvent; +use KTXC\Security\Event\SuspiciousActivityEvent; use KTXC\Security\Event\SecurityEvent; use KTXF\Event\DeliveryMode; use KTXF\Event\EventListenerRegistrarInterface; @@ -50,7 +51,7 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M AccessDeniedEvent::class, BruteForceDetectedEvent::class, RateLimitExceededEvent::class, - SecurityEvent::SUSPICIOUS_ACTIVITY, + SuspiciousActivityEvent::class, SecurityEvent::FIREWALL_RULE_CREATED, SecurityEvent::FIREWALL_RULE_EXTENDED, SecurityEvent::FIREWALL_RULE_ENABLED, diff --git a/core/lib/Security/Event/SecurityEvent.php b/core/lib/Security/Event/SecurityEvent.php index 42187a7..5bf751f 100644 --- a/core/lib/Security/Event/SecurityEvent.php +++ b/core/lib/Security/Event/SecurityEvent.php @@ -19,8 +19,6 @@ final class SecurityEvent extends Event implements SecurityRequestEventInterface public const ACCESS_GRANTED = 'security.access.granted'; - public const SUSPICIOUS_ACTIVITY = 'security.suspicious.activity'; - public const IP_BLOCKED = 'security.ip.blocked'; public const IP_ALLOWED = 'security.ip.allowed'; public const DEVICE_BLOCKED = 'security.device.blocked'; @@ -124,8 +122,6 @@ final class SecurityEvent extends Event implements SecurityRequestEventInterface self::AUTH_LOGOUT, self::TOKEN_REVOKED => self::SEVERITY_WARNING, - self::SUSPICIOUS_ACTIVITY => self::SEVERITY_ERROR, - self::IP_BLOCKED, self::DEVICE_BLOCKED => self::SEVERITY_CRITICAL, diff --git a/core/lib/Security/Event/SuspiciousActivityEvent.php b/core/lib/Security/Event/SuspiciousActivityEvent.php new file mode 100644 index 0000000..38b420f --- /dev/null +++ b/core/lib/Security/Event/SuspiciousActivityEvent.php @@ -0,0 +1,100 @@ + $detector] + $detectionData, + $tenantId, + $identityId, + ); + } + + public function getIpAddress(): string + { + return $this->ipAddress; + } + + public function getDetector(): string + { + return $this->detector; + } + + public function getDetectionData(): array + { + return $this->detectionData; + } + + public function getDeviceFingerprint(): ?string + { + return $this->deviceFingerprint; + } + + public function getUserAgent(): ?string + { + return $this->userAgent; + } + + public function getRequestPath(): ?string + { + return $this->requestPath; + } + + public function getRequestMethod(): ?string + { + return $this->requestMethod; + } + + public function getUserId(): ?string + { + return $this->userId; + } + + public function getReason(): ?string + { + return $this->reason; + } + + public function getSeverity(): int + { + return SecurityEvent::SEVERITY_ERROR; + } +} diff --git a/core/lib/Service/FirewallService.php b/core/lib/Service/FirewallService.php index 6c6f20a..88d8b47 100644 --- a/core/lib/Service/FirewallService.php +++ b/core/lib/Service/FirewallService.php @@ -14,6 +14,7 @@ use KTXC\Security\Event\AccessDeniedEvent; use KTXC\Security\Event\AuthenticationFailedEvent; use KTXC\Security\Event\BruteForceDetectedEvent; use KTXC\Security\Event\RateLimitExceededEvent; +use KTXC\Security\Event\SuspiciousActivityEvent; use KTXC\Security\Event\SecurityEvent; use KTXC\Security\Event\SecurityEventInterface; use KTXC\Security\Event\SecurityRequestEventInterface; @@ -286,7 +287,7 @@ class FirewallService BruteForceDetectedEvent::class => FirewallLogObject::EVENT_BRUTE_FORCE, RateLimitExceededEvent::class => FirewallLogObject::EVENT_RATE_LIMIT, AccessDeniedEvent::class => FirewallLogObject::EVENT_RULE_MATCH, - SecurityEvent::SUSPICIOUS_ACTIVITY => FirewallLogObject::EVENT_SUSPICIOUS, + SuspiciousActivityEvent::class => FirewallLogObject::EVENT_SUSPICIOUS, SecurityEvent::FIREWALL_RULE_CREATED => FirewallLogObject::EVENT_RULE_CREATED, SecurityEvent::FIREWALL_RULE_EXTENDED => FirewallLogObject::EVENT_RULE_EXTENDED, SecurityEvent::FIREWALL_RULE_ENABLED => FirewallLogObject::EVENT_RULE_ENABLED, diff --git a/tests/php/Unit/Event/SecurityEventTest.php b/tests/php/Unit/Event/SecurityEventTest.php index 833c798..48650b0 100644 --- a/tests/php/Unit/Event/SecurityEventTest.php +++ b/tests/php/Unit/Event/SecurityEventTest.php @@ -19,10 +19,6 @@ final class SecurityEventTest extends TestCase SecurityEvent::SEVERITY_WARNING, (new SecurityEvent(SecurityEvent::AUTH_LOGOUT))->getSeverity(), ); - self::assertSame( - SecurityEvent::SEVERITY_ERROR, - (new SecurityEvent(SecurityEvent::SUSPICIOUS_ACTIVITY))->getSeverity(), - ); self::assertSame( SecurityEvent::SEVERITY_CRITICAL, (new SecurityEvent(SecurityEvent::DEVICE_BLOCKED))->getSeverity(), diff --git a/tests/php/Unit/Event/SuspiciousActivityEventTest.php b/tests/php/Unit/Event/SuspiciousActivityEventTest.php new file mode 100644 index 0000000..d9efc93 --- /dev/null +++ b/tests/php/Unit/Event/SuspiciousActivityEventTest.php @@ -0,0 +1,66 @@ + 98], + tenantId: 'tenant-a', + identityId: 'identity-a', + deviceFingerprint: 'device-a', + userAgent: 'Test Agent', + requestPath: '/admin', + requestMethod: 'POST', + userId: 'user-a', + reason: 'Matched a blocked payload signature', + ); + + self::assertSame(SuspiciousActivityEvent::class, $event->getName()); + self::assertSame('203.0.113.20', $event->getIpAddress()); + self::assertSame('payload-signature', $event->getDetector()); + self::assertSame(['score' => 98], $event->getDetectionData()); + self::assertSame(['detector' => 'payload-signature', 'score' => 98], $event->getData()); + self::assertSame('tenant-a', $event->getTenantId()); + self::assertSame('identity-a', $event->getIdentityId()); + self::assertSame('device-a', $event->getDeviceFingerprint()); + self::assertSame('Test Agent', $event->getUserAgent()); + self::assertSame('/admin', $event->getRequestPath()); + self::assertSame('POST', $event->getRequestMethod()); + self::assertSame('user-a', $event->getUserId()); + self::assertSame('Matched a blocked payload signature', $event->getReason()); + self::assertSame(SecurityEvent::SEVERITY_ERROR, $event->getSeverity()); + } + + #[Test] + #[TestDox('Suspicious activity rejects incomplete or conflicting detection context')] + public function rejectsInvalidDetectionContext(): void + { + foreach ([ + ['', 'payload-signature', []], + ['203.0.113.20', '', []], + ['203.0.113.20', 'payload-signature', ['detector' => 'replacement']], + ] as $arguments) { + try { + new SuspiciousActivityEvent(...$arguments); + self::fail('Invalid suspicious-activity context was accepted.'); + } catch (\InvalidArgumentException) { + $this->addToAssertionCount(1); + } + } + } +} diff --git a/tests/php/Unit/Module/CoreModuleTest.php b/tests/php/Unit/Module/CoreModuleTest.php index 044733a..e8fc88e 100644 --- a/tests/php/Unit/Module/CoreModuleTest.php +++ b/tests/php/Unit/Module/CoreModuleTest.php @@ -20,6 +20,7 @@ use KTXC\Security\Event\AccessDeniedEvent; use KTXC\Security\Event\AuthenticationFailedEvent; use KTXC\Security\Event\BruteForceDetectedEvent; use KTXC\Security\Event\RateLimitExceededEvent; +use KTXC\Security\Event\SuspiciousActivityEvent; use KTXC\Security\Event\SecurityEvent; use KTXF\Event\DeliveryMode; use PHPUnit\Framework\Attributes\Test; @@ -49,7 +50,7 @@ final class CoreModuleTest extends TestCase AccessDeniedEvent::class, BruteForceDetectedEvent::class, RateLimitExceededEvent::class, - SecurityEvent::SUSPICIOUS_ACTIVITY, + SuspiciousActivityEvent::class, SecurityEvent::FIREWALL_RULE_CREATED, SecurityEvent::FIREWALL_RULE_EXTENDED, SecurityEvent::FIREWALL_RULE_DISABLED, diff --git a/tests/php/Unit/Service/FirewallServiceTest.php b/tests/php/Unit/Service/FirewallServiceTest.php index 2676864..8e2872b 100644 --- a/tests/php/Unit/Service/FirewallServiceTest.php +++ b/tests/php/Unit/Service/FirewallServiceTest.php @@ -14,6 +14,7 @@ use KTXC\Security\Event\AccessDeniedEvent; use KTXC\Security\Event\AuthenticationFailedEvent; use KTXC\Security\Event\BruteForceDetectedEvent; use KTXC\Security\Event\RateLimitExceededEvent; +use KTXC\Security\Event\SuspiciousActivityEvent; use KTXC\Service\FirewallService; use KTXC\Service\FirewallRuleCache; use KTXC\Service\FirewallRuleManager; @@ -320,11 +321,9 @@ class FirewallServiceTest extends TestCase && $log->getMetadata()['detector'] === 'payload-signature'; })) ->willReturnArgument(0); - $event = \KTXC\Security\Event\SecurityEvent::create( - \KTXC\Security\Event\SecurityEvent::SUSPICIOUS_ACTIVITY, - '203.0.113.20', - null, - ['detector' => 'payload-signature'], + $event = new SuspiciousActivityEvent( + ipAddress: '203.0.113.20', + detector: 'payload-signature', tenantId: 'tenant-a', requestPath: '/admin', requestMethod: 'POST',