4 Commits

Author SHA1 Message Date
Sebastian b781f8dde1 chore(deps): update dependency vue-i18n to v11.4.8
Build Test / build (pull_request) Successful in 18s
JS Unit Tests / test (pull_request) Successful in 15s
PHP Unit Tests / test (pull_request) Failing after 1m17s
PHP Integration Tests / Integration Tests (pull_request) Failing after 1m27s
2026-08-01 03:02:38 +00:00
Sebastian 4ee91a7918 feat(firewall): complete operational reliability phase
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-30 23:39:37 -04:00
Sebastian 90d847ffae perf(firewall): use BSON dates and add query indexes
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-30 23:13:37 -04:00
Sebastian ad6443bff3 fix(firewall): serialize automatic brute-force blocking
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-30 23:05:32 -04:00
17 changed files with 883 additions and 73 deletions
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\Firewall;
use KTXC\Service\FirewallService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'firewall:maintenance', description: 'Remove expired firewall data and record the outcome')]
final class FirewallMaintenanceCommand extends Command
{
public function __construct(private readonly FirewallService $firewall)
{
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
try {
$result = $this->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;
}
}
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\Firewall;
use KTXC\Stores\FirewallStore;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'firewall:setup', description: 'Install or verify firewall database indexes')]
final class FirewallSetupCommand extends Command
{
public function __construct(private readonly FirewallStore $store)
{
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
try {
$indexes = $this->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;
}
}
+1 -1
View File
@@ -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;
}
}
+20 -3
View File
@@ -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';
@@ -90,9 +91,7 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
$this->identityId = $data['identityId'] !== null ? (string)$data['identityId'] : null;
}
if (array_key_exists('timestamp', $data)) {
$this->timestamp = $data['timestamp'] !== null
? new \DateTimeImmutable($data['timestamp'])
: null;
$this->timestamp = self::deserializeDate($data['timestamp']);
}
if (array_key_exists('metadata', $data)) {
$this->metadata = $data['metadata'] !== null ? (array)$data['metadata'] : null;
@@ -122,6 +121,24 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
];
}
private static function deserializeDate(mixed $value): ?\DateTimeImmutable
{
if ($value === null) {
return null;
}
if ($value instanceof \MongoDB\BSON\UTCDateTime) {
return \DateTimeImmutable::createFromMutable($value->toDateTime());
}
if ($value instanceof \DateTimeImmutable) {
return $value;
}
if ($value instanceof \DateTimeInterface) {
return \DateTimeImmutable::createFromInterface($value);
}
return new \DateTimeImmutable((string)$value);
}
// Getters and setters
public function getId(): ?string
@@ -69,14 +69,10 @@ class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
$this->createdBy = $data['createdBy'] !== null ? (string)$data['createdBy'] : null;
}
if (array_key_exists('createdAt', $data)) {
$this->createdAt = $data['createdAt'] !== null
? new \DateTimeImmutable($data['createdAt'])
: null;
$this->createdAt = self::deserializeDate($data['createdAt']);
}
if (array_key_exists('expiresAt', $data)) {
$this->expiresAt = $data['expiresAt'] !== null
? new \DateTimeImmutable($data['expiresAt'])
: null;
$this->expiresAt = self::deserializeDate($data['expiresAt']);
}
if (array_key_exists('enabled', $data)) {
$this->enabled = (bool)$data['enabled'];
@@ -106,6 +102,24 @@ class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
];
}
private static function deserializeDate(mixed $value): ?\DateTimeImmutable
{
if ($value === null) {
return null;
}
if ($value instanceof \MongoDB\BSON\UTCDateTime) {
return \DateTimeImmutable::createFromMutable($value->toDateTime());
}
if ($value instanceof \DateTimeImmutable) {
return $value;
}
if ($value instanceof \DateTimeInterface) {
return \DateTimeImmutable::createFromInterface($value);
}
return new \DateTimeImmutable((string)$value);
}
/**
* Check if this rule has expired
*/
+5
View File
@@ -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,
+58 -4
View File
@@ -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());
+56 -16
View File
@@ -164,7 +164,23 @@ class FirewallService
);
if ($failureCount >= $maxFailures) {
$this->handleBruteForce($tenantId, $ipAddress, $failureCount, $windowSeconds);
$blockDuration = $this->getBoundedIntegerConfig(
self::CONFIG_AUTO_BLOCK_DURATION,
self::DEFAULT_AUTO_BLOCK_DURATION,
self::MAX_AUTO_BLOCK_DURATION
);
$responseCooldown = min($windowSeconds, max(1, intdiv($blockDuration, 2)));
if (!$this->store->claimBruteForce($tenantId, $ipAddress, $responseCooldown)) {
return;
}
$this->handleBruteForce(
$tenantId,
$ipAddress,
$failureCount,
$windowSeconds,
$blockDuration
);
}
}
@@ -175,27 +191,31 @@ class FirewallService
string $tenantId,
string $ipAddress,
int $failureCount,
int $windowSeconds
int $windowSeconds,
int $blockDuration
): void {
// Publish brute force event
$event = SecurityEvent::bruteForceDetected($ipAddress, $failureCount, $windowSeconds);
$event->setTenantId($tenantId);
$this->events->dispatch($event);
// Auto-block the IP
$blockDuration = $this->getBoundedIntegerConfig(
self::CONFIG_AUTO_BLOCK_DURATION,
self::DEFAULT_AUTO_BLOCK_DURATION,
self::MAX_AUTO_BLOCK_DURATION
);
$this->rules->blockIp(
FirewallRuleScope::tenant($tenantId),
$ipAddress,
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,
]
);
}
@@ -248,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,
@@ -263,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,
@@ -332,13 +354,31 @@ class FirewallService
*/
public function cleanup(): array
{
$expiredRules = $this->store->cleanupExpiredRules();
$oldLogs = $this->store->cleanupOldLogs(30);
$startedAt = new \DateTimeImmutable();
return [
'expiredRules' => $expiredRules,
'oldLogs' => $oldLogs,
];
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;
}
}
}
+178 -17
View File
@@ -5,6 +5,8 @@ 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;
@@ -15,11 +17,58 @@ 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
) {}
/**
* Install the indexes used by firewall enforcement, audit queries, and expiry.
*
* MongoDB createIndex is idempotent when the name and specification match.
*
* @return string[]
*/
public function ensureIndexes(): array
{
$rules = $this->dataStore->selectCollection(self::RULES_COLLECTION);
$logs = $this->dataStore->selectCollection(self::LOGS_COLLECTION);
$claims = $this->dataStore->selectCollection(self::BRUTE_FORCE_CLAIMS_COLLECTION);
return [
$rules->createIndex(
['scope' => 1, 'tenantId' => 1, 'enabled' => 1, 'expiresAt' => 1],
['name' => 'rules_by_scope_tenant_active']
),
$rules->createIndex(
['scope' => 1, 'tenantId' => 1, 'type' => 1, 'value' => 1, 'action' => 1, 'enabled' => 1, 'expiresAt' => 1],
['name' => 'rules_exact_lookup']
),
$logs->createIndex(
['tenantId' => 1, 'ipAddress' => 1, 'eventType' => 1, 'timestamp' => -1],
['name' => 'logs_auth_failures']
),
$logs->createIndex(
['tenantId' => 1, 'timestamp' => -1],
['name' => 'logs_tenant_timeline']
),
$logs->createIndex(
['tenantId' => 1, 'result' => 1, 'timestamp' => -1],
['name' => 'logs_blocked_counts']
),
$logs->createIndex(
['tenantId' => 1, 'eventType' => 1, 'timestamp' => -1],
['name' => 'logs_event_type']
),
$claims->createIndex(
['expiresAt' => 1],
['name' => 'claims_expiry', 'expireAfterSeconds' => 0]
),
];
}
// ========================================
// Rule Operations
// ========================================
@@ -39,7 +88,7 @@ class FirewallStore
$filter['$and'] = [[
'$or' => [
['expiresAt' => null],
['expiresAt' => ['$gt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)]]
['expiresAt' => ['$gt' => self::bsonDate(new \DateTimeImmutable())]]
],
]];
}
@@ -62,7 +111,7 @@ class FirewallStore
$filter['enabled'] = true;
$filter['$or'] = [
['expiresAt' => null],
['expiresAt' => ['$gt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)]],
['expiresAt' => ['$gt' => self::bsonDate(new \DateTimeImmutable())]],
];
}
@@ -86,7 +135,7 @@ class FirewallStore
'enabled' => true,
'$or' => [
['expiresAt' => null],
['expiresAt' => ['$gt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)]]
['expiresAt' => ['$gt' => self::bsonDate(new \DateTimeImmutable())]]
]
];
@@ -113,7 +162,7 @@ class FirewallStore
'enabled' => true,
'$or' => [
['expiresAt' => null],
['expiresAt' => ['$gt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)]]
['expiresAt' => ['$gt' => self::bsonDate(new \DateTimeImmutable())]]
]
];
@@ -133,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;
}
@@ -157,7 +206,7 @@ class FirewallStore
'enabled' => true,
'$or' => [
['expiresAt' => null],
['expiresAt' => ['$gt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)]],
['expiresAt' => ['$gt' => self::bsonDate(new \DateTimeImmutable())]],
],
];
@@ -193,7 +242,7 @@ class FirewallStore
private function createRule(FirewallRuleObject $rule): ?FirewallRuleObject
{
$data = $rule->jsonSerialize();
$data = self::ruleDocument($rule);
unset($data['id']); // Remove id for insert
$result = $this->dataStore->selectCollection(self::RULES_COLLECTION)->insertOne($data);
@@ -208,11 +257,11 @@ class FirewallStore
return null;
}
$data = $rule->jsonSerialize();
$data = self::ruleDocument($rule);
unset($data['id']);
$this->dataStore->selectCollection(self::RULES_COLLECTION)->updateOne(
['_id' => $id],
self::ruleIdFilter($id),
['$set' => $data]
);
return $rule;
@@ -227,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));
}
/**
@@ -236,8 +285,10 @@ class FirewallStore
public function cleanupExpiredRules(): int
{
$result = $this->dataStore->selectCollection(self::RULES_COLLECTION)->deleteMany([
'expiresAt' => ['$lt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)],
'expiresAt' => ['$ne' => null]
'expiresAt' => [
'$lt' => self::bsonDate(new \DateTimeImmutable()),
'$ne' => null,
],
]);
return $result->getDeletedCount();
@@ -252,7 +303,7 @@ class FirewallStore
*/
public function createLog(FirewallLogObject $log): FirewallLogObject
{
$data = $log->jsonSerialize();
$data = self::logDocument($log);
unset($data['id']);
$result = $this->dataStore->selectCollection(self::LOGS_COLLECTION)->insertOne($data);
@@ -270,7 +321,7 @@ class FirewallStore
throw new \InvalidArgumentException('Idempotent firewall logs require an event ID.');
}
$data = $log->jsonSerialize();
$data = self::logDocument($log);
unset($data['id']);
$data['_id'] = $eventId;
@@ -339,10 +390,50 @@ class FirewallStore
'tenantId' => $tenantId,
'ipAddress' => $ipAddress,
'eventType' => FirewallLogObject::EVENT_AUTH_FAILURE,
'timestamp' => ['$gte' => $since->format(\DateTimeInterface::ATOM)]
'timestamp' => ['$gte' => self::bsonDate($since)]
]);
}
/**
* Atomically claim responsibility for responding to a tenant/IP brute-force incident.
*/
public function claimBruteForce(
string $tenantId,
string $ipAddress,
int $claimDurationSeconds
): bool {
if ($claimDurationSeconds < 1) {
throw new \InvalidArgumentException('Brute-force claim duration must be greater than zero.');
}
$now = new \DateTimeImmutable();
$claimId = hash('sha256', $tenantId."\0".$ipAddress);
$collection = $this->dataStore->selectCollection(self::BRUTE_FORCE_CLAIMS_COLLECTION);
$collection->deleteOne([
'_id' => $claimId,
'expiresAt' => ['$lte' => self::bsonDate($now)],
]);
try {
$collection->insertOne([
'_id' => $claimId,
'tenantId' => $tenantId,
'ipAddress' => $ipAddress,
'createdAt' => self::bsonDate($now),
'expiresAt' => self::bsonDate($now->modify("+{$claimDurationSeconds} seconds")),
]);
} catch (\MongoDB\Driver\Exception\BulkWriteException $error) {
if ($error->getCode() === 11000) {
return false;
}
throw $error;
}
return true;
}
/**
* Get blocked requests count for dashboard
*/
@@ -356,7 +447,7 @@ class FirewallStore
];
if ($since !== null) {
$filter['timestamp'] = ['$gte' => $since->format(\DateTimeInterface::ATOM)];
$filter['timestamp'] = ['$gte' => self::bsonDate($since)];
}
return $this->dataStore->selectCollection(self::LOGS_COLLECTION)->countDocuments($filter);
@@ -370,9 +461,79 @@ class FirewallStore
$cutoff = (new \DateTimeImmutable())->modify("-{$daysToKeep} days");
$result = $this->dataStore->selectCollection(self::LOGS_COLLECTION)->deleteMany([
'timestamp' => ['$lt' => $cutoff->format(\DateTimeInterface::ATOM)]
'timestamp' => ['$lt' => self::bsonDate($cutoff)]
]);
return $result->getDeletedCount();
}
public function cleanupExpiredBruteForceClaims(): int
{
$result = $this->dataStore
->selectCollection(self::BRUTE_FORCE_CLAIMS_COLLECTION)
->deleteMany([
'expiresAt' => ['$lte' => self::bsonDate(new \DateTimeImmutable())],
]);
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();
$data['createdAt'] = self::nullableBsonDate($rule->getCreatedAt());
$data['expiresAt'] = self::nullableBsonDate($rule->getExpiresAt());
return $data;
}
private static function logDocument(FirewallLogObject $log): array
{
$data = $log->jsonSerialize();
$data['timestamp'] = self::nullableBsonDate($log->getTimestamp());
return $data;
}
private static function nullableBsonDate(?\DateTimeInterface $date): ?UTCDateTime
{
return $date === null ? null : self::bsonDate($date);
}
private static function bsonDate(\DateTimeInterface $date): UTCDateTime
{
return UTCDateTime::fromDateTime($date);
}
private static function ruleIdFilter(string $id): array
{
return ['_id' => ObjectId::isValid($id) ? ObjectId::fromString($id) : $id];
}
}
+2
View File
@@ -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
+24 -24
View File
@@ -642,14 +642,14 @@
}
},
"node_modules/@intlify/core-base": {
"version": "11.4.7",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.7.tgz",
"integrity": "sha512-MSB/sBKwEWJTILvQIhg2rnIcwPpLayo3wGwvVA+dJTNeUBD9GoqQgAaSOLdI9iOPDHCm9YoVnLqpfzza98MpkQ==",
"version": "11.4.8",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.8.tgz",
"integrity": "sha512-A+Q7SKm5oEcy1E/cghqd7n/St4XjTqLhiiyDuieNcMrJcrHlkY5n0jp7Q9dD3txvVHzvsmBVV5M9wD5/s1zfzw==",
"license": "MIT",
"dependencies": {
"@intlify/devtools-types": "11.4.7",
"@intlify/message-compiler": "11.4.7",
"@intlify/shared": "11.4.7"
"@intlify/devtools-types": "11.4.8",
"@intlify/message-compiler": "11.4.8",
"@intlify/shared": "11.4.8"
},
"engines": {
"node": ">= 22"
@@ -659,13 +659,13 @@
}
},
"node_modules/@intlify/devtools-types": {
"version": "11.4.7",
"resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.7.tgz",
"integrity": "sha512-GSz+J+hqH+AEpAHIYya6fSufS30OaMnG39HiZX7DmGKi3+aaLvassCfsXENEc4Wr4m68q2YP0QdMdB3D9UeAXg==",
"version": "11.4.8",
"resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.8.tgz",
"integrity": "sha512-MGpID+rlfzGUbNcnC20bm5NMSBHPrvx0atLTfv9dftn3kjXw1hGKDcIcwrO99tSrZEc2i+hczRL7ks8qXsHPkQ==",
"license": "MIT",
"dependencies": {
"@intlify/core-base": "11.4.7",
"@intlify/shared": "11.4.7"
"@intlify/core-base": "11.4.8",
"@intlify/shared": "11.4.8"
},
"engines": {
"node": ">= 22"
@@ -675,12 +675,12 @@
}
},
"node_modules/@intlify/message-compiler": {
"version": "11.4.7",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.7.tgz",
"integrity": "sha512-bHxmh7n94N4N1evADeb7XTkc3jTw6Ki5biMFZVSX6Jmk+iehy8/maeH2XUsBI27rtKIK+Hzc6QnVAKggUwylKw==",
"version": "11.4.8",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.8.tgz",
"integrity": "sha512-vbzk17dYwduYiv52EK61+FDCyhfVg1uPUtPmiD/d45W99uJIcXywrweOBcHv7n9/iEqmXiMGT52bgJbZDQqK3w==",
"license": "MIT",
"dependencies": {
"@intlify/shared": "11.4.7",
"@intlify/shared": "11.4.8",
"source-map-js": "^1.0.2"
},
"engines": {
@@ -691,9 +691,9 @@
}
},
"node_modules/@intlify/shared": {
"version": "11.4.7",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.7.tgz",
"integrity": "sha512-OtjPZan3No2OZZFnMUiCVsXC6+j+XRwEywaFDk0AoayAbLuPesyDloXhJZLl9JUl5vHZeQUkYSbEA8VX+CWMjg==",
"version": "11.4.8",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.8.tgz",
"integrity": "sha512-XbRgrv+XEuvDr7UCY55oibVrh+o4u+A0VB6nSL0F5Z8LcZxE/8j573LYG6bCrOigIcHdGpSNI7Rh5UpC5/B/eg==",
"license": "MIT",
"engines": {
"node": ">= 22"
@@ -6537,14 +6537,14 @@
}
},
"node_modules/vue-i18n": {
"version": "11.4.7",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.7.tgz",
"integrity": "sha512-j6RyshdPPzqLiMAUpnpvZGFPM+rRoWi14Sl5yTsquvoW0/56DWyvhAj2o9TO2YXGvb6teg8T0xrYO9jR3urvdw==",
"version": "11.4.8",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.8.tgz",
"integrity": "sha512-0ULeHP6Z9CGvAm67S77ZEp41cfGXIREGL8qfhos2BMgcQQewtQcDKuojt6jjasAD/S8GwfTp2ySPmDSpwvrCMQ==",
"license": "MIT",
"dependencies": {
"@intlify/core-base": "11.4.7",
"@intlify/devtools-types": "11.4.7",
"@intlify/shared": "11.4.7",
"@intlify/core-base": "11.4.8",
"@intlify/devtools-types": "11.4.8",
"@intlify/shared": "11.4.8",
"@vue/devtools-api": "^6.5.0"
},
"engines": {
+1
View File
@@ -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';
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace KTXT\Integration\Stores;
use KTXC\Db\DataStore;
use KTXC\Db\UTCDateTime;
use KTXC\Models\Firewall\FirewallRuleObject;
use KTXC\Models\Firewall\FirewallLogObject;
use KTXC\Service\FirewallRuleCache;
@@ -238,6 +239,219 @@ class FirewallStoreTest extends TestCase
self::assertSame('event-123', $logs[0]->getEventId());
}
#[TestDox('Only one worker can claim a tenant and IP brute-force response')]
public function testBruteForceClaim(): void
{
self::assertTrue($this->store->claimBruteForce('tenant-a', '203.0.113.10', 3600));
self::assertFalse($this->store->claimBruteForce('tenant-a', '203.0.113.10', 3600));
self::assertTrue($this->store->claimBruteForce('tenant-b', '203.0.113.10', 3600));
self::assertTrue($this->store->claimBruteForce('tenant-a', '203.0.113.11', 3600));
}
#[TestDox('Expired brute-force claims can be acquired again and cleaned up')]
public function testExpiredBruteForceClaim(): void
{
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::assertTrue($this->store->claimBruteForce('tenant-a', '203.0.113.10', 3600));
$this->dataStore->selectCollection('firewall_brute_force_claims')->updateOne(
['_id' => $claimId],
['$set' => ['expiresAt' => UTCDateTime::fromDateTime(new \DateTimeImmutable('-1 minute'))]]
);
self::assertSame(1, $this->store->cleanupExpiredBruteForceClaims());
}
#[TestDox('Firewall dates use BSON date storage and indexes are installed idempotently')]
public function testBsonDatesAndIndexes(): void
{
$rule = $this->rule('bson-date', FirewallRuleObject::SCOPE_TENANT, 'tenant-a')
->setExpiresAt(new \DateTimeImmutable('+1 hour'));
$this->store->depositRule($rule);
$log = (new FirewallLogObject())
->setTenantId('tenant-a')
->setEventType(FirewallLogObject::EVENT_ACCESS_CHECK)
->setResult(FirewallLogObject::RESULT_ALLOWED)
->setTimestamp(new \DateTimeImmutable());
$this->store->createLog($log);
$storedRule = $this->dataStore->selectCollection('firewall_rules')
->getMongoCollection()
->findOne(['_id' => new \MongoDB\BSON\ObjectId($rule->getId())]);
$storedLog = $this->dataStore->selectCollection('firewall_logs')
->getMongoCollection()
->findOne(['_id' => new \MongoDB\BSON\ObjectId($log->getId())]);
self::assertInstanceOf(\MongoDB\BSON\UTCDateTime::class, $storedRule['createdAt']);
self::assertInstanceOf(\MongoDB\BSON\UTCDateTime::class, $storedRule['expiresAt']);
self::assertInstanceOf(\MongoDB\BSON\UTCDateTime::class, $storedLog['timestamp']);
$expected = $this->store->ensureIndexes();
self::assertSame($expected, $this->store->ensureIndexes());
$claimIndex = null;
foreach ($this->dataStore->selectCollection('firewall_brute_force_claims')->getMongoCollection()->listIndexes() as $index) {
if ($index->getName() === 'claims_expiry') {
$claimIndex = $index;
}
}
self::assertNotNull($claimIndex);
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,
@@ -253,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());
}
}
+6 -1
View File
@@ -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
));
}
}
+74 -1
View File
@@ -392,6 +392,10 @@ class FirewallServiceTest extends TestCase
->method('countRecentFailures')
->with('tenant-event', '203.0.113.10', 300)
->willReturn(5);
$this->store->expects($this->once())
->method('claimBruteForce')
->with('tenant-event', '203.0.113.10', 300)
->willReturn(true);
$this->store->expects($this->once())
->method('findExactIpRule')
->with(
@@ -403,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);
@@ -431,6 +440,26 @@ class FirewallServiceTest extends TestCase
self::assertSame(FirewallRuleManager::ORIGIN_AUTOMATIC, $lifecycleOrigin);
}
#[TestDox('Workers that lose the brute-force claim do not block or publish detection events')]
public function testAutomaticBlockClaimLoss(): void
{
$this->store->method('createLogOnce')->willReturn(true);
$this->store->expects($this->once())
->method('countRecentFailures')
->with('tenant-a', '203.0.113.10', 300)
->willReturn(5);
$this->store->expects($this->once())
->method('claimBruteForce')
->with('tenant-a', '203.0.113.10', 300)
->willReturn(false);
$this->store->expects($this->never())->method('depositRule');
$this->events->expects($this->never())->method('dispatch');
$this->service->handleAuthFailure(
\KTXF\Event\SecurityEvent::authFailure('203.0.113.10')
);
}
#[TestDox('Authentication events without a tenant use the current tenant')]
public function testAutomaticBlockTenantFallback(): void
{
@@ -476,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,