Skip to content

Commit

Permalink
feat(config): Add bouncing_level config and use it to cap remediation
Browse files Browse the repository at this point in the history
  • Loading branch information
julienloizelet committed Dec 20, 2024
1 parent 5dda9f3 commit 2d71abc
Show file tree
Hide file tree
Showing 8 changed files with 157 additions and 60 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ As far as possible, we try to adhere to [Symfony guidelines](https://symfony.com

**This release is not published yet.**

### Added

- Add `bouncing_level` configuration to cap maximum remediation level

### Changed

- For `lists` origin, store also the list name (scenario) in origins count cache item
Expand Down
162 changes: 110 additions & 52 deletions src/AbstractRemediation.php
Original file line number Diff line number Diff line change
Expand Up @@ -113,17 +113,6 @@ public function pruneCache(): bool
*/
abstract public function refreshDecisions(): array;

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;
}

protected function convertRawDecision(array $rawDecision): ?Decision
{
if (!$this->validateRawDecision($rawDecision)) {
Expand Down Expand Up @@ -236,39 +225,6 @@ protected function handleRemediationFromDecisions(array $cacheFormattedDecisions
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
{
/**
Expand All @@ -286,14 +242,14 @@ protected function parseDurationToSeconds(string $duration): int
}
$seconds = 0;
if (isset($matches[2])) {
$seconds += ((int) $matches[2]) * 3600; // hours
$seconds += ((int)$matches[2]) * 3600; // hours
}
if (isset($matches[3])) {
$seconds += ((int) $matches[3]) * 60; // minutes
$seconds += ((int)$matches[3]) * 60; // minutes
}
$secondsPart = 0;
if (isset($matches[4])) {
$secondsPart += ((int) $matches[4]); // seconds
$secondsPart += ((int)$matches[4]); // seconds
}
if (isset($matches[5]) && 'm' === $matches[5]) { // units in milliseconds
$secondsPart *= 0.001;
Expand All @@ -303,7 +259,28 @@ protected function parseDurationToSeconds(string $duration): int
$seconds *= -1;
}

return (int) round($seconds);
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.
*
* It also updates the origin count if possible.
*
* @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];
}

/**
Expand Down Expand Up @@ -347,7 +324,7 @@ protected function sortDecisionsByRemediationPriority(array $decisions): array
return $decisions;
}
// Add priorities
$orderedRemediations = (array) $this->getConfig('ordered_remediations');
$orderedRemediations = (array)$this->getConfig('ordered_remediations');
$fallback = $this->getConfig('fallback_remediation');
$decisionsWithPriority = [];
foreach ($decisions as $decision) {
Expand Down Expand Up @@ -414,7 +391,7 @@ protected function updateRemediationOriginCount(string $origin): int
$originCountItem = $this->cacheStorage->getItem(AbstractCache::ORIGINS_COUNT);
$cacheOriginCount = $originCountItem->isHit() ? $originCountItem->get() : [];
$count = isset($cacheOriginCount[$origin]) ?
(int) $cacheOriginCount[$origin] :
(int)$cacheOriginCount[$origin] :
0;

$this->cacheStorage->upsertItem(
Expand All @@ -427,6 +404,56 @@ protected function updateRemediationOriginCount(string $origin): int
return $count;
}

/**
* 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')
*/
private function capRemediationLevel(string $remediation): string
{
if ($remediation === Constants::REMEDIATION_BYPASS) {
return Constants::REMEDIATION_BYPASS;
}

$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 $finalRemediation;
}

/**
* Compare two priorities.
*
Expand All @@ -449,7 +476,7 @@ private function handleDecisionExpiresAt(string $type, string $duration): int
{
$duration = $this->parseDurationToSeconds($duration);
if (Constants::REMEDIATION_BYPASS !== $type && !$this->getConfig('stream_mode')) {
$duration = min((int) $this->getConfig('bad_ip_cache_duration'), $duration);
$duration = min((int)$this->getConfig('bad_ip_cache_duration'), $duration);
}

return time() + $duration;
Expand All @@ -468,11 +495,42 @@ private function handleDecisionIdentifier(
$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 => $cappedRemediation === Constants::REMEDIATION_BYPASS ? AbstractCache::CLEAN : $origin,
];
}

/**
* Sort the decision array of a cache item, by remediation priorities, using fallback if needed.
*/
Expand All @@ -482,7 +540,7 @@ private function sortDecisionsByPriority(array $decisions): array
return $decisions;
}
// Add priorities
$orderedRemediations = (array) $this->getConfig('ordered_remediations');
$orderedRemediations = (array)$this->getConfig('ordered_remediations');
$fallback = $this->getConfig('fallback_remediation');
$decisionsWithPriority = [];
foreach ($decisions as $decision) {
Expand Down
11 changes: 11 additions & 0 deletions src/Configuration/AbstractRemediation.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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()
Expand Down
12 changes: 12 additions & 0 deletions src/Constants.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
*/
Expand Down
9 changes: 5 additions & 4 deletions tests/Unit/AppSecLapiRemediationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -357,9 +357,10 @@ public function testGetAppSecRemediation($cacheType)
);
$originsCount = $remediation->getOriginsCount();
$this->assertEquals(
['clean_appsec' => 2, 'appsec' => 2],
['clean_appsec' => 2, 'appsec' => 1, 'clean' => 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 (original appsec response was not a bypass,
so it does not increase clean_appsec counter. But as the result is a bypass, it increases clean counter)'
);
// Test 4 (AppSec response: unknown request with captcha fallback)
$remediationConfigs = ['fallback_remediation' => Constants::REMEDIATION_CAPTCHA];
Expand All @@ -372,9 +373,9 @@ public function testGetAppSecRemediation($cacheType)
);
$originsCount = $remediation->getOriginsCount();
$this->assertEquals(
['clean_appsec' => 2, 'appsec' => 3],
['clean_appsec' => 2, 'appsec' => 2, 'clean' => 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 (final response is not a bypass, so it does not increase neither clean_appsec neither clean counter)'
);
// Test 5 (AppSec response: timeout)
$result = $remediation->getAppSecRemediation($appSecHeaders, '');
Expand Down
4 changes: 2 additions & 2 deletions tests/Unit/CapiRemediationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -455,7 +455,7 @@ public function testPrivateOrProtectedMethods()
'Should have created a normalized scope'
);
$this->assertEquals(
'capi',
'CAPI',
$decision->getOrigin(),
'Should have created a normalized origin'
);
Expand Down Expand Up @@ -493,7 +493,7 @@ public function testPrivateOrProtectedMethods()
'Should have created a normalized scope'
);
$this->assertEquals(
'capi',
'CAPI',
$decision->getOrigin(),
'Should have created a normalized origin'
);
Expand Down
Loading

0 comments on commit 2d71abc

Please sign in to comment.