feat(firewall): complete operational reliability phase
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,6 +66,6 @@ class ObjectId
|
|||||||
*/
|
*/
|
||||||
public static function isValid(string $id): bool
|
public static function isValid(string $id): bool
|
||||||
{
|
{
|
||||||
return MongoObjectId::isValid($id);
|
return preg_match('/^[a-f0-9]{24}$/iD', $id) === 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class FirewallLogObject implements \JsonSerializable, JsonDeserializable
|
|||||||
public const EVENT_RULE_MATCH = 'rule_match';
|
public const EVENT_RULE_MATCH = 'rule_match';
|
||||||
public const EVENT_ACCESS_CHECK = 'access_check';
|
public const EVENT_ACCESS_CHECK = 'access_check';
|
||||||
public const EVENT_RULE_CREATED = 'rule_created';
|
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_DISABLED = 'rule_disabled';
|
||||||
public const EVENT_RULE_REMOVED = 'rule_removed';
|
public const EVENT_RULE_REMOVED = 'rule_removed';
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace KTXC\Module;
|
namespace KTXC\Module;
|
||||||
|
|
||||||
|
use KTXC\Console\Firewall\FirewallMaintenanceCommand;
|
||||||
|
use KTXC\Console\Firewall\FirewallSetupCommand;
|
||||||
use KTXC\Service\FirewallService;
|
use KTXC\Service\FirewallService;
|
||||||
use KTXC\Service\SystemFirewallRuleService;
|
use KTXC\Service\SystemFirewallRuleService;
|
||||||
use KTXC\Service\TenantFirewallRuleService;
|
use KTXC\Service\TenantFirewallRuleService;
|
||||||
@@ -41,6 +43,7 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
|
|||||||
SecurityEvent::RATE_LIMIT_EXCEEDED,
|
SecurityEvent::RATE_LIMIT_EXCEEDED,
|
||||||
SecurityEvent::SUSPICIOUS_ACTIVITY,
|
SecurityEvent::SUSPICIOUS_ACTIVITY,
|
||||||
SecurityEvent::FIREWALL_RULE_CREATED,
|
SecurityEvent::FIREWALL_RULE_CREATED,
|
||||||
|
SecurityEvent::FIREWALL_RULE_EXTENDED,
|
||||||
SecurityEvent::FIREWALL_RULE_DISABLED,
|
SecurityEvent::FIREWALL_RULE_DISABLED,
|
||||||
SecurityEvent::FIREWALL_RULE_REMOVED,
|
SecurityEvent::FIREWALL_RULE_REMOVED,
|
||||||
] as $event) {
|
] as $event) {
|
||||||
@@ -158,6 +161,8 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
|
|||||||
public function registerCI(): array
|
public function registerCI(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
FirewallSetupCommand::class,
|
||||||
|
FirewallMaintenanceCommand::class,
|
||||||
\KTXC\Console\Event\EventsDebugCommand::class,
|
\KTXC\Console\Event\EventsDebugCommand::class,
|
||||||
\KTXC\Console\Module\ModuleListCommand::class,
|
\KTXC\Console\Module\ModuleListCommand::class,
|
||||||
\KTXC\Console\Module\ModuleEnableCommand::class,
|
\KTXC\Console\Module\ModuleEnableCommand::class,
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ final class FirewallRuleManager
|
|||||||
?string $reason,
|
?string $reason,
|
||||||
?string $createdBy,
|
?string $createdBy,
|
||||||
?int $durationSeconds = null,
|
?int $durationSeconds = null,
|
||||||
string $origin = self::ORIGIN_MANUAL
|
string $origin = self::ORIGIN_MANUAL,
|
||||||
|
array $metadata = []
|
||||||
): FirewallRuleObject {
|
): FirewallRuleObject {
|
||||||
$ipAddress = FirewallRuleValidator::ipAddress($ipAddress);
|
$ipAddress = FirewallRuleValidator::ipAddress($ipAddress);
|
||||||
FirewallRuleValidator::duration($durationSeconds);
|
FirewallRuleValidator::duration($durationSeconds);
|
||||||
@@ -46,6 +47,14 @@ final class FirewallRuleManager
|
|||||||
$scope->scope
|
$scope->scope
|
||||||
);
|
);
|
||||||
if ($existing) {
|
if ($existing) {
|
||||||
|
if (
|
||||||
|
$origin === self::ORIGIN_AUTOMATIC
|
||||||
|
&& ($existing->getMetadata()['origin'] ?? null) === self::ORIGIN_AUTOMATIC
|
||||||
|
&& $durationSeconds !== null
|
||||||
|
) {
|
||||||
|
return $this->extendAutomaticBlock($existing, $durationSeconds, $metadata);
|
||||||
|
}
|
||||||
|
|
||||||
return $existing;
|
return $existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +66,8 @@ final class FirewallRuleManager
|
|||||||
$reason ?? 'Blocked by administrator',
|
$reason ?? 'Blocked by administrator',
|
||||||
$createdBy,
|
$createdBy,
|
||||||
$durationSeconds,
|
$durationSeconds,
|
||||||
$origin
|
$origin,
|
||||||
|
$metadata
|
||||||
);
|
);
|
||||||
$this->publishIpEvent(SecurityEvent::IP_BLOCKED, $scope, $ipAddress, $reason);
|
$this->publishIpEvent(SecurityEvent::IP_BLOCKED, $scope, $ipAddress, $reason);
|
||||||
|
|
||||||
@@ -171,7 +181,8 @@ final class FirewallRuleManager
|
|||||||
string $reason,
|
string $reason,
|
||||||
?string $createdBy,
|
?string $createdBy,
|
||||||
?int $durationSeconds = null,
|
?int $durationSeconds = null,
|
||||||
string $origin = self::ORIGIN_MANUAL
|
string $origin = self::ORIGIN_MANUAL,
|
||||||
|
array $metadata = []
|
||||||
): FirewallRuleObject {
|
): FirewallRuleObject {
|
||||||
if (!in_array($origin, [self::ORIGIN_MANUAL, self::ORIGIN_AUTOMATIC], true)) {
|
if (!in_array($origin, [self::ORIGIN_MANUAL, self::ORIGIN_AUTOMATIC], true)) {
|
||||||
throw new \InvalidArgumentException("Invalid firewall rule origin: {$origin}");
|
throw new \InvalidArgumentException("Invalid firewall rule origin: {$origin}");
|
||||||
@@ -186,12 +197,17 @@ final class FirewallRuleManager
|
|||||||
->setReason($reason)
|
->setReason($reason)
|
||||||
->setCreatedBy($createdBy)
|
->setCreatedBy($createdBy)
|
||||||
->setCreatedAt(new \DateTimeImmutable())
|
->setCreatedAt(new \DateTimeImmutable())
|
||||||
->setMetadata(['origin' => $origin])
|
|
||||||
->setEnabled(true);
|
->setEnabled(true);
|
||||||
|
|
||||||
if ($durationSeconds !== null) {
|
if ($durationSeconds !== null) {
|
||||||
$rule->setExpiresAt((new \DateTimeImmutable())->modify("+{$durationSeconds} seconds"));
|
$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->store->depositRule($rule);
|
||||||
$this->cache->invalidate();
|
$this->cache->invalidate();
|
||||||
@@ -200,6 +216,43 @@ final class FirewallRuleManager
|
|||||||
return $rule;
|
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
|
private function ownedRule(FirewallRuleScope $scope, string $ruleId): ?FirewallRuleObject
|
||||||
{
|
{
|
||||||
$rule = $this->store->fetchRule($ruleId);
|
$rule = $this->store->fetchRule($ruleId);
|
||||||
@@ -233,6 +286,7 @@ final class FirewallRuleManager
|
|||||||
'reason' => $rule->getReason(),
|
'reason' => $rule->getReason(),
|
||||||
'origin' => $rule->getMetadata()['origin'] ?? self::ORIGIN_MANUAL,
|
'origin' => $rule->getMetadata()['origin'] ?? self::ORIGIN_MANUAL,
|
||||||
'expiresAt' => $rule->getExpiresAt()?->format(\DateTimeInterface::ATOM),
|
'expiresAt' => $rule->getExpiresAt()?->format(\DateTimeInterface::ATOM),
|
||||||
|
...($rule->getMetadata() ?? []),
|
||||||
]);
|
]);
|
||||||
$event->setTenantId($rule->getTenantId())
|
$event->setTenantId($rule->getTenantId())
|
||||||
->setIdentityId($actorId ?? $rule->getCreatedBy());
|
->setIdentityId($actorId ?? $rule->getCreatedBy());
|
||||||
|
|||||||
@@ -169,7 +169,8 @@ class FirewallService
|
|||||||
self::DEFAULT_AUTO_BLOCK_DURATION,
|
self::DEFAULT_AUTO_BLOCK_DURATION,
|
||||||
self::MAX_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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,7 +205,17 @@ class FirewallService
|
|||||||
sprintf('Auto-blocked: %d failed auth attempts in %d seconds', $failureCount, $windowSeconds),
|
sprintf('Auto-blocked: %d failed auth attempts in %d seconds', $failureCount, $windowSeconds),
|
||||||
null, // System-created
|
null, // System-created
|
||||||
$blockDuration,
|
$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::ACCESS_DENIED => FirewallLogObject::EVENT_RULE_MATCH,
|
||||||
SecurityEvent::SUSPICIOUS_ACTIVITY => FirewallLogObject::EVENT_SUSPICIOUS,
|
SecurityEvent::SUSPICIOUS_ACTIVITY => FirewallLogObject::EVENT_SUSPICIOUS,
|
||||||
SecurityEvent::FIREWALL_RULE_CREATED => FirewallLogObject::EVENT_RULE_CREATED,
|
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_DISABLED => FirewallLogObject::EVENT_RULE_DISABLED,
|
||||||
SecurityEvent::FIREWALL_RULE_REMOVED => FirewallLogObject::EVENT_RULE_REMOVED,
|
SecurityEvent::FIREWALL_RULE_REMOVED => FirewallLogObject::EVENT_RULE_REMOVED,
|
||||||
default => FirewallLogObject::EVENT_ACCESS_CHECK,
|
default => FirewallLogObject::EVENT_ACCESS_CHECK,
|
||||||
@@ -272,6 +284,7 @@ class FirewallService
|
|||||||
SecurityEvent::AUTH_SUCCESS,
|
SecurityEvent::AUTH_SUCCESS,
|
||||||
SecurityEvent::ACCESS_GRANTED => FirewallLogObject::RESULT_ALLOWED,
|
SecurityEvent::ACCESS_GRANTED => FirewallLogObject::RESULT_ALLOWED,
|
||||||
SecurityEvent::FIREWALL_RULE_CREATED,
|
SecurityEvent::FIREWALL_RULE_CREATED,
|
||||||
|
SecurityEvent::FIREWALL_RULE_EXTENDED,
|
||||||
SecurityEvent::FIREWALL_RULE_DISABLED,
|
SecurityEvent::FIREWALL_RULE_DISABLED,
|
||||||
SecurityEvent::FIREWALL_RULE_REMOVED => FirewallLogObject::RESULT_RECORDED,
|
SecurityEvent::FIREWALL_RULE_REMOVED => FirewallLogObject::RESULT_RECORDED,
|
||||||
default => FirewallLogObject::RESULT_BLOCKED,
|
default => FirewallLogObject::RESULT_BLOCKED,
|
||||||
@@ -341,15 +354,31 @@ class FirewallService
|
|||||||
*/
|
*/
|
||||||
public function cleanup(): array
|
public function cleanup(): array
|
||||||
{
|
{
|
||||||
$expiredRules = $this->store->cleanupExpiredRules();
|
$startedAt = new \DateTimeImmutable();
|
||||||
$oldLogs = $this->store->cleanupOldLogs(30);
|
|
||||||
$expiredClaims = $this->store->cleanupExpiredBruteForceClaims();
|
|
||||||
|
|
||||||
return [
|
try {
|
||||||
'expiredRules' => $expiredRules,
|
$result = [
|
||||||
'oldLogs' => $oldLogs,
|
'expiredRules' => $this->store->cleanupExpiredRules(),
|
||||||
'expiredBruteForceClaims' => $expiredClaims,
|
'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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace KTXC\Stores;
|
namespace KTXC\Stores;
|
||||||
|
|
||||||
use KTXC\Db\DataStore;
|
use KTXC\Db\DataStore;
|
||||||
|
use KTXC\Db\ObjectId;
|
||||||
use KTXC\Db\UTCDateTime;
|
use KTXC\Db\UTCDateTime;
|
||||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
use KTXC\Models\Firewall\FirewallRuleObject;
|
||||||
use KTXC\Models\Firewall\FirewallLogObject;
|
use KTXC\Models\Firewall\FirewallLogObject;
|
||||||
@@ -17,6 +18,7 @@ class FirewallStore
|
|||||||
protected const RULES_COLLECTION = 'firewall_rules';
|
protected const RULES_COLLECTION = 'firewall_rules';
|
||||||
protected const LOGS_COLLECTION = 'firewall_logs';
|
protected const LOGS_COLLECTION = 'firewall_logs';
|
||||||
protected const BRUTE_FORCE_CLAIMS_COLLECTION = 'firewall_brute_force_claims';
|
protected const BRUTE_FORCE_CLAIMS_COLLECTION = 'firewall_brute_force_claims';
|
||||||
|
protected const MAINTENANCE_COLLECTION = 'firewall_maintenance';
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
protected readonly DataStore $dataStore
|
protected readonly DataStore $dataStore
|
||||||
@@ -180,7 +182,7 @@ class FirewallStore
|
|||||||
*/
|
*/
|
||||||
public function fetchRule(string $id): ?FirewallRuleObject
|
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) {
|
if (!$entry) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -259,7 +261,7 @@ class FirewallStore
|
|||||||
unset($data['id']);
|
unset($data['id']);
|
||||||
|
|
||||||
$this->dataStore->selectCollection(self::RULES_COLLECTION)->updateOne(
|
$this->dataStore->selectCollection(self::RULES_COLLECTION)->updateOne(
|
||||||
['_id' => $id],
|
self::ruleIdFilter($id),
|
||||||
['$set' => $data]
|
['$set' => $data]
|
||||||
);
|
);
|
||||||
return $rule;
|
return $rule;
|
||||||
@@ -274,7 +276,7 @@ class FirewallStore
|
|||||||
if (!$id) {
|
if (!$id) {
|
||||||
return;
|
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();
|
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
|
private static function ruleDocument(FirewallRuleObject $rule): array
|
||||||
{
|
{
|
||||||
$data = $rule->jsonSerialize();
|
$data = $rule->jsonSerialize();
|
||||||
@@ -502,4 +531,9 @@ class FirewallStore
|
|||||||
{
|
{
|
||||||
return UTCDateTime::fromDateTime($date);
|
return UTCDateTime::fromDateTime($date);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function ruleIdFilter(string $id): array
|
||||||
|
{
|
||||||
|
return ['_id' => ObjectId::isValid($id) ? ObjectId::fromString($id) : $id];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -27,6 +27,7 @@ class SecurityEvent extends Event
|
|||||||
public const IP_ALLOWED = 'security.ip.allowed';
|
public const IP_ALLOWED = 'security.ip.allowed';
|
||||||
public const DEVICE_BLOCKED = 'security.device.blocked';
|
public const DEVICE_BLOCKED = 'security.device.blocked';
|
||||||
public const FIREWALL_RULE_CREATED = 'security.firewall.rule.created';
|
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_DISABLED = 'security.firewall.rule.disabled';
|
||||||
public const FIREWALL_RULE_REMOVED = 'security.firewall.rule.removed';
|
public const FIREWALL_RULE_REMOVED = 'security.firewall.rule.removed';
|
||||||
|
|
||||||
|
|||||||
@@ -304,6 +304,154 @@ class FirewallStoreTest extends TestCase
|
|||||||
self::assertSame(0, $claimIndex['expireAfterSeconds']);
|
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(
|
private function rule(
|
||||||
string $reason,
|
string $reason,
|
||||||
string $scope,
|
string $scope,
|
||||||
@@ -319,4 +467,21 @@ class FirewallStoreTest extends TestCase
|
|||||||
->setCreatedAt(new \DateTimeImmutable())
|
->setCreatedAt(new \DateTimeImmutable())
|
||||||
->setEnabled(true);
|
->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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace KTXT\Unit\Module;
|
namespace KTXT\Unit\Module;
|
||||||
|
|
||||||
|
use KTXC\Console\Firewall\FirewallMaintenanceCommand;
|
||||||
|
use KTXC\Console\Firewall\FirewallSetupCommand;
|
||||||
use KTXC\Console\Event\EventsDebugCommand;
|
use KTXC\Console\Event\EventsDebugCommand;
|
||||||
use KTXC\Module\Module;
|
use KTXC\Module\Module;
|
||||||
use KTXC\Service\FirewallService;
|
use KTXC\Service\FirewallService;
|
||||||
@@ -28,7 +30,7 @@ final class CoreModuleTest extends TestCase
|
|||||||
$module->boot();
|
$module->boot();
|
||||||
$definitions = $registry->definitions();
|
$definitions = $registry->definitions();
|
||||||
|
|
||||||
self::assertCount(9, $definitions);
|
self::assertCount(10, $definitions);
|
||||||
self::assertSame(['core'], array_values(array_unique(array_column($definitions, 'module'))));
|
self::assertSame(['core'], array_values(array_unique(array_column($definitions, 'module'))));
|
||||||
self::assertSame(
|
self::assertSame(
|
||||||
FirewallService::class,
|
FirewallService::class,
|
||||||
@@ -39,6 +41,7 @@ final class CoreModuleTest extends TestCase
|
|||||||
SecurityEvent::RATE_LIMIT_EXCEEDED,
|
SecurityEvent::RATE_LIMIT_EXCEEDED,
|
||||||
SecurityEvent::SUSPICIOUS_ACTIVITY,
|
SecurityEvent::SUSPICIOUS_ACTIVITY,
|
||||||
SecurityEvent::FIREWALL_RULE_CREATED,
|
SecurityEvent::FIREWALL_RULE_CREATED,
|
||||||
|
SecurityEvent::FIREWALL_RULE_EXTENDED,
|
||||||
SecurityEvent::FIREWALL_RULE_DISABLED,
|
SecurityEvent::FIREWALL_RULE_DISABLED,
|
||||||
SecurityEvent::FIREWALL_RULE_REMOVED,
|
SecurityEvent::FIREWALL_RULE_REMOVED,
|
||||||
] as $event) {
|
] as $event) {
|
||||||
@@ -57,6 +60,8 @@ final class CoreModuleTest extends TestCase
|
|||||||
$module = new Module(new EventListenerRegistry());
|
$module = new Module(new EventListenerRegistry());
|
||||||
|
|
||||||
self::assertContains(EventsDebugCommand::class, $module->registerCI());
|
self::assertContains(EventsDebugCommand::class, $module->registerCI());
|
||||||
|
self::assertContains(FirewallSetupCommand::class, $module->registerCI());
|
||||||
|
self::assertContains(FirewallMaintenanceCommand::class, $module->registerCI());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[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_DISABLED]->getIdentityId());
|
||||||
self::assertSame('operator', $events[SecurityEvent::FIREWALL_RULE_REMOVED]->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
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -394,7 +394,7 @@ class FirewallServiceTest extends TestCase
|
|||||||
->willReturn(5);
|
->willReturn(5);
|
||||||
$this->store->expects($this->once())
|
$this->store->expects($this->once())
|
||||||
->method('claimBruteForce')
|
->method('claimBruteForce')
|
||||||
->with('tenant-event', '203.0.113.10', 3600)
|
->with('tenant-event', '203.0.113.10', 300)
|
||||||
->willReturn(true);
|
->willReturn(true);
|
||||||
$this->store->expects($this->once())
|
$this->store->expects($this->once())
|
||||||
->method('findExactIpRule')
|
->method('findExactIpRule')
|
||||||
@@ -407,9 +407,14 @@ class FirewallServiceTest extends TestCase
|
|||||||
$this->store->expects($this->once())
|
$this->store->expects($this->once())
|
||||||
->method('depositRule')
|
->method('depositRule')
|
||||||
->with(self::callback(static function (FirewallRuleObject $rule): bool {
|
->with(self::callback(static function (FirewallRuleObject $rule): bool {
|
||||||
|
$metadata = $rule->getMetadata();
|
||||||
return $rule->getScope() === FirewallRuleObject::SCOPE_TENANT
|
return $rule->getScope() === FirewallRuleObject::SCOPE_TENANT
|
||||||
&& $rule->getTenantId() === 'tenant-event'
|
&& $rule->getTenantId() === 'tenant-event'
|
||||||
&& $rule->getExpiresAt() !== null;
|
&& $rule->getExpiresAt() !== null
|
||||||
|
&& $metadata['failureThreshold'] === 5
|
||||||
|
&& $metadata['failureWindowSeconds'] === 300
|
||||||
|
&& $metadata['lastFailureCount'] === 5
|
||||||
|
&& $metadata['blockDurationSeconds'] === 3600;
|
||||||
}))
|
}))
|
||||||
->willReturnArgument(0);
|
->willReturnArgument(0);
|
||||||
|
|
||||||
@@ -445,7 +450,7 @@ class FirewallServiceTest extends TestCase
|
|||||||
->willReturn(5);
|
->willReturn(5);
|
||||||
$this->store->expects($this->once())
|
$this->store->expects($this->once())
|
||||||
->method('claimBruteForce')
|
->method('claimBruteForce')
|
||||||
->with('tenant-a', '203.0.113.10', 3600)
|
->with('tenant-a', '203.0.113.10', 300)
|
||||||
->willReturn(false);
|
->willReturn(false);
|
||||||
$this->store->expects($this->never())->method('depositRule');
|
$this->store->expects($this->never())->method('depositRule');
|
||||||
$this->events->expects($this->never())->method('dispatch');
|
$this->events->expects($this->never())->method('dispatch');
|
||||||
@@ -500,6 +505,50 @@ class FirewallServiceTest extends TestCase
|
|||||||
self::assertSame($eventId, $event->getEventId());
|
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(
|
private function rule(
|
||||||
string $id,
|
string $id,
|
||||||
string $scope,
|
string $scope,
|
||||||
|
|||||||
Reference in New Issue
Block a user