Skip to content

Commit be00db2

Browse files
feat(*): Add "allow list" support for CAPI (#36)
* feat(allow list): Handle allow list decisions * feat(allow list): Use 20 years as infinite TTL * feat(remdiation): Early return a bypass if there is an allow list decision * test(*): Update test for private method * style(*): Pass through code format tools * test(*): Add code coverage * style(*): Rearrange code * docs(*): Prepare release 4.3.0
1 parent 8521f3c commit be00db2

17 files changed

+437
-90
lines changed

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,16 @@ The [public API](https://semver.org/spec/v2.0.0.html#spec-item-1) of this libra
1313
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.
1414

1515

16+
---
17+
18+
## [4.3.0](https://github.com/crowdsecurity/php-remediation-engine/releases/tag/v4.3.0) - 2025-04-??
19+
[_Compare with previous release_](https://github.com/crowdsecurity/php-remediation-engine/compare/v4.2.0...HEAD)
20+
21+
22+
### Added
23+
24+
- Add support for `Allowlists` decisions
25+
1626
---
1727

1828
## [4.2.0](https://github.com/crowdsecurity/php-remediation-engine/releases/tag/v4.2.0) - 2025-01-31

docs/USER_GUIDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ This kind of action is called a remediation and can be:
6464
- Handle Range scoped decisions for IPv4
6565
- Handle Country scoped decisions using [MaxMind](https://www.maxmind.com) database
6666
- Handle List decisions
67+
- Handle Allow list decisions for CAPI
6768
- Determine remediation for a given IP
6869
- Use the cached decisions for CAPI and for LAPI in stream mode
6970
- For LAPI in live mode, call LAPI if there is no cached decision

src/AbstractRemediation.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616

1717
abstract class AbstractRemediation
1818
{
19+
/** @var string The CrowdSec name for allowlist */
20+
public const CS_ALLOW = 'allowlists';
1921
/** @var string The CrowdSec name for blocklist */
2022
public const CS_BLOCK = 'blocklists';
2123
/** @var string The CrowdSec name for deleted decisions */
@@ -487,6 +489,15 @@ private function normalize(string $value): string
487489
private function retrieveRemediationFromCachedDecisions(array $cacheDecisions): array
488490
{
489491
$cleanDecisions = $this->cacheStorage->cleanCachedValues($cacheDecisions);
492+
// Early return for Allow list
493+
foreach ($cleanDecisions as $decision) {
494+
if (Constants::ALLOW_LIST_REMEDIATION === $decision[AbstractCache::INDEX_MAIN]) {
495+
return [
496+
self::INDEX_REM => Constants::REMEDIATION_BYPASS,
497+
self::INDEX_ORIGIN => $decision[AbstractCache::INDEX_ORIGIN],
498+
];
499+
}
500+
}
490501
$sortedDecisions = $this->sortDecisionsByPriority($cleanDecisions);
491502
$this->logger->debug('Decisions have been sorted by priority', [
492503
'type' => 'REM_SORTED_DECISIONS',

src/CacheStorage/AbstractCache.php

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ abstract class AbstractCache
4848
public const LAST_PULL = 'last_pull';
4949
/** @var string Internal name for list */
5050
public const LIST = 'list';
51+
/** @var string Internal name for allow list */
52+
public const ALLOW_LIST = 'allow_list';
5153
/** @var string Internal name for cache remediation origin count item */
5254
public const ORIGINS_COUNT = 'origins_count';
5355
/** @var string Internal name for cache clean item */
@@ -398,7 +400,7 @@ protected function saveDeferred(CacheItemInterface $item): bool
398400
*/
399401
private function format(Decision $decision, ?int $bucketInt = null): array
400402
{
401-
$mainValue = $bucketInt ? $decision->getValue() : $decision->getType();
403+
$mainValue = null !== $bucketInt ? $decision->getValue() : $decision->getType();
402404

403405
return [
404406
self::INDEX_MAIN => $mainValue,
@@ -470,7 +472,7 @@ private function getRangeIntForIp(string $ip): int
470472
*/
471473
private function getTags(Decision $decision, ?int $bucketInt = null): array
472474
{
473-
return $bucketInt ? [self::RANGE_BUCKET_TAG] : [self::CACHE_TAG_REM, $decision->getScope()];
475+
return null !== $bucketInt ? [self::RANGE_BUCKET_TAG] : [self::CACHE_TAG_REM, $decision->getScope()];
474476
}
475477

476478
/**
@@ -529,7 +531,7 @@ private function manageRange(Decision $decision): ?RangeInterface
529531
private function remove(Decision $decision, ?int $bucketInt = null): array
530532
{
531533
$result = [self::DONE => 0, self::DEFER => 0, self::REMOVED => []];
532-
$cacheKey = $bucketInt ? $this->getCacheKey(self::IPV4_BUCKET_KEY, (string) $bucketInt) :
534+
$cacheKey = null !== $bucketInt ? $this->getCacheKey(self::IPV4_BUCKET_KEY, (string) $bucketInt) :
533535
$this->getCacheKey($decision->getScope(), $decision->getValue());
534536
$item = $this->getItem($cacheKey);
535537

@@ -605,7 +607,7 @@ private function saveItemWithDuration(
605607
*/
606608
private function store(Decision $decision, ?int $bucketInt = null): array
607609
{
608-
$cacheKey = $bucketInt ? $this->getCacheKey(self::IPV4_BUCKET_KEY, (string) $bucketInt) :
610+
$cacheKey = null !== $bucketInt ? $this->getCacheKey(self::IPV4_BUCKET_KEY, (string) $bucketInt) :
609611
$this->getCacheKey($decision->getScope(), $decision->getValue());
610612
$item = $this->getItem($cacheKey);
611613
$cachedValues = $item->isHit() ? $item->get() : [];

src/CapiRemediation.php

Lines changed: 196 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,42 @@ public function getIpRemediation(string $ip): array
6666
return $this->processCachedDecisions($cachedDecisions);
6767
}
6868

69+
/**
70+
* @throws CacheStorageException
71+
* @throws InvalidArgumentException
72+
* @throws CacheException|ClientException
73+
*/
74+
public function refreshDecisions(): array
75+
{
76+
$rawDecisions = $this->client->getStreamDecisions();
77+
$newDecisions = $this->convertRawCapiDecisionsToDecisions($rawDecisions[self::CS_NEW] ?? []);
78+
$deletedDecisions = $this->convertRawCapiDecisionsToDecisions($rawDecisions[self::CS_DEL] ?? []);
79+
$listDecisions = $this->handleListDecisions($rawDecisions[self::CS_LINK][self::CS_BLOCK] ?? []);
80+
$allowListDecisions = $this->handleAllowListDecisions($rawDecisions[self::CS_LINK][self::CS_ALLOW] ?? []);
81+
82+
$stored = $this->storeDecisions(array_merge(
83+
$newDecisions,
84+
$listDecisions,
85+
$allowListDecisions
86+
));
87+
$removed = $this->removeDecisions($deletedDecisions);
88+
89+
return [
90+
self::CS_NEW => $stored[AbstractCache::DONE] ?? 0,
91+
self::CS_DEL => $removed[AbstractCache::DONE] ?? 0,
92+
];
93+
}
94+
95+
/**
96+
* Process and validate input configurations.
97+
*/
98+
private function configure(array $configs): void
99+
{
100+
$configuration = new CapiRemediationConfig();
101+
$processor = new Processor();
102+
$this->configs = $processor->processConfiguration($configuration, [$configuration->cleanConfigs($configs)]);
103+
}
104+
69105
private function convertRawCapiDecisionsToDecisions(array $rawDecisions): array
70106
{
71107
$decisions = [];
@@ -100,31 +136,90 @@ private function formatIfModifiedSinceHeader(int $timestamp): string
100136
return gmdate('D, d M Y H:i:s \G\M\T', $timestamp);
101137
}
102138

103-
/**
104-
* This method allows to know if the "If-Modified-Since" should be added when pulling list decisions.
105-
*
106-
* @param int $pullTime // Moment when the list is pulled
107-
* @param int $listExpirationTime // Expiration of the cached list decisions
108-
* @param int $frequency // Amount of time in seconds that represents the average decision pull frequency
109-
*/
110-
private function shouldAddModifiedSince(int $pullTime, int $listExpirationTime, int $frequency): bool
139+
private function getDurationInSeconds(string $futureDate): int
111140
{
112-
return ($listExpirationTime - $frequency) > $pullTime;
141+
try {
142+
// Remove microseconds for better compatibility with older PHP versions
143+
$cleanDate = preg_replace('/\.\d{3,6}/', '', $futureDate);
144+
145+
$expiration = new \DateTime($cleanDate, new \DateTimeZone('UTC'));
146+
$now = new \DateTime('now', new \DateTimeZone('UTC'));
147+
148+
$duration = $expiration->getTimestamp() - $now->getTimestamp();
149+
150+
return max(0, $duration);
151+
} catch (\Throwable $e) {
152+
// If parsing fails, return 0 as a fallback
153+
return 0;
154+
}
113155
}
114156

115-
private function handleListPullHeaders(array $headers, array $lastPullContent, int $pullTime): array
157+
/**
158+
* @throws InvalidArgumentException|CacheException
159+
*/
160+
private function handleAllowListDecisions(array $allowlists): array
116161
{
117-
$shouldAddModifiedSince = false;
118-
if (isset($lastPullContent[AbstractCache::INDEX_EXP])) {
119-
$frequency = $this->getConfig('refresh_frequency_indicator') ?? Constants::REFRESH_FREQUENCY;
120-
$shouldAddModifiedSince = $this->shouldAddModifiedSince(
121-
$pullTime,
122-
(int) $lastPullContent[AbstractCache::INDEX_EXP],
123-
(int) $frequency
124-
);
162+
$decisions = [];
163+
try {
164+
foreach ($allowlists as $allowlist) {
165+
$headers = [];
166+
if ($this->validateAllowlist($allowlist)) {
167+
// The existence of the following indexes must be guaranteed by the validateAllowlist method
168+
$listName = strtolower((string) $allowlist['name']);
169+
$listId = (string) $allowlist['id'];
170+
$url = (string) $allowlist['url'];
171+
$origin = Constants::ORIGIN_LISTS;
172+
$allowDecision = [
173+
'type' => Constants::ALLOW_LIST_REMEDIATION,
174+
'origin' => $origin,
175+
'scenario' => $listName,
176+
];
177+
178+
$lastPullCacheKey = $this->getCacheStorage()->getCacheKey(
179+
AbstractCache::ALLOW_LIST,
180+
$listId
181+
);
182+
183+
$lastPullItem = $this->getCacheStorage()->getItem($lastPullCacheKey);
184+
185+
$pullTime = time();
186+
if ($lastPullItem->isHit()) {
187+
$lastPullContent = $lastPullItem->get();
188+
$headers = $this->handleAllowListPullHeaders($headers, $lastPullContent);
189+
}
190+
191+
$listResponse = rtrim(
192+
$this->client->getCapiHandler()->getListDecisions($url, $headers),
193+
\PHP_EOL
194+
);
195+
196+
if ($listResponse) {
197+
$this->cacheStorage->upsertItem(
198+
$lastPullCacheKey,
199+
[
200+
AbstractCache::LAST_PULL => $pullTime,
201+
],
202+
0,
203+
[AbstractCache::ALLOW_LIST, $listName]
204+
);
205+
$decisions = $this->handleAllowListResponse($listResponse, $allowDecision);
206+
}
207+
}
208+
}
209+
} catch (\Exception $e) {
210+
$this->logger->info('Something went wrong during list decisions process', [
211+
'type' => 'CAPI_REM_HANDLE_ALLOW_LIST_DECISIONS',
212+
'message' => $e->getMessage(),
213+
'code' => $e->getCode(),
214+
]);
125215
}
126216

127-
if ($shouldAddModifiedSince && isset($lastPullContent[AbstractCache::LAST_PULL])) {
217+
return $decisions;
218+
}
219+
220+
private function handleAllowListPullHeaders(array $headers, array $lastPullContent): array
221+
{
222+
if (isset($lastPullContent[AbstractCache::LAST_PULL])) {
128223
$headers['If-Modified-Since'] = $this->formatIfModifiedSinceHeader(
129224
(int) $lastPullContent[AbstractCache::LAST_PULL]
130225
);
@@ -133,6 +228,34 @@ private function handleListPullHeaders(array $headers, array $lastPullContent, i
133228
return $headers;
134229
}
135230

231+
private function handleAllowListResponse(string $listResponse, array $allowDecision): array
232+
{
233+
$decisions = [];
234+
$listedAllows = explode(\PHP_EOL, $listResponse);
235+
$this->logger->debug('Handle allow list decisions', [
236+
'type' => 'CAPI_REM_HANDLE_ALLOW_LIST_DECISIONS',
237+
'list_count' => count($listedAllows),
238+
]);
239+
foreach ($listedAllows as $listedAllow) {
240+
$decoded = json_decode($listedAllow, true);
241+
$allowDecision['value'] = $decoded['value'];
242+
$allowDecision['scope'] = $decoded['scope'];
243+
$allowDecision['duration'] = '1s'; // Will be overwritten by the duration in the allowlist
244+
$decision = $this->convertRawDecision($allowDecision);
245+
246+
if ($decision) {
247+
$durationInSeconds = isset($decoded['expiration']) ?
248+
$this->getDurationInSeconds($decoded['expiration']) :
249+
Constants::MAX_DURATION;
250+
251+
$decision->setExpiresAt(time() + $durationInSeconds);
252+
$decisions[] = $decision;
253+
}
254+
}
255+
256+
return $decisions;
257+
}
258+
136259
/**
137260
* @throws InvalidArgumentException|CacheException
138261
*/
@@ -168,7 +291,9 @@ private function handleListDecisions(array $blocklists): array
168291
$pullTime = time();
169292
if ($lastPullItem->isHit()) {
170293
$lastPullContent = $lastPullItem->get();
171-
$headers = $this->handleListPullHeaders($headers, $lastPullContent, $pullTime);
294+
$frequency = $this->getConfig('refresh_frequency_indicator') ?? Constants::REFRESH_FREQUENCY;
295+
$headers = $this->handleListPullHeaders($headers, $lastPullContent, $pullTime, (int)
296+
$frequency);
172297
}
173298

174299
$listResponse = rtrim(
@@ -202,6 +327,26 @@ private function handleListDecisions(array $blocklists): array
202327
return $decisions;
203328
}
204329

330+
private function handleListPullHeaders(array $headers, array $lastPullContent, int $pullTime, int $frequency): array
331+
{
332+
$shouldAddModifiedSince = false;
333+
if (isset($lastPullContent[AbstractCache::INDEX_EXP])) {
334+
$shouldAddModifiedSince = $this->shouldAddModifiedSince(
335+
$pullTime,
336+
(int) $lastPullContent[AbstractCache::INDEX_EXP],
337+
$frequency
338+
);
339+
}
340+
341+
if ($shouldAddModifiedSince && isset($lastPullContent[AbstractCache::LAST_PULL])) {
342+
$headers['If-Modified-Since'] = $this->formatIfModifiedSinceHeader(
343+
(int) $lastPullContent[AbstractCache::LAST_PULL]
344+
);
345+
}
346+
347+
return $headers;
348+
}
349+
205350
private function handleListResponse(string $listResponse, array $blockDecision): array
206351
{
207352
$decisions = [];
@@ -221,6 +366,37 @@ private function handleListResponse(string $listResponse, array $blockDecision):
221366
return $decisions;
222367
}
223368

369+
/**
370+
* This method allows to know if the "If-Modified-Since" should be added when pulling list decisions.
371+
*
372+
* @param int $pullTime // Moment when the list is pulled
373+
* @param int $listExpirationTime // Expiration of the cached list decisions
374+
* @param int $frequency // Amount of time in seconds that represents the average decision pull frequency
375+
*/
376+
private function shouldAddModifiedSince(int $pullTime, int $listExpirationTime, int $frequency): bool
377+
{
378+
return ($listExpirationTime - $frequency) > $pullTime;
379+
}
380+
381+
private function validateAllowlist(array $allowlist): bool
382+
{
383+
if (
384+
!empty($allowlist['id'])
385+
&& !empty($allowlist['name'])
386+
&& !empty($allowlist['description'])
387+
&& !empty($allowlist['url'])
388+
) {
389+
return true;
390+
}
391+
392+
$this->logger->error('Retrieved allowlist is not as expected', [
393+
'type' => 'REM_RAW_DECISION_NOT_AS_EXPECTED',
394+
'raw_decision' => json_encode($allowlist),
395+
]);
396+
397+
return false;
398+
}
399+
224400
private function validateBlocklist(array $blocklist): bool
225401
{
226402
if (
@@ -240,35 +416,4 @@ private function validateBlocklist(array $blocklist): bool
240416

241417
return false;
242418
}
243-
244-
/**
245-
* @throws CacheStorageException
246-
* @throws InvalidArgumentException
247-
* @throws CacheException|ClientException
248-
*/
249-
public function refreshDecisions(): array
250-
{
251-
$rawDecisions = $this->client->getStreamDecisions();
252-
$newDecisions = $this->convertRawCapiDecisionsToDecisions($rawDecisions[self::CS_NEW] ?? []);
253-
$deletedDecisions = $this->convertRawCapiDecisionsToDecisions($rawDecisions[self::CS_DEL] ?? []);
254-
$listDecisions = $this->handleListDecisions($rawDecisions[self::CS_LINK][self::CS_BLOCK] ?? []);
255-
256-
$stored = $this->storeDecisions(array_merge($newDecisions, $listDecisions));
257-
$removed = $this->removeDecisions($deletedDecisions);
258-
259-
return [
260-
self::CS_NEW => $stored[AbstractCache::DONE] ?? 0,
261-
self::CS_DEL => $removed[AbstractCache::DONE] ?? 0,
262-
];
263-
}
264-
265-
/**
266-
* Process and validate input configurations.
267-
*/
268-
private function configure(array $configs): void
269-
{
270-
$configuration = new CapiRemediationConfig();
271-
$processor = new Processor();
272-
$this->configs = $processor->processConfiguration($configuration, [$configuration->cleanConfigs($configs)]);
273-
}
274419
}

0 commit comments

Comments
 (0)