diff --git a/CHANGELOG.md b/CHANGELOG.md index f50480f..222358a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,30 @@ The [public API](https://semver.org/spec/v2.0.0.html#spec-item-1) of this libra As far as possible, we try to adhere to [Symfony guidelines](https://symfony.com/doc/current/contributing/code/bc.html#working-on-symfony-code) when deciding whether a change is a breaking change or not. +--- + +## [4.0.0](https://github.com/crowdsecurity/php-remediation-engine/releases/tag/v4.0.0) - 2024-??-?? +[_Compare with previous release_](https://github.com/crowdsecurity/php-remediation-engine/compare/v3.5.0...HEAD) + +**This release is not published yet.** + +### Added + +- Add `LapiRemediation::pushUsageMetrics` method to push usage metrics to LAPI +- Add `bouncing_level` configuration to cap maximum remediation level + +### Changed + +- **Breaking change**: `getIpRemediation` method now returns an array with `remediation` and `origin` keys +- **Breaking change**: Change protected `AbstractRemediation::updateRemediationOriginCount` method to public + `updateMetricsOriginsCount` with new `$remediation` and `$delta` parameters. +- **Breaking change**: Do not store origins count in cache as it should be managed by the bouncer +- Update `crowdsec/lapi-client` dependency to `v3.4.0` + +### Removed + +- Removed deprecated methods + --- ## [3.5.0](https://github.com/crowdsecurity/php-remediation-engine/releases/tag/v3.5.0) - 2024-10-18 diff --git a/composer.json b/composer.json index 72dbfb1..4fd7ebc 100644 --- a/composer.json +++ b/composer.json @@ -47,7 +47,7 @@ "symfony/cache": "^5.4.11|| ^6.0.11", "crowdsec/common": "^2.3.2", "crowdsec/capi-client": "^3.2.0", - "crowdsec/lapi-client": "^3.3.0", + "crowdsec/lapi-client": "^3.4.0", "monolog/monolog": "^1.17 || ^2.1", "mlocati/ip-lib": "^1.18", "geoip2/geoip2": "^2.13.0" diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 24c0ac5..91263a3 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -22,6 +22,7 @@ - [Example scripts](#example-scripts) - [CAPI remediation engine configurations](#capi-remediation-engine-configurations) - [Remediation priorities](#remediation-priorities) + - [Bouncing level](#bouncing-level) - [Remediation fallback](#remediation-fallback) - [Geolocation](#geolocation) - [Refresh frequency indicator](#refresh-frequency-indicator) @@ -67,11 +68,13 @@ This kind of action is called a remediation and can be: - Use the cached decisions for CAPI and for LAPI in stream mode - For LAPI in live mode, call LAPI if there is no cached decision - Use customizable remediation priorities - - Determine AppSec (LAPI) remediation for a given request + - Determine AppSec (LAPI) remediation for a given request + +- CrowdSec metrics + - Push usage metrics to LAPI - Overridable cache handler (built-in support for `Redis`, `Memcached` and `PhpFiles` caches) - - Large PHP matrix compatibility: from 7.2 to 8.4 @@ -341,6 +344,16 @@ The `$rawBody` parameter is optional and must be used if the forwarded request c Please see the [CrowdSec AppSec documentation](https://docs.crowdsec.net/docs/appsec/intro) for more details. +##### Push usage metrics to LAPI + +To push usage metrics to LAPI, you can do the following call: + +```php + $remediationEngine->pushUsageMetrics($bouncerName, $bouncerVersion, $bouncerType); +``` + +Metrics are retrieved from the cache and sent to LAPI. + #### Example scripts @@ -437,6 +450,20 @@ php tests/scripts/get-remediation-appsec.php +``` + +###### Example usage + +```bash + php tests/scripts/push-lapi-usage-metrics.php 68c2b479830c89bfd48926f9d764da39 https://crowdsec:8080 +``` + ## CAPI remediation engine configurations @@ -465,6 +492,18 @@ This setting is not required. If you don't set any value, `['ban']` will be used In the example above, priorities can be summarized as `ban > captcha > bypass`. +### Bouncing level + +```php +$configs = [ + ... + 'bouncing_level' => 'normal_bouncing' + ... +]; +``` + +- `bouncing_level`: Select from `bouncing_disabled`, `normal_bouncing` or `flex_bouncing`. Choose if you want to apply CrowdSec directives (Normal bouncing) or be more permissive (Flex bouncing). With the `Flex mode`, it is impossible to accidentally block access to your site to people who don’t deserve it. This mode makes it possible to never ban an IP but only to offer a captcha, in the worst-case scenario. + ### Remediation fallback @@ -539,8 +578,7 @@ This setting is not required. If you don't set any value, `14400` (4h) will be u The first parameter `$configs` of the `LapiRemediation` constructor can be used to pass some settings. -As for the CAPI remediation engine above, you can pass `ordered_remediations`, `fallback_remediation` and -`geolocation` settings. +As for the CAPI remediation engine above, you can pass `ordered_remediations`, `bouncing_level`, `fallback_remediation` and `geolocation` settings. In addition, LAPI remediation engine handles the following settings: @@ -744,10 +782,11 @@ the origin. When the retrieved remediation is a `bypass` (i.e. no active decisio $originsCount = $remediation->getOriginsCount(); /*$originsCount = [ - 'appsec' => 6, - 'clean' => 150, - 'clean_appsec' => 2, - 'capi' => 28, - 'lists' => 16, + 'appsec' => ['ban' => 10], + 'clean' => ['bypass' =>150], + 'clean_appsec' => ['bypass' =>2], + 'CAPI' => ['ban' => 28], + 'cscli' => ['ban' => 5, 'captcha' => 3], + 'lists:tor' => ['custom' => 16], ]*/ ``` diff --git a/src/AbstractRemediation.php b/src/AbstractRemediation.php index 54ab71c..1f7f139 100644 --- a/src/AbstractRemediation.php +++ b/src/AbstractRemediation.php @@ -83,18 +83,22 @@ public function getConfig(string $name) } /** - * Retrieve remediation for some IP. + * Retrieve remediation and its origin for a given IP. + * + * @returns array + * [ + * 'remediation' => (string): the remediation to apply (ex: 'ban', 'captcha', 'bypass'), + * 'origin' => (string): the origin of the remediation (ex: 'CAPI', 'cscli') + * ] */ - abstract public function getIpRemediation(string $ip): string; + abstract public function getIpRemediation(string $ip): array; /** * @throws InvalidArgumentException */ public function getOriginsCount(): array { - $originsCountItem = $this->cacheStorage->getItem(AbstractCache::ORIGINS_COUNT); - - return $originsCountItem->isHit() ? $originsCountItem->get() : []; + return $this->getOriginsCountItem(); } /** @@ -113,15 +117,34 @@ public function pruneCache(): bool */ abstract public function refreshDecisions(): array; - private function handleDecisionOrigin(array $rawDecision): string + /** + * Updating the "origins count" metrics in cache is the responsibility of the bouncer. + * This method should be called by the bouncer after a remediation has been applied. + * + * @throws CacheException + * @throws InvalidArgumentException + */ + public function updateMetricsOriginsCount(string $origin, string $remediation, int $delta = 1): int { - $origin = $this->normalize($rawDecision['origin']); - if (Constants::ORIGIN_LISTS === $origin) { - // The existence of the $rawDecision['scenario'] must be guaranteed by the validateRawDecision method - $origin .= Constants::ORIGIN_LISTS_SEPARATOR . $this->normalize($rawDecision['scenario']); - } + $cacheOriginCount = $this->getOriginsCountItem(); + $count = isset($cacheOriginCount[$origin][$remediation]) ? + (int) $cacheOriginCount[$origin][$remediation] : + 0; - return $origin; + $count += $delta; + $finalCount = max(0, $count); + $this->cacheStorage->upsertItem( + AbstractCache::ORIGINS_COUNT, + [ + $origin => [ + $remediation => $finalCount, + ], + ], + 0, + [AbstractCache::ORIGINS_COUNT] + ); + + return $finalCount; } protected function convertRawDecision(array $rawDecision): ?Decision @@ -185,7 +208,6 @@ protected function getAllCachedDecisions(string $ip, string $country): array * @throws CacheStorageException * @throws InvalidArgumentException * @throws RemediationException - * @throws \Symfony\Component\Cache\Exception\InvalidArgumentException */ protected function getCountryForIp(string $ip): string { @@ -208,74 +230,13 @@ protected function getIpType(string $ip): int } /** - * @deprecated since 3.2.0 . Will be removed in 4.0.0. Use handleRemediationFromDecisions instead. - * - * @codeCoverageIgnore - */ - protected function getRemediationFromDecisions(array $decisions): string - { - $cleanDecisions = $this->cacheStorage->cleanCachedValues($decisions); - - $sortedDecisions = $this->sortDecisionsByPriority($cleanDecisions); - $this->logger->debug('Decisions have been sorted by priority', [ - 'type' => 'REM_SORTED_DECISIONS', - 'decisions' => $sortedDecisions, - ]); - - // Return only a remediation with the highest priority - return $sortedDecisions[0][AbstractCache::INDEX_MAIN] ?? Constants::REMEDIATION_BYPASS; - } - - /** - * @deprecated since 3.4.0 . Will be removed in 4.0.0. Use private retrieveRemediationFromCachedDecisions instead. - * - * @codeCoverageIgnore + * Converts durations like 3h24m59.5565s, 3h24m5957ms, 149h, etc. in seconds. */ - protected function handleRemediationFromDecisions(array $cacheFormattedDecisions): array - { - return $this->retrieveRemediationFromCachedDecisions($cacheFormattedDecisions); - } - - private function retrieveRemediationFromCachedDecisions(array $cacheDecisions): array - { - $cleanDecisions = $this->cacheStorage->cleanCachedValues($cacheDecisions); - $sortedDecisions = $this->sortDecisionsByPriority($cleanDecisions); - $this->logger->debug('Decisions have been sorted by priority', [ - 'type' => 'REM_SORTED_DECISIONS', - 'decisions' => $sortedDecisions, - ]); - - // Return only a remediation with the highest priority - return [ - self::INDEX_REM => $sortedDecisions[0][AbstractCache::INDEX_MAIN] ?? Constants::REMEDIATION_BYPASS, - self::INDEX_ORIGIN => $sortedDecisions[0][AbstractCache::INDEX_ORIGIN] ?? '', - ]; - } - - /** - * Retrieve only the remediation with the highest priority from decisions. - * It also updates the origin count if needed. - * - * @throws CacheException - * @throws InvalidArgumentException - */ - protected function processCachedDecisions(array $cacheDecisions): string - { - $remediationData = $this->retrieveRemediationFromCachedDecisions($cacheDecisions); - if (!empty($remediationData[self::INDEX_ORIGIN])) { - $this->updateRemediationOriginCount((string) $remediationData[self::INDEX_ORIGIN]); - } - - return $remediationData[self::INDEX_REM]; - } - protected function parseDurationToSeconds(string $duration): int { - /** - * 3h24m59.5565s or 3h24m5957ms or 149h, etc. - */ - $re = '/(-?)(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)(?:\.\d+)?(m?)s)?/m'; + $re = '/(-)?(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)(\.\d+)?s)?(?:(\d+)ms)?/'; preg_match($re, $duration, $matches); + if (empty($matches[0])) { $this->logger->error('An error occurred during duration parsing', [ 'type' => 'REM_DECISION_DURATION_PARSE_ERROR', @@ -284,28 +245,61 @@ protected function parseDurationToSeconds(string $duration): int return 0; } + $seconds = 0; - if (isset($matches[2])) { - $seconds += ((int) $matches[2]) * 3600; // hours + // Parse hours + if (!empty($matches[2])) { + $seconds += ((int) $matches[2]) * 3600; + } + + // Parse minutes + if (!empty($matches[3])) { + $seconds += ((int) $matches[3]) * 60; } - if (isset($matches[3])) { - $seconds += ((int) $matches[3]) * 60; // minutes + + // Parse seconds + if (!empty($matches[4])) { + $seconds += (int) $matches[4]; } - $secondsPart = 0; - if (isset($matches[4])) { - $secondsPart += ((int) $matches[4]); // seconds + + // Parse fractional seconds + if (!empty($matches[5])) { + $seconds += (float) $matches[5]; } - if (isset($matches[5]) && 'm' === $matches[5]) { // units in milliseconds - $secondsPart *= 0.001; + + // Parse milliseconds + if (!empty($matches[6])) { + $seconds += ((int) $matches[6]) * 0.001; } - $seconds += $secondsPart; - if ('-' === $matches[1]) { // negative + + // Handle negative durations + if ('-' === $matches[1]) { $seconds *= -1; } return (int) round($seconds); } + /** + * Retrieve only the remediation with the highest priority from decisions. + * + * It will remove expired decisions. + * It will use fallback for unknown remediation. + * It will cap the remediation level if needed. + */ + protected function processCachedDecisions(array $cacheDecisions): array + { + $remediationData = $this->retrieveRemediationFromCachedDecisions($cacheDecisions); + $origin = !empty($remediationData[self::INDEX_ORIGIN]) ? (string) $remediationData[self::INDEX_ORIGIN] : ''; + $remediation = !empty($remediationData[self::INDEX_REM]) ? (string) $remediationData[self::INDEX_REM] : + Constants::REMEDIATION_BYPASS; + + return [ + Constants::REMEDIATION_KEY => $remediation, + Constants::ORIGIN_KEY => $origin, + ]; + } + /** * Remove decisions from cache. * @@ -336,38 +330,6 @@ protected function removeDecisions(array $decisions): array ]; } - /** - * Sort the decision array of a cache item, by remediation priorities. - * - * @deprecated since 3.2.0 . Will be removed in 4.0.0 (Replaced by private method sortDecisionsByPriority) - */ - protected function sortDecisionsByRemediationPriority(array $decisions): array - { - if (!$decisions) { - return $decisions; - } - // Add priorities - $orderedRemediations = (array) $this->getConfig('ordered_remediations'); - $fallback = $this->getConfig('fallback_remediation'); - $decisionsWithPriority = []; - foreach ($decisions as $decision) { - $priority = array_search($decision[AbstractCache::INDEX_MAIN], $orderedRemediations); - // Use fallback for unknown remediation - if (false === $priority) { - $priority = array_search($fallback, $orderedRemediations); - $decision[AbstractCache::INDEX_MAIN] = $fallback; - } - $decision[self::INDEX_PRIO] = $priority; - $decisionsWithPriority[] = $decision; - } - // Sort by priorities. - /** @var callable $compareFunction */ - $compareFunction = self::class . '::comparePriorities'; - usort($decisionsWithPriority, $compareFunction); - - return $decisionsWithPriority; - } - /** * Add decisions in cache. * If decisions are already in cache, result will be [AbstractCache::DONE => 0, AbstractCache::STORED => []]. @@ -406,25 +368,53 @@ protected function storeDecisions(array $decisions): array } /** - * @throws CacheException - * @throws InvalidArgumentException + * Cap the remediation to a fixed value given by the bouncing level configuration. + * + * @param string $remediation (ex: 'ban', 'captcha', 'bypass') + * + * @return string $remediation The resulting remediation to use (ex: 'ban', 'captcha', 'bypass') */ - protected function updateRemediationOriginCount(string $origin): int + private function capRemediationLevel(string $remediation): string { - $originCountItem = $this->cacheStorage->getItem(AbstractCache::ORIGINS_COUNT); - $cacheOriginCount = $originCountItem->isHit() ? $originCountItem->get() : []; - $count = isset($cacheOriginCount[$origin]) ? - (int) $cacheOriginCount[$origin] : - 0; + if (Constants::REMEDIATION_BYPASS === $remediation) { + return Constants::REMEDIATION_BYPASS; + } - $this->cacheStorage->upsertItem( - AbstractCache::ORIGINS_COUNT, - [$origin => ++$count], - 0, - [AbstractCache::ORIGINS_COUNT] + $orderedRemediations = (array) $this->getConfig('ordered_remediations'); + + $bouncingLevel = $this->getConfig('bouncing_level') ?? Constants::BOUNCING_LEVEL_NORMAL; + // Compute max remediation level + switch ($bouncingLevel) { + case Constants::BOUNCING_LEVEL_DISABLED: + $maxRemediationLevel = Constants::REMEDIATION_BYPASS; + break; + case Constants::BOUNCING_LEVEL_FLEX: + $maxRemediationLevel = Constants::REMEDIATION_CAPTCHA; + break; + case Constants::BOUNCING_LEVEL_NORMAL: + default: + $maxRemediationLevel = Constants::REMEDIATION_BAN; + break; + } + + $currentIndex = (int) array_search($remediation, $orderedRemediations); + $maxIndex = (int) array_search( + $maxRemediationLevel, + $orderedRemediations ); + $finalRemediation = $remediation; + if ($currentIndex < $maxIndex) { + $finalRemediation = $orderedRemediations[$maxIndex]; + $this->logger->debug('Original remediation has been capped', [ + 'origin' => $remediation, + 'final' => $finalRemediation, + ]); + } + $this->logger->info('Final remediation', [ + 'remediation' => $finalRemediation, + ]); - return $count; + return $finalRemediation; } /** @@ -445,6 +435,16 @@ private static function comparePriorities(array $a, array $b): int return ($a < $b) ? -1 : 1; } + /** + * @throws InvalidArgumentException + */ + private function getOriginsCountItem(): array + { + $originsCountItem = $this->cacheStorage->getItem(AbstractCache::ORIGINS_COUNT); + + return $originsCountItem->isHit() ? (array) $originsCountItem->get() : []; + } + private function handleDecisionExpiresAt(string $type, string $duration): int { $duration = $this->parseDurationToSeconds($duration); @@ -462,17 +462,48 @@ private function handleDecisionIdentifier( string $value ): string { return - $origin . Decision::ID_SEP . + $this->normalize($origin) . Decision::ID_SEP . $type . Decision::ID_SEP . $scope . Decision::ID_SEP . $value; } + private function handleDecisionOrigin(array $rawDecision): string + { + $origin = $rawDecision['origin']; + if (Constants::ORIGIN_LISTS === $origin) { + // The existence of the $rawDecision['scenario'] must be guaranteed by the validateRawDecision method + $origin .= Constants::ORIGIN_LISTS_SEPARATOR . $rawDecision['scenario']; + } + + return $origin; + } + private function normalize(string $value): string { return strtolower($value); } + private function retrieveRemediationFromCachedDecisions(array $cacheDecisions): array + { + $cleanDecisions = $this->cacheStorage->cleanCachedValues($cacheDecisions); + $sortedDecisions = $this->sortDecisionsByPriority($cleanDecisions); + $this->logger->debug('Decisions have been sorted by priority', [ + 'type' => 'REM_SORTED_DECISIONS', + 'decisions' => $sortedDecisions, + ]); + // Keep only a remediation with the highest priority + $highestRemediation = $sortedDecisions[0][AbstractCache::INDEX_MAIN] ?? Constants::REMEDIATION_BYPASS; + $origin = $sortedDecisions[0][AbstractCache::INDEX_ORIGIN] ?? ''; + // Cap the remediation level + $cappedRemediation = $this->capRemediationLevel($highestRemediation); + + return [ + self::INDEX_REM => $cappedRemediation, + self::INDEX_ORIGIN => Constants::REMEDIATION_BYPASS === $cappedRemediation ? AbstractCache::CLEAN : $origin, + ]; + } + /** * Sort the decision array of a cache item, by remediation priorities, using fallback if needed. */ diff --git a/src/CacheStorage/AbstractCache.php b/src/CacheStorage/AbstractCache.php index 5d5c96a..832651c 100644 --- a/src/CacheStorage/AbstractCache.php +++ b/src/CacheStorage/AbstractCache.php @@ -28,6 +28,8 @@ abstract class AbstractCache public const DEFER = 'deferred'; /** @var string Internal name for effective saved cache item (not deferred) */ public const DONE = 'done'; + /** @var string Internal name for first lapi call config item */ + public const FIRST_LAPI_CALL = 'first_lapi_call'; /** @var string The cache key prefix or tag for a geolocation */ public const GEOLOCATION = 'geolocation'; /** @var int Cache item content array expiration index */ @@ -40,6 +42,8 @@ abstract class AbstractCache public const INDEX_ORIGIN = 3; /** @var string The cache key prefix for a IPV4 range bucket */ public const IPV4_BUCKET_KEY = 'range_bucket_ipv4'; + /** @var string Internal name for last metrics sent timestamp */ + public const LAST_METRICS_SENT = 'last_metrics_sent'; /** @var string Internal name for last pull */ public const LAST_PULL = 'last_pull'; /** @var string Internal name for list */ @@ -360,6 +364,7 @@ public function unsetIpVariables(string $cacheScope, array $names, string $ip, i /** * Create or update an item; Only passed content is updated. + * With this method, we can only add or update keys to the content, not remove them. * * @throws InvalidArgumentException|CacheException */ diff --git a/src/CapiRemediation.php b/src/CapiRemediation.php index 9ebcefd..a377711 100644 --- a/src/CapiRemediation.php +++ b/src/CapiRemediation.php @@ -46,20 +46,21 @@ public function getClient(): Watcher * @throws InvalidArgumentException * @throws RemediationException|CacheException */ - public function getIpRemediation(string $ip): string + public function getIpRemediation(string $ip): array { + $clean = [ + Constants::REMEDIATION_KEY => Constants::REMEDIATION_BYPASS, + Constants::ORIGIN_KEY => AbstractCache::CLEAN, + ]; $cachedDecisions = $this->getAllCachedDecisions($ip, $this->getCountryForIp($ip)); - if (!$cachedDecisions) { $this->logger->debug('There is no cached decision', [ 'type' => 'CAPI_REM_NO_CACHED_DECISIONS', 'ip' => $ip, ]); - - $this->updateRemediationOriginCount(AbstractCache::CLEAN); - // As CAPI is always in stream_mode, we do not store this bypass - return Constants::REMEDIATION_BYPASS; + + return $clean; } return $this->processCachedDecisions($cachedDecisions); @@ -83,7 +84,7 @@ private function convertRawCapiDecisionsToDecisions(array $rawDecisions): array } $capiDecision['scope'] = $scope; $capiDecision['type'] = Constants::REMEDIATION_BAN; - $capiDecision['origin'] = Constants::ORIGIN_CAPI; + $capiDecision['origin'] = strtoupper(Constants::ORIGIN_CAPI); // CrowdSec convention is CAPI $decision = $this->convertRawDecision($capiDecision); if ($decision) { $decisions[] = $decision; diff --git a/src/Configuration/AbstractRemediation.php b/src/Configuration/AbstractRemediation.php index 3ba0180..3665933 100644 --- a/src/Configuration/AbstractRemediation.php +++ b/src/Configuration/AbstractRemediation.php @@ -27,6 +27,7 @@ abstract class AbstractRemediation extends AbstractConfiguration protected $keys = [ 'fallback_remediation', 'ordered_remediations', + 'bouncing_level', 'stream_mode', 'clean_ip_cache_duration', 'bad_ip_cache_duration', @@ -53,6 +54,16 @@ protected function addCommonNodes($rootNode) ->scalarNode('fallback_remediation') ->defaultValue(Constants::REMEDIATION_BYPASS) ->end() + ->enumNode('bouncing_level') + ->values( + [ + Constants::BOUNCING_LEVEL_DISABLED, + Constants::BOUNCING_LEVEL_NORMAL, + Constants::BOUNCING_LEVEL_FLEX, + ] + ) + ->defaultValue(Constants::BOUNCING_LEVEL_NORMAL) + ->end() ->arrayNode('ordered_remediations')->cannotBeEmpty() ->validate() ->ifArray() diff --git a/src/Configuration/Capi.php b/src/Configuration/Capi.php index 2b9cd03..21fedb8 100644 --- a/src/Configuration/Capi.php +++ b/src/Configuration/Capi.php @@ -27,6 +27,7 @@ class Capi extends AbstractRemediation 'fallback_remediation', 'ordered_remediations', 'stream_mode', + 'bouncing_level', 'clean_ip_cache_duration', 'bad_ip_cache_duration', 'geolocation', diff --git a/src/Configuration/Lapi.php b/src/Configuration/Lapi.php index f703ed2..a8bc85c 100644 --- a/src/Configuration/Lapi.php +++ b/src/Configuration/Lapi.php @@ -27,6 +27,7 @@ class Lapi extends AbstractRemediation 'fallback_remediation', 'ordered_remediations', 'stream_mode', + 'bouncing_level', 'clean_ip_cache_duration', 'bad_ip_cache_duration', 'geolocation', diff --git a/src/Constants.php b/src/Constants.php index 5e2f252..2077245 100644 --- a/src/Constants.php +++ b/src/Constants.php @@ -34,6 +34,18 @@ class Constants extends CommonConstants * @var int The default maximum body size for AppSec requests (in KB) */ public const APPSEC_DEFAULT_MAX_BODY_SIZE = 1024; + /** + * @var string The "disabled" bouncing level + */ + public const BOUNCING_LEVEL_DISABLED = 'bouncing_disabled'; + /** + * @var string The "flex" bouncing level + */ + public const BOUNCING_LEVEL_FLEX = 'flex_bouncing'; + /** + * @var string The "normal" bouncing level + */ + public const BOUNCING_LEVEL_NORMAL = 'normal_bouncing'; /** * @var int The default duration we keep a bad IP in cache (in seconds) */ @@ -58,16 +70,24 @@ class Constants extends CommonConstants * @var string The Maxmind "Country" database type */ public const MAXMIND_COUNTRY = 'country'; + /** + * @var string The key to get the origin from getIpRemediation + */ + public const ORIGIN_KEY = 'origin'; + /** + * @var string The separator between the origin and scenario for the stored origin + */ + public const ORIGIN_LISTS_SEPARATOR = ':'; /** * @var int The default refresh frequency (in seconds) */ public const REFRESH_FREQUENCY = 14400; /** - * @var string The current version of this library + * @var string The key to get the remediation from getIpRemediation */ - public const VERSION = 'v3.5.0'; + public const REMEDIATION_KEY = 'remediation'; /** - * @var string The separator between the origin and scenario for the stored origin + * @var string The current version of this library */ - public const ORIGIN_LISTS_SEPARATOR = ':'; + public const VERSION = 'v3.6.0'; } diff --git a/src/LapiRemediation.php b/src/LapiRemediation.php index 709169a..957a4b7 100644 --- a/src/LapiRemediation.php +++ b/src/LapiRemediation.php @@ -6,6 +6,7 @@ use CrowdSec\LapiClient\Bouncer; use CrowdSec\LapiClient\ClientException; +use CrowdSec\LapiClient\Constants as LapiConstants; use CrowdSec\LapiClient\TimeoutException; use CrowdSec\RemediationEngine\CacheStorage\AbstractCache; use CrowdSec\RemediationEngine\CacheStorage\CacheStorageException; @@ -41,19 +42,104 @@ public function __construct( parent::__construct($this->configs, $cacheStorage, $logger); } + /** + * This method aims to be used synchronously in the remediation process, + * after a call to the getIpRemediation method. + * We don't ask for cached LAPI decisions, as it is done by the getIpRemediation method. + * If you want to use this method alone, you should call the getAllCachedDecisions method before. + * + * @throws CacheException + * @throws ClientException + * @throws InvalidArgumentException + */ + public function getAppSecRemediation(array $headers, string $rawBody = ''): array + { + $clean = [ + Constants::REMEDIATION_KEY => Constants::REMEDIATION_BYPASS, + Constants::ORIGIN_KEY => AbstractCache::CLEAN_APPSEC, + ]; + if (!$this->validateAppSecHeaders($headers)) { + return $clean; + } + if (!$this->validateRawBody($rawBody)) { + $action = $this->getConfig('appsec_body_size_exceeded_action') ?? Constants::APPSEC_ACTION_HEADERS_ONLY; + $this->logger->debug('Action to be taken if maximum size is exceeded', [ + 'type' => 'LAPI_REM_APPSEC_BODY_SIZE_EXCEEDED', + 'action' => $action, + ]); + switch ($action) { + case Constants::APPSEC_ACTION_BLOCK: + return [ + Constants::REMEDIATION_KEY => Constants::REMEDIATION_BAN, + Constants::ORIGIN_KEY => Constants::ORIGIN_APPSEC, + ]; + case Constants::APPSEC_ACTION_ALLOW: + return [ + Constants::REMEDIATION_KEY => $clean[Constants::REMEDIATION_KEY], + Constants::ORIGIN_KEY => $clean[Constants::ORIGIN_KEY], + ]; + // Default to headers only action + default: + $rawBody = ''; + break; + } + } + try { + $rawAppSecDecision = $this->client->getAppSecDecision($headers, $rawBody); + } catch (TimeoutException $e) { + $this->logger->error('Timeout while retrieving AppSec decision', [ + 'type' => 'LAPI_REM_APPSEC_TIMEOUT', + 'exception' => $e, + ]); + + // Early return for AppSec fallback remediation + $remediation = $this->getConfig('appsec_fallback_remediation') ?? Constants::REMEDIATION_BYPASS; + $origin = Constants::REMEDIATION_BYPASS === $remediation ? + $clean[Constants::ORIGIN_KEY] : + Constants::ORIGIN_APPSEC; + + return [ + Constants::REMEDIATION_KEY => $remediation, + Constants::ORIGIN_KEY => $origin, + ]; + } + $rawRemediation = $this->parseAppSecDecision($rawAppSecDecision); + if (Constants::REMEDIATION_BYPASS === $rawRemediation) { + return $clean; + } + // We only set required indexes for the processCachedDecisions method + $fakeCachedDecisions = [[ + AbstractCache::INDEX_MAIN => $rawRemediation, + AbstractCache::INDEX_ORIGIN => Constants::ORIGIN_APPSEC, + ]]; + + return $this->processCachedDecisions($fakeCachedDecisions); + } + public function getClient(): Bouncer { return $this->client; } /** + * Retrieve the remediation and its origin for a given IP. + * + * It will first check the cache for the IP decisions. + * If no decisions are found, it will call LAPI to get the decisions. + * The decisions are then stored in the cache. + * The remediation is then processed and returned. + * * @throws CacheStorageException * @throws InvalidArgumentException * @throws RemediationException * @throws CacheException|ClientException */ - public function getIpRemediation(string $ip): string + public function getIpRemediation(string $ip): array { + $clean = [ + Constants::REMEDIATION_KEY => Constants::REMEDIATION_BYPASS, + Constants::ORIGIN_KEY => AbstractCache::CLEAN, + ]; $country = $this->getCountryForIp($ip); $cachedDecisions = $this->getAllCachedDecisions($ip, $country); $this->logger->debug('Cache result', [ @@ -64,11 +150,10 @@ public function getIpRemediation(string $ip): string if (!$cachedDecisions) { // In stream_mode, we do not store this bypass, and we do not call LAPI directly if ($this->getConfig('stream_mode')) { - $this->updateRemediationOriginCount(AbstractCache::CLEAN); - - return Constants::REMEDIATION_BYPASS; + return $clean; } // In live mode, ask LAPI (Retrieve Ip AND Range scoped decisions) + $this->storeFirstCall(time()); $rawIpDecisions = $this->client->getFilteredDecisions(['ip' => $ip]); $ipDecisions = $this->convertRawDecisionsToDecisions($rawIpDecisions); // IPV6 range scoped decisions are not yet stored in cache, so we store it as IP scoped decisions @@ -99,113 +184,83 @@ public function getIpRemediation(string $ip): string return $this->processCachedDecisions($cachedDecisions); } - private function validateAppSecHeaders(array $headers): bool - { - if ( - empty($headers[Constants::HEADER_APPSEC_IP]) - || empty($headers[Constants::HEADER_APPSEC_URI]) - || empty($headers[Constants::HEADER_APPSEC_VERB]) - ) { - $this->logger->error('Missing or empty required AppSec header', [ - 'type' => 'LAPI_REM_APPSEC_MISSING_HEADER', - 'headers' => $headers, - ]); - - return false; - } - - return true; - } - - private function parseAppSecDecision(array $rawAppSecDecision): string - { - if (!isset($rawAppSecDecision['action'])) { - return Constants::REMEDIATION_BYPASS; - } - - return Constants::APPSEC_ACTION_ALLOW === $rawAppSecDecision['action'] ? - Constants::REMEDIATION_BYPASS : - $rawAppSecDecision['action']; - } - - private function validateRawBody(string $rawBody): bool - { - // rawBody length is in bytes, so we convert the max size in bytes - $maxBodySize = $this->getConfig('appsec_max_body_size_kb') * 1024; - $rawBodySize = strlen($rawBody); - - if ($rawBodySize > $maxBodySize) { - $this->logger->warning('Request body size exceeded', [ - 'type' => 'LAPI_REM_APPSEC_BODY_SIZE_EXCEEDED', - 'size' => $rawBodySize, - 'max_size' => $maxBodySize, - ]); - - return false; - } - - return true; - } - /** - * This method aims to be used synchronously in the remediation process, - * after a call to the getIpRemediation method. - * We don't ask for cached LAPI decisions, as it is done by the getIpRemediation method. - * If you want to use this method alone, you should call the getAllCachedDecisions method before. + * Push usage metrics to LAPI. + * + * The metrics are built from the cache and then sent to LAPI. + * The cache is then updated to reflect the metrics sent. + * Returns the metrics items sent to LAPI. * * @throws CacheException * @throws ClientException * @throws InvalidArgumentException */ - public function getAppSecRemediation(array $headers, string $rawBody = ''): string - { - if (!$this->validateAppSecHeaders($headers)) { - return Constants::REMEDIATION_BYPASS; - } - if (!$this->validateRawBody($rawBody)) { - $action = $this->getConfig('appsec_body_size_exceeded_action') ?? Constants::APPSEC_ACTION_HEADERS_ONLY; - $this->logger->debug('Action to be taken if maximum size is exceeded', [ - 'type' => 'LAPI_REM_APPSEC_BODY_SIZE_EXCEEDED', - 'action' => $action, - ]); - switch ($action) { - case Constants::APPSEC_ACTION_BLOCK: - return Constants::REMEDIATION_BAN; - case Constants::APPSEC_ACTION_ALLOW: - return Constants::REMEDIATION_BYPASS; - // Default to headers only action - default: - $rawBody = ''; - break; - } - } - try { - $rawAppSecDecision = $this->client->getAppSecDecision($headers, $rawBody); - } catch (TimeoutException $e) { - $this->logger->error('Timeout while retrieving AppSec decision', [ - 'type' => 'LAPI_REM_APPSEC_TIMEOUT', - 'exception' => $e, + public function pushUsageMetrics( + string $bouncerName, + string $bouncerVersion, + string $bouncerType = LapiConstants::METRICS_TYPE + ): array { + $cacheConfigItem = $this->cacheStorage->getItem(AbstractCache::CONFIG); + $cacheConfig = $cacheConfigItem->isHit() ? $cacheConfigItem->get() : []; + $start = $cacheConfig[AbstractCache::FIRST_LAPI_CALL] ?? 0; + $now = time(); + $lastSent = $cacheConfig[AbstractCache::LAST_METRICS_SENT] ?? $start; + // Updating the "origins count" metrics in cache is the responsibility of the bouncer. + $originsCount = $this->getOriginsCount(); + $build = $this->buildMetricsItems($originsCount); + $metricsItems = $build['items'] ?? []; + $originsToUpdate = $build['origins'] ?? []; + if (empty($metricsItems)) { + $this->logger->info('No metrics to send', [ + 'type' => 'LAPI_REM_NO_METRICS', ]); - // Early return for AppSec fallback remediation - return $this->getConfig('appsec_fallback_remediation') ?? Constants::REMEDIATION_BYPASS; + return []; } - $rawRemediation = $this->parseAppSecDecision($rawAppSecDecision); - if (Constants::REMEDIATION_BYPASS === $rawRemediation) { - $this->updateRemediationOriginCount(AbstractCache::CLEAN_APPSEC); - return Constants::REMEDIATION_BYPASS; + $properties = [ + 'name' => $bouncerName, + 'type' => $bouncerType, + 'version' => $bouncerVersion, + 'utc_startup_timestamp' => $start, + ]; + $meta = [ + 'window_size_seconds' => max(0, $now - $lastSent), + 'utc_now_timestamp' => $now, + ]; + $this->logger->debug('Metrics to build', [ + 'type' => 'LAPI_REM_METRICS', + 'items' => $metricsItems, + 'properties' => $properties, + 'meta' => $meta, + ]); + + $metrics = $this->client->buildUsageMetrics($properties, $meta, $metricsItems); + + $this->client->pushUsageMetrics($metrics); + + // Decrement the count of each origin/remediation + foreach ($originsToUpdate as $origin => $remediationCount) { + foreach ($remediationCount as $remediation => $delta) { + // We update the count of each origin/remediation, one by one + // because we want to handle the case where an origin/remediation/count has been updated + // between the time we get the count and the time we update it + // $delta is negative, so we decrement the count + $this->updateMetricsOriginsCount($origin, $remediation, $delta); + } } - // We only set required indexes for the processCachedDecisions method - $fakeCachedDecisions = [[ - AbstractCache::INDEX_MAIN => $rawRemediation, - AbstractCache::INDEX_ORIGIN => Constants::ORIGIN_APPSEC, - ]]; - return $this->processCachedDecisions($fakeCachedDecisions); + $this->storeMetricsLastSent($now); + + return $metrics; } /** + * Refresh the decisions from LAPI. + * + * This method is only available in stream mode. + * Depending on the warmup status, it will either process a startup or a regular refresh. + * * @SuppressWarnings(PHPMD.BooleanArgumentFlag) * * @throws CacheException @@ -235,6 +290,46 @@ public function refreshDecisions(): array return $this->getStreamDecisions(false, $filter); } + private function buildMetricsItems(array $originsCount): array + { + $metricsItems = []; + $processed = 0; + $originsToUpdate = []; + foreach ($originsCount as $origin => $remediationCount) { + foreach ($remediationCount as $remediation => $count) { + if ($count <= 0) { + continue; + } + // Count all processed metrics, even bypass ones + $processed += $count; + // Prepare data to update origins count item after processing + $originsToUpdate[$origin][$remediation] = -$count; + if (Constants::REMEDIATION_BYPASS === $remediation) { + continue; + } + // Create "dropped" metrics (all that is not a bypass) + $metricsItems[] = [ + 'name' => 'dropped', + 'value' => $count, + 'unit' => 'request', + 'labels' => [ + 'origin' => $origin, + 'remediation' => $remediation, + ], + ]; + } + } + if ($processed > 0) { + $metricsItems[] = [ + 'name' => 'processed', + 'value' => $processed, + 'unit' => 'request', + ]; + } + + return ['items' => $metricsItems, 'origins' => $originsToUpdate]; + } + /** * Process and validate input configurations. */ @@ -245,6 +340,18 @@ private function configure(array $configs): void $this->configs = $processor->processConfiguration($configuration, [$configuration->cleanConfigs($configs)]); } + /** + * @throws CacheException + * @throws InvalidArgumentException + */ + private function getFirstCall(): int + { + $cacheConfigItem = $this->cacheStorage->getItem(AbstractCache::CONFIG); + $cacheConfig = $cacheConfigItem->isHit() ? $cacheConfigItem->get() : []; + + return $cacheConfig[AbstractCache::FIRST_LAPI_CALL] ?? 0; + } + private function getScopes(): array { if (null === $this->scopes) { @@ -269,6 +376,7 @@ private function getScopes(): array */ private function getStreamDecisions(bool $startup = false, array $filter = []): array { + $this->storeFirstCall(time()); $rawDecisions = $this->client->getStreamDecisions($startup, $filter); $newDecisions = $this->convertRawDecisionsToDecisions($rawDecisions[self::CS_NEW] ?? []); $deletedDecisions = $this->convertRawDecisionsToDecisions($rawDecisions[self::CS_DEL] ?? []); @@ -318,6 +426,104 @@ private function isWarm(): bool && true === $cacheConfig[AbstractCache::WARMUP]; } + private function parseAppSecDecision(array $rawAppSecDecision): string + { + if (!isset($rawAppSecDecision['action'])) { + return Constants::REMEDIATION_BYPASS; + } + + return Constants::APPSEC_ACTION_ALLOW === $rawAppSecDecision['action'] ? + Constants::REMEDIATION_BYPASS : + $rawAppSecDecision['action']; + } + + /** + * @throws CacheException + * @throws InvalidArgumentException + */ + private function storeFirstCall(int $timestamp): void + { + $firstCall = $this->getFirstCall(); + if (0 !== $firstCall) { + return; + } + $content = [AbstractCache::FIRST_LAPI_CALL => $timestamp]; + $this->logger->info( + 'Flag LAPI first call', + [ + 'type' => 'LAPI_REM_CACHE_FIRST_CALL', + 'time' => $timestamp, + ] + ); + + $this->cacheStorage->upsertItem( + AbstractCache::CONFIG, + $content, + 0, + [AbstractCache::CONFIG] + ); + } + + /** + * @throws CacheException + * @throws InvalidArgumentException + */ + private function storeMetricsLastSent(int $timestamp): void + { + $content = [AbstractCache::LAST_METRICS_SENT => $timestamp]; + $this->logger->debug( + 'Flag metrics last sent', + [ + 'type' => 'LAPI_REM_CACHE_METRICS_LAST_SENT', + 'time' => $timestamp, + ] + ); + + $this->cacheStorage->upsertItem( + AbstractCache::CONFIG, + $content, + 0, + [AbstractCache::CONFIG] + ); + } + + private function validateAppSecHeaders(array $headers): bool + { + if ( + empty($headers[Constants::HEADER_APPSEC_IP]) + || empty($headers[Constants::HEADER_APPSEC_URI]) + || empty($headers[Constants::HEADER_APPSEC_VERB]) + ) { + $this->logger->error('Missing or empty required AppSec header', [ + 'type' => 'LAPI_REM_APPSEC_MISSING_HEADER', + 'headers' => $headers, + ]); + + return false; + } + + return true; + } + + private function validateRawBody(string $rawBody): bool + { + // rawBody length is in bytes, so we convert the max size in bytes + $maxBodySize = $this->getConfig('appsec_max_body_size_kb') * 1024; + $rawBodySize = strlen($rawBody); + + if ($rawBodySize > $maxBodySize) { + $this->logger->warning('Request body size exceeded', [ + 'type' => 'LAPI_REM_APPSEC_BODY_SIZE_EXCEEDED', + 'size' => $rawBodySize, + 'max_size' => $maxBodySize, + ]); + + return false; + } + + return true; + } + /** * @throws ClientException * @throws InvalidArgumentException diff --git a/tests/Constants.php b/tests/Constants.php index 12eaab3..b3237a1 100644 --- a/tests/Constants.php +++ b/tests/Constants.php @@ -24,6 +24,7 @@ class Constants public const IP_V4_2 = '5.6.7.8'; public const IP_V4_3 = '9.10.11.12'; public const IP_V4_4 = '12.13.14.15'; + public const IP_V4_5 = '16.17.18.19'; public const IP_V4_2_CACHE_KEY = RemConstants::SCOPE_IP . AbstractCache::SEP . self::IP_V4_2; /* * 66051 = intdiv(ip2long(IP_V4),256) diff --git a/tests/Unit/AbstractRemediation.php b/tests/Unit/AbstractRemediation.php index a8a82ae..842039f 100644 --- a/tests/Unit/AbstractRemediation.php +++ b/tests/Unit/AbstractRemediation.php @@ -61,7 +61,7 @@ protected function getBouncerMock() { return $this->getMockBuilder('CrowdSec\LapiClient\Bouncer') ->disableOriginalConstructor() - ->onlyMethods(['getStreamDecisions', 'getFilteredDecisions', 'getAppSecDecision']) + ->onlyMethods(['getStreamDecisions', 'getFilteredDecisions', 'getAppSecDecision', 'pushUsageMetrics']) ->getMock(); } diff --git a/tests/Unit/AppSecLapiRemediationTest.php b/tests/Unit/AppSecLapiRemediationTest.php index 9db9b57..623f92c 100644 --- a/tests/Unit/AppSecLapiRemediationTest.php +++ b/tests/Unit/AppSecLapiRemediationTest.php @@ -60,12 +60,13 @@ * @uses \CrowdSec\RemediationEngine\CacheStorage\Memcached::getItem * @uses \CrowdSec\RemediationEngine\Configuration\AbstractCache::addCommonNodes * - * @covers \CrowdSec\RemediationEngine\AbstractRemediation::handleRemediationFromDecisions * @covers \CrowdSec\RemediationEngine\AbstractRemediation::getOriginsCount * * @uses \CrowdSec\RemediationEngine\AbstractRemediation::sortDecisionsByPriority + * @uses \CrowdSec\RemediationEngine\AbstractRemediation::capRemediationLevel + * @uses \CrowdSec\RemediationEngine\AbstractRemediation::getOriginsCountItem * - * @covers \CrowdSec\RemediationEngine\AbstractRemediation::updateRemediationOriginCount + * @covers \CrowdSec\RemediationEngine\AbstractRemediation::updateMetricsOriginsCount * @covers \CrowdSec\RemediationEngine\AbstractRemediation::getCacheStorage * @covers \CrowdSec\RemediationEngine\LapiRemediation::handleIpV6RangeDecisions * @covers \CrowdSec\RemediationEngine\AbstractRemediation::getIpType @@ -82,7 +83,6 @@ * @covers \CrowdSec\RemediationEngine\AbstractRemediation::getConfig * @covers \CrowdSec\RemediationEngine\LapiRemediation::getIpRemediation * @covers \CrowdSec\RemediationEngine\LapiRemediation::storeDecisions - * @covers \CrowdSec\RemediationEngine\LapiRemediation::sortDecisionsByRemediationPriority * @covers \CrowdSec\RemediationEngine\LapiRemediation::refreshDecisions * @covers \CrowdSec\RemediationEngine\LapiRemediation::getStreamDecisions * @covers \CrowdSec\RemediationEngine\Configuration\Capi::getConfigTreeBuilder @@ -249,11 +249,13 @@ public function testGetAppSecRemediation($cacheType) ['action' => 'allow', 'http_status' => 200], // Test 0.4 : request with no body (headers only test) ['action' => 'allow', 'http_status' => 200], // Test 1 : clean request ['action' => 'ban', 'http_status' => 403], // Test 2 : ban request - ['action' => 'unknown', 'http_status' => 403], // Test 3 : unknown request - ['action' => 'unknown', 'http_status' => 403], // Test 4 : unknown request with captcha fallback + ['action' => 'unknown', 'http_status' => 403], // Test 3 : unknown remediation + ['action' => 'unknown', 'http_status' => 403], // Test 4 : unknown remediation with captcha fallback $this->throwException(new TimeoutException('Test timeout exception')), // Test 5 : exception $this->throwException(new TimeoutException('Test timeout exception')), // Test 6 : exception - ['key' => 'value'] // Test 7 : response with no action + $this->throwException(new TimeoutException('Test timeout exception')), // Test 7 : exception with + // bypass fallback + ['key' => 'value'] // Test 8 : response with no action ) ); @@ -276,129 +278,200 @@ public function testGetAppSecRemediation($cacheType) $result = $remediation->getAppSecRemediation($appSecHeaders, ''); $this->assertEquals( Constants::REMEDIATION_BYPASS, - $result, + $result['remediation'], 'Bad header should early return a bypass remediation' ); + // We simulate what a bouncer should do: update clean_appsec/bypass count + $remediation->updateMetricsOriginsCount('clean_appsec', 'bypass'); + $originsCount = $remediation->getOriginsCount(); + $this->assertEquals( + ['clean_appsec' => ['bypass' => 1]], + $originsCount, + 'Origins count should be empty' + ); $appSecHeaders[Constants::HEADER_APPSEC_IP] = '1.2.3.4'; // Test 0.2: exceeded body and allow action $remediationConfigs = ['appsec_body_size_exceeded_action' => 'allow', 'appsec_max_body_size_kb' => 1024]; $remediation = new LapiRemediation($remediationConfigs, $this->bouncer, $this->cacheStorage, null); $result = $remediation->getAppSecRemediation($appSecHeaders, str_repeat('a', 1024 * 1024 + 1)); + // We simulate what a bouncer should do: update clean_appsec/bypass count + $remediation->updateMetricsOriginsCount('clean_appsec', 'bypass'); $this->assertEquals( Constants::REMEDIATION_BYPASS, - $result, + $result['remediation'], 'Exceeded body size should return a bypass remediation' ); + $originsCount = $remediation->getOriginsCount(); + $this->assertEquals( + ['clean_appsec' => ['bypass' => 2]], + $originsCount, + 'Origins count should be empty' + ); // Test 0.3: exceeded body and block action $remediationConfigs = ['appsec_body_size_exceeded_action' => 'block', 'appsec_max_body_size_kb' => 12]; $remediation = new LapiRemediation($remediationConfigs, $this->bouncer, $this->cacheStorage, null); $result = $remediation->getAppSecRemediation($appSecHeaders, str_repeat('a', 12 * 1024 + 1)); + // We simulate what a bouncer should do: update appsec/ban count + $remediation->updateMetricsOriginsCount('appsec', 'ban'); $this->assertEquals( Constants::REMEDIATION_BAN, - $result, + $result['remediation'], 'Exceeded body size should return a ban remediation' ); - // Test 0.4: exceeded body and headers only action - $remediationConfigs = ['appsec_body_size_exceeded_action' => 'headers_only', 'appsec_max_body_size_kb' => 1024]; - $remediation = new LapiRemediation($remediationConfigs, $this->bouncer, $this->cacheStorage, null); $originsCount = $remediation->getOriginsCount(); $this->assertEquals( - [], + ['clean_appsec' => ['bypass' => 2], 'appsec' => ['ban' => 1]], $originsCount, 'Origins count should be empty' ); + // Test 0.4: exceeded body and headers only action + $remediationConfigs = ['appsec_body_size_exceeded_action' => 'headers_only', 'appsec_max_body_size_kb' => 1024]; + $remediation = new LapiRemediation($remediationConfigs, $this->bouncer, $this->cacheStorage, null); $result = $remediation->getAppSecRemediation($appSecHeaders, str_repeat('a', 1024 * 1024 + 1)); + // We simulate what a bouncer should do: update clean_appsec/bypass count + $remediation->updateMetricsOriginsCount('clean_appsec', 'bypass'); $this->assertEquals( Constants::REMEDIATION_BYPASS, - $result, + $result['remediation'], 'Request with headers only should return a bypass remediation here' ); $originsCount = $remediation->getOriginsCount(); $this->assertEquals( - ['clean_appsec' => 1], + ['clean_appsec' => ['bypass' => 3], 'appsec' => ['ban' => 1]], $originsCount, - 'Origin count should be cached' + 'Origins count should be empty' ); // Test 1 (AppSec response: clean request) $remediationConfigs = []; $remediation = new LapiRemediation($remediationConfigs, $this->bouncer, $this->cacheStorage, null); $result = $remediation->getAppSecRemediation($appSecHeaders, str_repeat('a', 256)); + // We simulate what a bouncer should do: update clean_appsec/bypass count + $remediation->updateMetricsOriginsCount('clean_appsec', 'bypass'); $this->assertEquals( Constants::REMEDIATION_BYPASS, - $result, + $result['remediation'], 'Clean request should return a bypass remediation' ); $originsCount = $remediation->getOriginsCount(); $this->assertEquals( - ['clean_appsec' => 2], + ['clean_appsec' => ['bypass' => 4], 'appsec' => ['ban' => 1]], $originsCount, - 'Origin count should be cached' + 'Origins count should be empty' ); // Test 2 (AppSec response: bad request) $result = $remediation->getAppSecRemediation($appSecHeaders, ''); + // We simulate what a bouncer should do: update appsec/ban count + $remediation->updateMetricsOriginsCount('appsec', 'ban'); $this->assertEquals( Constants::REMEDIATION_BAN, - $result, + $result['remediation'], 'Bad request should return a ban remediation' ); $originsCount = $remediation->getOriginsCount(); $this->assertEquals( - ['clean_appsec' => 2, 'appsec' => 1], + ['clean_appsec' => ['bypass' => 4], 'appsec' => ['ban' => 2]], $originsCount, 'Origin count should be cached' ); - // Test 3 (AppSec response: unknown request) + // Test 3 (AppSec response: unknown remediation) $result = $remediation->getAppSecRemediation($appSecHeaders, ''); + // We simulate what a bouncer should do: update clean_appsec/bypass count + $remediation->updateMetricsOriginsCount('clean_appsec', 'bypass'); $this->assertEquals( Constants::REMEDIATION_BYPASS, - $result, - 'Unknown request should return a bypass (fallback) remediation' + $result['remediation'], + 'Unknown remediation should return a bypass (fallback) remediation' ); $originsCount = $remediation->getOriginsCount(); $this->assertEquals( - ['clean_appsec' => 2, 'appsec' => 2], + ['clean_appsec' => ['bypass' => 5], 'appsec' => ['ban' => 2]], $originsCount, - 'Origin count should be cached (original appsec response was not a bypass, so it does not increase clean_appsec counter)' + 'Origin count should be cached (original appsec response was not a bypass. + But as the result is a bypass, it increases clean_appsec counter)' ); - // Test 4 (AppSec response: unknown request with captcha fallback) + // Test 4 (AppSec response: unknown remediation with captcha fallback) $remediationConfigs = ['fallback_remediation' => Constants::REMEDIATION_CAPTCHA]; $remediation = new LapiRemediation($remediationConfigs, $this->bouncer, $this->cacheStorage, $this->logger); $result = $remediation->getAppSecRemediation($appSecHeaders, ''); + // We simulate what a bouncer should do: update appsec/captcha count + $remediation->updateMetricsOriginsCount('appsec', 'captcha'); $this->assertEquals( Constants::REMEDIATION_CAPTCHA, - $result, + $result['remediation'], 'Unknown request should return a captcha (fallback) remediation' ); $originsCount = $remediation->getOriginsCount(); $this->assertEquals( - ['clean_appsec' => 2, 'appsec' => 3], + ['clean_appsec' => ['bypass' => 5], 'appsec' => ['ban' => 2, 'captcha' => 1]], $originsCount, - 'Origin count should be cached (original appsec response was not a bypass, so it does not increase clean_appsec counter)' + 'Origin count should be cached' ); // Test 5 (AppSec response: timeout) $result = $remediation->getAppSecRemediation($appSecHeaders, ''); $this->assertEquals( Constants::REMEDIATION_CAPTCHA, - $result, + $result['remediation'], 'Timeout should return a captcha remediation (default appsec fallback)') ; + // We simulate what a bouncer should do: update clean_appsec/bypass count + $remediation->updateMetricsOriginsCount('appsec', 'captcha'); + $originsCount = $remediation->getOriginsCount(); + $this->assertEquals( + ['clean_appsec' => ['bypass' => 5], 'appsec' => ['ban' => 2, 'captcha' => 2]], + $originsCount, + 'Origin count should be cached' + ); // Test 6 (AppSec response: timeout with configured fallback) $remediationConfigs = ['appsec_fallback_remediation' => Constants::REMEDIATION_BAN]; $remediation = new LapiRemediation($remediationConfigs, $this->bouncer, $this->cacheStorage, $this->logger); $result = $remediation->getAppSecRemediation($appSecHeaders, ''); + // We simulate what a bouncer should do: update appsec/ban count + $remediation->updateMetricsOriginsCount('appsec', 'ban'); $this->assertEquals( Constants::REMEDIATION_BAN, - $result, + $result['remediation'], 'Timeout should return a ban remediation (appsec_remediation_fallback setting)') ; - // Test 7 (AppSec response: no action) + $originsCount = $remediation->getOriginsCount(); + $this->assertEquals( + ['clean_appsec' => ['bypass' => 5], 'appsec' => ['ban' => 3, 'captcha' => 2]], + $originsCount, + 'Origin count should be cached' + ); + // test 7 (AppSec response: timeout with configured fallback to bypass) + $remediationConfigs = ['appsec_fallback_remediation' => Constants::REMEDIATION_BYPASS]; + $remediation = new LapiRemediation($remediationConfigs, $this->bouncer, $this->cacheStorage, $this->logger); + $result = $remediation->getAppSecRemediation($appSecHeaders, ''); + // We simulate what a bouncer should do: update clean_appsec/bypass count + $remediation->updateMetricsOriginsCount('clean_appsec', 'bypass'); + $this->assertEquals( + Constants::REMEDIATION_BYPASS, + $result['remediation'], + 'Timeout should return a bypass remediation (appsec_remediation_fallback setting)') + ; + $originsCount = $remediation->getOriginsCount(); + $this->assertEquals( + ['clean_appsec' => ['bypass' => 6], 'appsec' => ['ban' => 3, 'captcha' => 2]], + $originsCount, + 'Origin count should be cached (final response is a bypass, so it increase clean_appsec counter)' + ); + + // Test 8 (AppSec response: no action) $result = $remediation->getAppSecRemediation($appSecHeaders, ''); + // We simulate what a bouncer should do: update clean_appsec/bypass count + $remediation->updateMetricsOriginsCount('clean_appsec', 'bypass'); $this->assertEquals( Constants::REMEDIATION_BYPASS, - $result, + $result['remediation'], 'No action should return a bypass remediation' ); + $originsCount = $remediation->getOriginsCount(); + $this->assertEquals( + ['clean_appsec' => ['bypass' => 7], 'appsec' => ['ban' => 3, 'captcha' => 2]], + $originsCount, + 'Origin count should be cached (final response is a bypass, so it does increase clean_appsec counter)' + ); } protected function tearDown(): void diff --git a/tests/Unit/CapiRemediationTest.php b/tests/Unit/CapiRemediationTest.php index 07bde6e..b09ebb1 100644 --- a/tests/Unit/CapiRemediationTest.php +++ b/tests/Unit/CapiRemediationTest.php @@ -54,11 +54,7 @@ * @uses \CrowdSec\RemediationEngine\Configuration\AbstractRemediation::addGeolocationNodes * @uses \CrowdSec\RemediationEngine\AbstractRemediation::getCountryForIp * @uses \CrowdSec\RemediationEngine\Configuration\AbstractCache::addCommonNodes - * - * @covers \CrowdSec\RemediationEngine\AbstractRemediation::handleRemediationFromDecisions - * @covers \CrowdSec\RemediationEngine\AbstractRemediation::sortDecisionsByPriority - * - * @uses \CrowdSec\RemediationEngine\AbstractRemediation::updateRemediationOriginCount + * @uses \CrowdSec\RemediationEngine\AbstractRemediation::updateMetricsOriginsCount * * @covers \CrowdSec\RemediationEngine\AbstractRemediation::getCacheStorage * @@ -85,7 +81,6 @@ * @covers \CrowdSec\RemediationEngine\AbstractRemediation::getConfig * @covers \CrowdSec\RemediationEngine\CapiRemediation::getIpRemediation * @covers \CrowdSec\RemediationEngine\CapiRemediation::storeDecisions - * @covers \CrowdSec\RemediationEngine\CapiRemediation::sortDecisionsByRemediationPriority * @covers \CrowdSec\RemediationEngine\CapiRemediation::refreshDecisions * @covers \CrowdSec\RemediationEngine\Configuration\Capi::getConfigTreeBuilder * @covers \CrowdSec\RemediationEngine\AbstractRemediation::removeDecisions @@ -126,6 +121,10 @@ * @covers \CrowdSec\RemediationEngine\CapiRemediation::handleListResponse * @covers \CrowdSec\RemediationEngine\AbstractRemediation::processCachedDecisions * @covers \CrowdSec\RemediationEngine\AbstractRemediation::retrieveRemediationFromCachedDecisions + * @covers \CrowdSec\RemediationEngine\AbstractRemediation::sortDecisionsByPriority + * @covers \CrowdSec\RemediationEngine\AbstractRemediation::capRemediationLevel + * + * @uses \CrowdSec\RemediationEngine\AbstractRemediation::getOriginsCountItem */ final class CapiRemediationTest extends AbstractRemediation { @@ -375,7 +374,7 @@ public function testGetIpRemediation($cacheType) $result = $remediation->getIpRemediation(TestConstants::IP_V4); $this->assertEquals( Constants::REMEDIATION_BYPASS, - $result, + $result['remediation'], 'Uncached (clean) IP should return a bypass remediation' ); @@ -391,21 +390,21 @@ public function testGetIpRemediation($cacheType) $result = $remediation->getIpRemediation(TestConstants::IP_V4); $this->assertEquals( Constants::REMEDIATION_BYPASS, - $result, + $result['remediation'], 'Cached clean IP should return a bypass remediation' ); // Test 3 $result = $remediation->getIpRemediation(TestConstants::IP_V4); $this->assertEquals( Constants::REMEDIATION_BAN, - $result, + $result['remediation'], 'Remediations should be ordered by priority' ); // Test 4 $result = $remediation->getIpRemediation(TestConstants::IP_V4); $this->assertEquals( Constants::REMEDIATION_BYPASS, - $result, + $result['remediation'], 'Expired cached remediations should have been cleaned' ); } @@ -455,7 +454,7 @@ public function testPrivateOrProtectedMethods() 'Should have created a normalized scope' ); $this->assertEquals( - 'capi', + 'CAPI', $decision->getOrigin(), 'Should have created a normalized origin' ); @@ -493,7 +492,7 @@ public function testPrivateOrProtectedMethods() 'Should have created a normalized scope' ); $this->assertEquals( - 'capi', + 'CAPI', $decision->getOrigin(), 'Should have created a normalized origin' ); @@ -600,7 +599,7 @@ public function testPrivateOrProtectedMethods() $result, 'Should return 1' ); - // sortDecisionsByRemediationPriority + // sortDecisionsByPriority // Test 1 : default $decisions = [ [ @@ -616,7 +615,7 @@ public function testPrivateOrProtectedMethods() ]; $result = PHPUnitUtil::callMethod( $remediation, - 'sortDecisionsByRemediationPriority', + 'sortDecisionsByPriority', [$decisions] ); $this->assertEquals( @@ -644,7 +643,7 @@ public function testPrivateOrProtectedMethods() ]; $result = PHPUnitUtil::callMethod( $remediation, - 'sortDecisionsByRemediationPriority', + 'sortDecisionsByPriority', [$decisions] ); $this->assertEquals( @@ -667,7 +666,7 @@ public function testPrivateOrProtectedMethods() ]; $result = PHPUnitUtil::callMethod( $remediation, - 'sortDecisionsByRemediationPriority', + 'sortDecisionsByPriority', [$decisions] ); $this->assertEquals( @@ -679,7 +678,7 @@ public function testPrivateOrProtectedMethods() $decisions = []; $result = PHPUnitUtil::callMethod( $remediation, - 'sortDecisionsByRemediationPriority', + 'sortDecisionsByPriority', [$decisions] ); $this->assertCount( @@ -894,7 +893,7 @@ public function testPrivateOrProtectedMethods() $result = PHPUnitUtil::callMethod( $remediation, 'parseDurationToSeconds', - ['147h23m43000.5665ms'] + ['147h23m43.0005665s'] ); $this->assertEquals( 3600 * 147 + 23 * 60 + 43, @@ -922,6 +921,16 @@ public function testPrivateOrProtectedMethods() $result, 'Should convert in seconds' ); + $result = PHPUnitUtil::callMethod( + $remediation, + 'parseDurationToSeconds', + ['147h23m43000ms'] + ); + $this->assertEquals( + 530623, + $result, + 'Should convert in seconds' + ); $result = PHPUnitUtil::callMethod( $remediation, @@ -1039,6 +1048,37 @@ public function testPrivateOrProtectedMethods() ['If-Modified-Since' => 'Fri, 03 Mar 2023 00:00:00 GMT'], $result ); + + // capRemediationLevel + $result = PHPUnitUtil::callMethod( + $remediation, + 'capRemediationLevel', + ['ban'] + ); + $this->assertEquals('ban', $result, 'Remediation should be capped as ban'); + + $remediationConfigs = ['bouncing_level' => Constants::BOUNCING_LEVEL_DISABLED]; + $remediation = new CapiRemediation($remediationConfigs, $this->watcher, $this->cacheStorage, $this->logger); + + $result = PHPUnitUtil::callMethod( + $remediation, + 'capRemediationLevel', + ['ban'] + ); + $this->assertEquals('bypass', $result, 'Remediation should be capped as bypass'); + // We need to add the captcha in ordered_remediations to test the cap + $remediationConfigs = [ + 'bouncing_level' => Constants::BOUNCING_LEVEL_FLEX, + 'ordered_remediations' => ['ban', 'captcha', 'bypass'], + ]; + $remediation = new CapiRemediation($remediationConfigs, $this->watcher, $this->cacheStorage, $this->logger); + + $result = PHPUnitUtil::callMethod( + $remediation, + 'capRemediationLevel', + ['ban'] + ); + $this->assertEquals('captcha', $result, 'Remediation should be capped as captcha'); } /** diff --git a/tests/Unit/ConfigurationTest.php b/tests/Unit/ConfigurationTest.php index 6e2434a..fb006fb 100644 --- a/tests/Unit/ConfigurationTest.php +++ b/tests/Unit/ConfigurationTest.php @@ -73,6 +73,7 @@ public function testCapiConfiguration() ], ], 'refresh_frequency_indicator' => 14400, + 'bouncing_level' => 'normal_bouncing', ], $result, 'Should set default config' @@ -98,6 +99,7 @@ public function testCapiConfiguration() ], ], 'refresh_frequency_indicator' => 7200, + 'bouncing_level' => 'normal_bouncing', ], $result, 'Should set passed config' @@ -121,6 +123,7 @@ public function testCapiConfiguration() ], ], 'refresh_frequency_indicator' => 14400, + 'bouncing_level' => 'normal_bouncing', ], $result, 'Should add bypass with the lowest priority' @@ -143,6 +146,7 @@ public function testCapiConfiguration() ], ], 'refresh_frequency_indicator' => 14400, + 'bouncing_level' => 'normal_bouncing', ], $result, 'Should add bypass with the lowest priority' @@ -166,6 +170,7 @@ public function testCapiConfiguration() ], ], 'refresh_frequency_indicator' => 14400, + 'bouncing_level' => 'normal_bouncing', ], $result, 'Should normalize config' @@ -230,6 +235,7 @@ public function testLapiConfiguration() 'appsec_fallback_remediation' => 'captcha', 'appsec_max_body_size_kb' => 1024, 'appsec_body_size_exceeded_action' => 'headers_only', + 'bouncing_level' => 'normal_bouncing', ], $result, 'Should set default config' @@ -257,6 +263,7 @@ public function testLapiConfiguration() 'appsec_fallback_remediation' => 'captcha', 'appsec_max_body_size_kb' => 1024, 'appsec_body_size_exceeded_action' => 'headers_only', + 'bouncing_level' => 'normal_bouncing', ], $result, 'Should set stream mode false' @@ -283,6 +290,7 @@ public function testLapiConfiguration() 'appsec_fallback_remediation' => 'rem1', 'appsec_max_body_size_kb' => 1024, 'appsec_body_size_exceeded_action' => 'headers_only', + 'bouncing_level' => 'normal_bouncing', ], $result, 'Should add bypass with the lowest priority' @@ -307,6 +315,7 @@ public function testLapiConfiguration() 'appsec_fallback_remediation' => 'rem4', 'appsec_max_body_size_kb' => 1024, 'appsec_body_size_exceeded_action' => 'headers_only', + 'bouncing_level' => 'normal_bouncing', ], $result, 'Should add bypass with the lowest priority' @@ -332,6 +341,7 @@ public function testLapiConfiguration() 'appsec_fallback_remediation' => 'captcha', 'appsec_max_body_size_kb' => 1024, 'appsec_body_size_exceeded_action' => 'headers_only', + 'bouncing_level' => 'normal_bouncing', ], $result, 'Should normalize config' @@ -405,6 +415,7 @@ public function testLapiConfiguration() 'appsec_fallback_remediation' => 'bar', 'appsec_max_body_size_kb' => 2048, 'appsec_body_size_exceeded_action' => 'block', + 'bouncing_level' => 'normal_bouncing', ], $result, 'Should set custom config' diff --git a/tests/Unit/LapiRemediationTest.php b/tests/Unit/LapiRemediationTest.php index aa96eee..1fef20a 100644 --- a/tests/Unit/LapiRemediationTest.php +++ b/tests/Unit/LapiRemediationTest.php @@ -62,13 +62,16 @@ * @uses \CrowdSec\RemediationEngine\CacheStorage\Memcached::getItem * @uses \CrowdSec\RemediationEngine\Configuration\AbstractCache::addCommonNodes * - * @covers \CrowdSec\RemediationEngine\AbstractRemediation::handleRemediationFromDecisions * @covers \CrowdSec\RemediationEngine\AbstractRemediation::getOriginsCount * * @uses \CrowdSec\RemediationEngine\AbstractRemediation::sortDecisionsByPriority - * @covers \CrowdSec\RemediationEngine\AbstractRemediation::handleDecisionOrigin * - * @covers \CrowdSec\RemediationEngine\AbstractRemediation::updateRemediationOriginCount + * @covers \CrowdSec\RemediationEngine\AbstractRemediation::capRemediationLevel + * @covers \CrowdSec\RemediationEngine\AbstractRemediation::getOriginsCountItem + * @covers \CrowdSec\RemediationEngine\LapiRemediation::getFirstCall + * @covers \CrowdSec\RemediationEngine\LapiRemediation::storeFirstCall + * @covers \CrowdSec\RemediationEngine\AbstractRemediation::handleDecisionOrigin + * @covers \CrowdSec\RemediationEngine\AbstractRemediation::updateMetricsOriginsCount * @covers \CrowdSec\RemediationEngine\AbstractRemediation::getCacheStorage * @covers \CrowdSec\RemediationEngine\LapiRemediation::handleIpV6RangeDecisions * @covers \CrowdSec\RemediationEngine\AbstractRemediation::getIpType @@ -85,7 +88,6 @@ * @covers \CrowdSec\RemediationEngine\AbstractRemediation::getConfig * @covers \CrowdSec\RemediationEngine\LapiRemediation::getIpRemediation * @covers \CrowdSec\RemediationEngine\LapiRemediation::storeDecisions - * @covers \CrowdSec\RemediationEngine\LapiRemediation::sortDecisionsByRemediationPriority * @covers \CrowdSec\RemediationEngine\LapiRemediation::refreshDecisions * @covers \CrowdSec\RemediationEngine\LapiRemediation::getStreamDecisions * @covers \CrowdSec\RemediationEngine\Configuration\Capi::getConfigTreeBuilder @@ -131,6 +133,9 @@ * @covers \CrowdSec\RemediationEngine\Configuration\Lapi::validateAppSec * @covers \CrowdSec\RemediationEngine\AbstractRemediation::processCachedDecisions * @covers \CrowdSec\RemediationEngine\AbstractRemediation::retrieveRemediationFromCachedDecisions + * @covers \CrowdSec\RemediationEngine\LapiRemediation::buildMetricsItems + * @covers \CrowdSec\RemediationEngine\LapiRemediation::pushUsageMetrics + * @covers \CrowdSec\RemediationEngine\LapiRemediation::storeMetricsLastSent */ final class LapiRemediationTest extends AbstractRemediation { @@ -360,13 +365,13 @@ public function testGetIpRemediationInStreamMode($cacheType) 'ban', 999999999999, 'capi-ban-ip-1.2.3.4', - 'capi', + 'CAPI', ]]], // Test 3 : retrieve ban for range [AbstractCache::STORED => [[ 'ban', 311738199, // Sunday 18 November 1979 'capi-ban-ip-1.2.3.4', - 'capi', + 'CAPI', ]]], // Test 4 : retrieve expired ban ip [AbstractCache::STORED => []] // Test 4 : retrieve empty range ) @@ -375,7 +380,7 @@ public function testGetIpRemediationInStreamMode($cacheType) $result = $remediation->getIpRemediation(TestConstants::IP_V4); $this->assertEquals( Constants::REMEDIATION_BYPASS, - $result, + $result['remediation'], 'Uncached (clean) IP should return a bypass remediation' ); @@ -391,21 +396,21 @@ public function testGetIpRemediationInStreamMode($cacheType) $result = $remediation->getIpRemediation(TestConstants::IP_V4); $this->assertEquals( Constants::REMEDIATION_BYPASS, - $result, + $result['remediation'], 'Cached clean IP should return a bypass remediation' ); // Test 3 $result = $remediation->getIpRemediation(TestConstants::IP_V4); $this->assertEquals( Constants::REMEDIATION_BAN, - $result, + $result['remediation'], 'Remediations should be ordered by priority' ); // Test 4 $result = $remediation->getIpRemediation(TestConstants::IP_V4); $this->assertEquals( Constants::REMEDIATION_BYPASS, - $result, + $result['remediation'], 'Expired cached remediations should have been cleaned' ); } @@ -519,17 +524,12 @@ public function testGetIpRemediationInLiveMode($cacheType) $this->bouncer->expects($this->exactly(4))->method('getFilteredDecisions'); // Test 1 (No cached items and no active decision) - $originsCount = $remediation->getOriginsCount(); - $this->assertEquals( - [], - $originsCount, - 'Origins count should be empty' - ); + $result = $remediation->getIpRemediation(TestConstants::IP_V4); $this->assertEquals( Constants::REMEDIATION_BYPASS, - $result, + $result['remediation'], 'Uncached (clean) and with no active decision should return a bypass remediation' ); @@ -561,43 +561,54 @@ public function testGetIpRemediationInLiveMode($cacheType) $cachedItem[0][AbstractCache::INDEX_ORIGIN], 'Should return correct origin' ); - $originsCount = $remediation->getOriginsCount(); + + $item = $this->cacheStorage->getItem(AbstractCache::CONFIG); $this->assertEquals( - ['clean' => 1], - $originsCount, - 'Origin count should be cached' + true, + $item->isHit(), + 'Config item should be cached' ); + $configItem = $item->get(); + $this->assertEqualsWithDelta( + [ + AbstractCache::FIRST_LAPI_CALL => time(), + ], + $configItem, + 1000, // 1 second delta to avoid false negative + 'Config cache item should be as expected' + ); + $originalFirstCall = $configItem[AbstractCache::FIRST_LAPI_CALL]; + sleep(1); // To test that first LAPI call is cached and do not change // Test 2 (cached decisions) $result = $remediation->getIpRemediation(TestConstants::IP_V4); $this->assertEquals( Constants::REMEDIATION_BYPASS, - $result, + $result['remediation'], 'Cached (clean) should return a bypass remediation' ); - $originsCount = $remediation->getOriginsCount(); + + // Additional tests + $item = $adapter->getItem(base64_encode(AbstractCache::CONFIG)); $this->assertEquals( - ['clean' => 2], - $originsCount, - 'Clean count should be 2' + true, + $item->isHit(), + 'First LAPI call should be cached' ); - // Test 3 (no cached decision and 2 actives IP decisions) - $this->cacheStorage->clear(); - $originsCount = $remediation->getOriginsCount(); + $finalConfigItem = $item->get(); + $finalFirstCall = $finalConfigItem[AbstractCache::FIRST_LAPI_CALL]; $this->assertEquals( - [], - $originsCount, - 'Origin count should not be cached' + $originalFirstCall, + $finalFirstCall, + 'First LAPI call should be the same as at beginning' ); + // Test 3 (no cached decision and 2 actives IP decisions) + $this->cacheStorage->clear(); + $result = $remediation->getIpRemediation(TestConstants::IP_V4); - $originsCount = $remediation->getOriginsCount(); - $this->assertEquals( - ['lapi' => 1], - $originsCount, - 'Origin count should be cached' - ); + $this->assertEquals( Constants::REMEDIATION_BAN, - $result, + $result['remediation'], 'Should return a ban remediation' ); $item = $adapter->getItem(base64_encode(TestConstants::IP_V4_CACHE_KEY)); @@ -609,56 +620,405 @@ public function testGetIpRemediationInLiveMode($cacheType) $result = $remediation->getIpRemediation(TestConstants::IP_V6); $this->assertEquals( Constants::REMEDIATION_BAN, - $result, + $result['remediation'], 'Should return a ban remediation' ); $item = $adapter->getItem(base64_encode(RemConstants::SCOPE_IP . AbstractCache::SEP . TestConstants::IP_V6_CACHE_KEY)); $cachedItem = $item->get(); $this->assertCount(1, $cachedItem, 'Should have cache 1 decisions for IP'); $this->assertEquals($cachedItem[0][0], 'ban', 'Should be a ban'); + + // Test 5 : merge origins count + $remediation->getIpRemediation(TestConstants::IP_V4); + + // Test 5 bis : merge origins count + $remediation->getIpRemediation(TestConstants::IP_V4); + + // Test 6 : origin lists + $result = $remediation->getIpRemediation(TestConstants::IP_V4_4); + $this->assertEquals( + Constants::REMEDIATION_BAN, + $result['remediation'], + 'Should return a ban remediation' + ); + } + + /** + * @dataProvider cacheTypeProvider + */ + public function testPushUsageMetricsInLiveMode($cacheType) + { + $this->setCache($cacheType); + $remediationConfigs = ['stream_mode' => false]; + // Prepare next tests + $currentTime = time(); + $this->cacheStorage->method('retrieveDecisionsForIp')->will( + // We simulate that cache never contains any decision + $this->onConsecutiveCalls( + [AbstractCache::STORED => []], // Test 1 / Call1 : retrieve empty IP decisions + [AbstractCache::STORED => []], // Test 1 / Call1 : retrieve empty range decisions + [AbstractCache::STORED => []], // Test 1 / Call2 : retrieve empty IP decisions + [AbstractCache::STORED => []], // Test 1 / Call2 : retrieve empty range decisions + [AbstractCache::STORED => []], // Test 1 / Call3 : retrieve empty IP decisions + [AbstractCache::STORED => []], // Test 1 / Call3 : retrieve empty range decisions + [AbstractCache::STORED => []], // Test 2 / Call1 : retrieve empty IP decisions + [AbstractCache::STORED => []], // Test 2 / Call1 : retrieve empty range decisions + [AbstractCache::STORED => []], // Test 3 / Call1 : retrieve empty IP decisions + [AbstractCache::STORED => []], // Test 3 / Call1 : retrieve empty range decisions + [AbstractCache::STORED => []], // Test 3 / Call2 : retrieve empty IP decisions + [AbstractCache::STORED => []] // Test 3 / Call2 : retrieve empty range decisions + ) + ); + $this->bouncer->method('getFilteredDecisions')->will( + $this->onConsecutiveCalls( + [], // Test 1 / Call1 : retrieve empty IP decisions (final metrics count will be a bypass) + [ + [ + 'scope' => 'ip', + 'value' => TestConstants::IP_V4, + 'type' => 'captcha', + 'origin' => 'cscli', + 'duration' => '1h', + ], + [ + 'scope' => 'ip', + 'value' => TestConstants::IP_V4, + 'type' => 'ban', + 'origin' => 'CAPI', + 'duration' => '1h', + ], // Test 1 / Call2 : retrieve ban and captcha (final metrics count will be a ban from CAPI) + ], + [ + [ + 'scope' => 'ip', + 'value' => TestConstants::IP_V4_2, + 'type' => 'captcha', + 'origin' => 'lists:tor', + 'duration' => '1h', + ], // Test 1 / Call3 : retrieve captcha (final metrics count will be a captcha from lists-tor) + ], + [ + [ + 'scope' => 'ip', + 'value' => TestConstants::IP_V4_3, + 'type' => 'captcha', + 'origin' => 'lists:tor', + 'duration' => '1h', + ], // Test 2 / Call1 : retrieve captcha (final metrics count will be a captcha from lists-tor) + ], + [ + [ + 'scope' => 'ip', + 'value' => TestConstants::IP_V4_4, + 'type' => 'captcha', + 'origin' => 'lists:tor', + 'duration' => '1h', + ], // Test 3 / Call1 : retrieve captcha (final metrics count will be a captcha from lists-tor) + ], + [ + [ + 'scope' => 'ip', + 'value' => TestConstants::IP_V4_5, + 'type' => 'captcha', + 'origin' => 'lists:tor', + 'duration' => '1h', + ], // Test 3 / Call2 : retrieve captcha (final metrics count will be a captcha from lists-tor) + ] + ) + ); + // Test 1 : push metrics + $remediation = new LapiRemediation($remediationConfigs, $this->bouncer, $this->cacheStorage, null); + // Call 1 + $remediation->getIpRemediation(TestConstants::IP_V4); + $item = $this->cacheStorage->getItem(AbstractCache::CONFIG); + $configItem = $item->get(); + $this->assertEqualsWithDelta( + [ + AbstractCache::FIRST_LAPI_CALL => $currentTime, + ], + $configItem, + 1, // 1 second delta to avoid false negative + 'First call should have been cached' + ); + $originalFirstCall = $configItem[AbstractCache::FIRST_LAPI_CALL]; + $this->assertArrayNotHasKey( + AbstractCache::LAST_METRICS_SENT, + $configItem, + 'Last sent Usage metrics should not be cached'); + // We simulate what a bouncer should do: update clean/bypass count + $remediation->updateMetricsOriginsCount('clean', 'bypass'); + // Call 2 + $remediation->getIpRemediation(TestConstants::IP_V4); + // We simulate what a bouncer should do: update CAPI/ban count + $remediation->updateMetricsOriginsCount('CAPI', 'ban'); $originsCount = $remediation->getOriginsCount(); $this->assertEquals( - ['lapi' => 1], + ['clean' => ['bypass' => 1], 'CAPI' => ['ban' => 1]], $originsCount, 'Origin count should be cached' ); - // Test 5 : merge origins count - $remediation->getIpRemediation(TestConstants::IP_V4); + // Call 3 + $remediation->getIpRemediation(TestConstants::IP_V4_2); + // We simulate what a bouncer should do: update lists:tor/captcha count + $remediation->updateMetricsOriginsCount('lists:tor', 'captcha'); $originsCount = $remediation->getOriginsCount(); $this->assertEquals( - ['clean' => 1, - 'lapi' => 1, + [ + 'clean' => ['bypass' => 1], + 'CAPI' => ['ban' => 1], + 'lists:tor' => ['captcha' => 1], ], $originsCount, - 'Origin count should be updated' + 'Origin count should be cached' ); - // Test 5 bis : merge origins count - $remediation->getIpRemediation(TestConstants::IP_V4); + + $result = $remediation->pushUsageMetrics('test-remediation-php-unit', 'v0.0.0', 'crowdsec-php-bouncer-unit-test'); + $this->assertArrayHasKey('remediation_components', $result, 'Should return a remediation_components key'); + $items = $result['remediation_components'][0]['metrics'][0]['items']; + + $this->assertEquals( + $items[0], + [ + 'name' => 'dropped', + 'value' => 1, + 'unit' => 'request', + 'labels' => [ + 'origin' => 'CAPI', + 'remediation' => 'ban', + ], + ], + 'Should have CAPI/ban metrics'. json_encode($items[0]) + ); + $this->assertEquals( + $items[1], + [ + 'name' => 'dropped', + 'value' => 1, + 'unit' => 'request', + 'labels' => [ + 'origin' => 'lists:tor', + 'remediation' => 'captcha', + ], + ], + 'Should have lists:tor/captcha metrics'. json_encode($items[1]) + ); + $this->assertEquals( + $items[2], + [ + 'name' => 'processed', + 'value' => 3, + 'unit' => 'request', + ], + 'Should have processed metrics'. json_encode($items[2]) + ); + $firstPushTime = time(); + $item = $this->cacheStorage->getItem(AbstractCache::CONFIG); + $configItem = $item->get(); + $this->assertEqualsWithDelta( + [ + AbstractCache::LAST_METRICS_SENT => $firstPushTime, + AbstractCache::FIRST_LAPI_CALL => $originalFirstCall, + ], + $configItem, + 1, // 1 second delta to avoid false negative + 'Last sent should have been cached' + ); + $originsCount = $remediation->getOriginsCount(); + $this->assertEquals( + [ + 'clean' => ['bypass' => 0], + 'CAPI' => ['ban' => 0], + 'lists:tor' => ['captcha' => 0], + ], + $originsCount, + 'Origin count should be reset' + ); + + // Test 2 : push metrics again after some delay + // Call 1 + sleep(1); + $remediation->getIpRemediation(TestConstants::IP_V4_3); + // We simulate what a bouncer should do: update lists:tor/captcha count + $remediation->updateMetricsOriginsCount('lists:tor', 'captcha'); $originsCount = $remediation->getOriginsCount(); $this->assertEquals( - ['clean' => 2, - 'lapi' => 1, + [ + 'clean' => ['bypass' => 0], + 'CAPI' => ['ban' => 0], + 'lists:tor' => ['captcha' => 1], ], $originsCount, 'Origin count should be updated' ); - // Test 6 : origin lists - $result = $remediation->getIpRemediation(TestConstants::IP_V4_4); + $secondPushTime = time(); + $result = $remediation->pushUsageMetrics('test-remediation-php-unit', 'v0.0.0', 'crowdsec-php-bouncer-unit-test'); + $item = $this->cacheStorage->getItem(AbstractCache::CONFIG); + $configItem = $item->get(); + $this->assertEqualsWithDelta( + [ + AbstractCache::LAST_METRICS_SENT => $secondPushTime, + AbstractCache::FIRST_LAPI_CALL => $originalFirstCall, + ], + $configItem, + 1, // 1 second delta to avoid false negative + 'Last sent should have been cached' + ); + $this->assertEqualsWithDelta( + 1, + $result['remediation_components'][0]['metrics'][0]['meta']['window_size_seconds'], + 1, // 1s to avoid false negative + 'window_size_seconds should be 1 seconds' + ); + $originsCount = $remediation->getOriginsCount(); $this->assertEquals( - Constants::REMEDIATION_BAN, - $result, - 'Should return a ban remediation' + [ + 'clean' => ['bypass' => 0], + 'CAPI' => ['ban' => 0], + 'lists:tor' => ['captcha' => 0], + ], + $originsCount, + 'Origin count should be reset' ); + // Test 3 : push metrics and concurrent getRemediationIp call + $remediation->getIpRemediation(TestConstants::IP_V4_4); + // We simulate what a bouncer should do: update lists:tor/captcha count + $remediation->updateMetricsOriginsCount('lists:tor', 'captcha'); $originsCount = $remediation->getOriginsCount(); $this->assertEquals( [ - 'lists:crowdsec_proxy' => 1, - 'clean' => 2, - 'lapi' => 1, + 'clean' => ['bypass' => 0], + 'CAPI' => ['ban' => 0], + 'lists:tor' => ['captcha' => 1], ], $originsCount, 'Origin count should be updated' ); + $thirdPushTime = time(); + // Trying to test simultaneous call + $result = $remediation->pushUsageMetrics('test-remediation-php-unit', 'v0.0.0', 'crowdsec-php-bouncer-unit-test'); + $remediation->getIpRemediation(TestConstants::IP_V4_5); + // We simulate what a bouncer should do: update lists:tor/captcha count + $remediation->updateMetricsOriginsCount('lists:tor', 'captcha'); + $originsCount = $remediation->getOriginsCount(); + $this->assertEquals( + [ + 'clean' => ['bypass' => 0], + 'CAPI' => ['ban' => 0], + 'lists:tor' => ['captcha' => 1], + ], + $originsCount, + 'Origin count should be updated with -1 + 1 (i.e same as before)' + ); + $item = $this->cacheStorage->getItem(AbstractCache::CONFIG); + $configItem = $item->get(); + $this->assertEqualsWithDelta( + [ + AbstractCache::LAST_METRICS_SENT => $thirdPushTime, + AbstractCache::FIRST_LAPI_CALL => $originalFirstCall, + ], + $configItem, + 1, // 1 second delta to avoid false negative + 'Last sent should have been cached' + ); + } + + /** + * @dataProvider cacheTypeProvider + */ + public function testPushUsageMetricsInStreamMode($cacheType) + { + $this->setCache($cacheType); + $remediationConfigs = ['stream_mode' => true]; + // Prepare next tests + $currentTime = time(); + $this->cacheStorage->method('retrieveDecisionsForIp')->will( + $this->onConsecutiveCalls( + [AbstractCache::STORED => [[ + 'bypass', + 999999999999, + 'clean-bypass-ip-' . TestConstants::IP_V4, + 'clean', + ]]], // Test 1/Call 1 : retrieve cached bypass + [AbstractCache::STORED => []], // Test 1/Call 1 : retrieve empty range + [AbstractCache::STORED => [[ + 'bypass', + 999999999999, + 'clean-bypass-ip-' . TestConstants::IP_V4, + 'clean', + ]]], // Test 1/Call 2 : retrieve cached bypass + [AbstractCache::STORED => []] // Test 1/Call 2 : retrieve empty range + ) + ); + $this->bouncer->method('getStreamDecisions')->will( + $this->onConsecutiveCalls( + MockedData::DECISIONS['new_ip_v4'] // Test 1 : new IP decision (ban) + ) + ); + + // Test 1 : push metrics + $remediation = new LapiRemediation($remediationConfigs, $this->bouncer, $this->cacheStorage, null); + // Call 1 + $remediation->refreshDecisions(); + $remediation->getIpRemediation(TestConstants::IP_V4); + // We simulate what a bouncer should do: update clean/bypass count + $remediation->updateMetricsOriginsCount('clean', 'bypass'); + $item = $this->cacheStorage->getItem(AbstractCache::CONFIG); + $configItem = $item->get(); + $this->assertEqualsWithDelta( + [ + AbstractCache::FIRST_LAPI_CALL => $currentTime, + AbstractCache::WARMUP => true, + ], + $configItem, + 1, // 1 second delta to avoid false negative + 'First call should have been cached' + ); + $originalFirstCall = $configItem[AbstractCache::FIRST_LAPI_CALL]; + $this->assertArrayNotHasKey( + AbstractCache::LAST_METRICS_SENT, + $configItem, + 'Last sent Usage metrics should not be cached'); + // Call 2 + $remediation->getIpRemediation(TestConstants::IP_V4); + // We simulate what a bouncer should do: update clean/bypass count + $remediation->updateMetricsOriginsCount('clean', 'bypass'); + $originsCount = $remediation->getOriginsCount(); + $this->assertEquals( + ['clean' => ['bypass' => 2]], + $originsCount, + 'Origin count should be cached' + ); + $result = $remediation->pushUsageMetrics('test-remediation-php-unit', 'v0.0.0', 'crowdsec-php-bouncer-unit-test'); + $this->assertArrayHasKey('remediation_components', $result, 'Should return a remediation_components key'); + + $firstPushTime = time(); + $item = $this->cacheStorage->getItem(AbstractCache::CONFIG); + $configItem = $item->get(); + $this->assertEqualsWithDelta( + [ + AbstractCache::LAST_METRICS_SENT => $firstPushTime, + AbstractCache::FIRST_LAPI_CALL => $originalFirstCall, + AbstractCache::WARMUP => true, + ], + $configItem, + 1, // 1 second delta to avoid false negative + 'Last sent should have been cached' + ); + $originsCount = $remediation->getOriginsCount(); + $this->assertEquals( + [ + 'clean' => ['bypass' => 0], + ], + $originsCount, + 'Origin count should be reset' + ); + // Test 2: nothing to send + $result = $remediation->pushUsageMetrics('test-remediation-php-unit', 'v0.0.0', 'crowdsec-php-bouncer-unit-test'); + $this->assertEquals( + [], + $result, + 'Should return an empty array' + ); } /** @@ -730,7 +1090,7 @@ public function testGetIpRemediationInLiveModeWithGeolocation($cacheType) $this->assertEquals( Constants::REMEDIATION_CAPTCHA, - $result, + $result['remediation'], 'Should return a captcha' ); @@ -830,7 +1190,7 @@ public function testGetIpRemediationInStreamModeWithGeolocation($cacheType) $this->assertEquals( Constants::REMEDIATION_BYPASS, - $result, + $result['remediation'], 'Uncached (clean) and with no active decision should return a bypass remediation' ); @@ -846,7 +1206,7 @@ public function testGetIpRemediationInStreamModeWithGeolocation($cacheType) $this->assertEquals( Constants::REMEDIATION_BAN, - $result, + $result['remediation'], 'Cached country ban should return ban' ); @@ -855,7 +1215,7 @@ public function testGetIpRemediationInStreamModeWithGeolocation($cacheType) $this->assertEquals( Constants::REMEDIATION_BAN, - $result, + $result['remediation'], 'Should return higshest priority' ); } @@ -929,10 +1289,14 @@ public function testRefreshDecisions($cacheType) $item->isHit(), 'Cached should be warmed up' ); - $this->assertEquals( - [AbstractCache::WARMUP => true], + $this->assertEqualsWithDelta( + [ + AbstractCache::WARMUP => true, + AbstractCache::FIRST_LAPI_CALL => time(), + ], $item->get(), - 'Warmup cache item should be as expected' + 1000, // 1 second delta to avoid false negative + 'Config cache item should be as expected' ); $adapter = $this->cacheStorage->getAdapter(); @@ -1182,10 +1546,20 @@ public function testRefreshDecisions($cacheType) $result = PHPUnitUtil::callMethod( $remediation, 'parseDurationToSeconds', - ['147h23m43000.5665ms'] + ['23m43s'] ); $this->assertEquals( - 3600 * 147 + 23 * 60 + 43, + 23 * 60 + 43, + $result, + 'Should convert in seconds' + ); + $result = PHPUnitUtil::callMethod( + $remediation, + 'parseDurationToSeconds', + ['-23m43s'] + ); + $this->assertEquals( + -23 * 60 - 43, $result, 'Should convert in seconds' ); @@ -1193,20 +1567,21 @@ public function testRefreshDecisions($cacheType) $result = PHPUnitUtil::callMethod( $remediation, 'parseDurationToSeconds', - ['23m43s'] + ['2h15m123456ms'] ); $this->assertEquals( - 23 * 60 + 43, + 8223, $result, 'Should convert in seconds' ); + $result = PHPUnitUtil::callMethod( $remediation, 'parseDurationToSeconds', - ['-23m43s'] + ['1h45m30.123456s'] ); $this->assertEquals( - -23 * 60 - 43, + 6330, $result, 'Should convert in seconds' ); @@ -1385,6 +1760,33 @@ public function testPrivateOrProtectedMethods() $decision->getOrigin(), 'Should have created a correct decision origin' ); + // capRemediationLevel + $result = PHPUnitUtil::callMethod( + $remediation, + 'capRemediationLevel', + ['ban'] + ); + $this->assertEquals('ban', $result, 'Remediation should be capped as ban'); + + $remediationConfigs = ['bouncing_level' => Constants::BOUNCING_LEVEL_DISABLED]; + $remediation = new LapiRemediation($remediationConfigs, $this->bouncer, $this->cacheStorage, $this->logger); + + $result = PHPUnitUtil::callMethod( + $remediation, + 'capRemediationLevel', + ['ban'] + ); + $this->assertEquals('bypass', $result, 'Remediation should be capped as bypass'); + + $remediationConfigs = ['bouncing_level' => Constants::BOUNCING_LEVEL_FLEX]; + $remediation = new LapiRemediation($remediationConfigs, $this->bouncer, $this->cacheStorage, $this->logger); + + $result = PHPUnitUtil::callMethod( + $remediation, + 'capRemediationLevel', + ['ban'] + ); + $this->assertEquals('captcha', $result, 'Remediation should be capped as captcha'); } protected function tearDown(): void diff --git a/tests/scripts/clear-cache-lapi.php b/tests/scripts/clear-cache-lapi.php index 39b6e1b..e276ea6 100644 --- a/tests/scripts/clear-cache-lapi.php +++ b/tests/scripts/clear-cache-lapi.php @@ -17,7 +17,7 @@ . \PHP_EOL); } // Init logger -$logger = new FileLog(['debug_mode' => true], 'remediation-engine-logger'); +$logger = new FileLog(['debug_mode' => true, 'log_directory_path' => __DIR__ . '/.logs'], 'remediation-engine-logger'); // Init client $clientConfigs = [ 'auth_type' => 'api_key', diff --git a/tests/scripts/get-remediation-lapi.php b/tests/scripts/get-remediation-lapi.php index 06593bd..8ed2885 100644 --- a/tests/scripts/get-remediation-lapi.php +++ b/tests/scripts/get-remediation-lapi.php @@ -28,7 +28,7 @@ } // Init logger -$logger = new FileLog(['debug_mode' => true], 'remediation-engine-logger'); +$logger = new FileLog(['debug_mode' => true, 'log_directory_path' => __DIR__ . '/.logs'], 'remediation-engine-logger'); // Init client $clientConfigs = [ 'auth_type' => 'api_key', diff --git a/tests/scripts/push-lapi-usage-metrics.php b/tests/scripts/push-lapi-usage-metrics.php new file mode 100644 index 0000000..5f61d4e --- /dev/null +++ b/tests/scripts/push-lapi-usage-metrics.php @@ -0,0 +1,60 @@ + ' . \PHP_EOL . + 'Example: php push-lapi-usage-metrics.php c580ebdff45da6e01415ed0e9bc9c06b https://crowdsec:8080' . + \PHP_EOL + ); +} +$bouncerKey = $argv[2] ?? false; +$lapiUrl = $argv[3] ?? false; +if (!$bouncerKey || !$lapiUrl) { + exit('Params and are required' . \PHP_EOL + . 'Usage: php push-lapi-usage-metrics.php ' + . \PHP_EOL); +} + +// Init logger +$logger = new FileLog(['debug_mode' => true, 'log_directory_path' => __DIR__ . '/.logs'], 'remediation-engine-logger'); +// Init client +$clientConfigs = [ + 'auth_type' => 'api_key', + 'api_url' => $lapiUrl, + 'api_key' => $bouncerKey, +]; +$lapiClient = new Bouncer($clientConfigs, null, $logger); + +// Init PhpFiles cache storage +$cacheFileConfigs = [ + 'fs_cache_path' => __DIR__ . '/.cache/lapi', +]; +$phpFileCache = new PhpFiles($cacheFileConfigs, $logger); +// Init Memcached cache storage +$cacheMemcachedConfigs = [ + 'memcached_dsn' => 'memcached://memcached:11211', +]; +$memcachedCache = new Memcached($cacheMemcachedConfigs, $logger); +// Init Redis cache storage +$cacheRedisConfigs = [ + 'redis_dsn' => 'redis://redis:6379', +]; +$redisCache = new Redis($cacheRedisConfigs, $logger); +// Init LAPI remediation +$remediationConfigs = []; +$remediationEngine = new LapiRemediation($remediationConfigs, $lapiClient, $phpFileCache, $logger); + +// Send usage metrics +$sentMetrics = $remediationEngine->pushUsageMetrics('test-remediation-php', 'v0.0.0', 'crowdsec-php-bouncer-test'); +echo 'Sent metrics: ' . json_encode($sentMetrics) . \PHP_EOL;