From efffa9dd082cf14d48cd2b589cabb082a1bce1fe Mon Sep 17 00:00:00 2001 From: Alexander Strizhak Date: Fri, 7 Nov 2025 15:23:33 +0300 Subject: [PATCH 01/43] alert init (#3) * AlertsClient --- .../workflows/php-sdk-development-tests.yml | 2 + .gitignore | 2 + composer.json | 6 +- docs/DEVELOPER.md | 4 +- src/AbstractLapiClient.php | 128 ++++++ src/AlertsClient.php | 170 ++++++++ src/Bouncer.php | 133 +----- src/Configuration.php | 21 +- src/Configuration/Alert.php | 54 +++ src/Configuration/Alert/Decision.php | 42 ++ src/Configuration/Alert/Event.php | 39 ++ src/Configuration/Alert/Meta.php | 31 ++ src/Configuration/Alert/Source.php | 46 ++ src/Constants.php | 24 +- src/Metrics.php | 2 +- src/Payload/Alert.php | 237 ++++++++++ src/Storage/TokenStorage.php | 52 +++ src/Storage/TokenStorageInterface.php | 14 + src/WatcherClient.php | 55 +++ tests/Integration/AlertsClientTest.php | 406 ++++++++++++++++++ tests/Integration/BouncerTest.php | 4 +- ...atcherClient.php => TestWatcherClient.php} | 23 +- tests/Integration/WatcherClientTest.php | 76 ++++ tests/Unit/FileGetContentsTest.php | 1 - tests/Unit/Payload/AlertTest.php | 93 ++++ tests/Unit/Storage/TokenStorageTest.php | 34 ++ 26 files changed, 1543 insertions(+), 156 deletions(-) create mode 100644 src/AbstractLapiClient.php create mode 100644 src/AlertsClient.php create mode 100644 src/Configuration/Alert.php create mode 100644 src/Configuration/Alert/Decision.php create mode 100644 src/Configuration/Alert/Event.php create mode 100644 src/Configuration/Alert/Meta.php create mode 100644 src/Configuration/Alert/Source.php create mode 100644 src/Payload/Alert.php create mode 100644 src/Storage/TokenStorage.php create mode 100644 src/Storage/TokenStorageInterface.php create mode 100644 src/WatcherClient.php create mode 100644 tests/Integration/AlertsClientTest.php rename tests/Integration/{WatcherClient.php => TestWatcherClient.php} (93%) create mode 100644 tests/Integration/WatcherClientTest.php create mode 100644 tests/Unit/Payload/AlertTest.php create mode 100644 tests/Unit/Storage/TokenStorageTest.php diff --git a/.github/workflows/php-sdk-development-tests.yml b/.github/workflows/php-sdk-development-tests.yml index 3f9e9ed..50e1343 100644 --- a/.github/workflows/php-sdk-development-tests.yml +++ b/.github/workflows/php-sdk-development-tests.yml @@ -99,6 +99,8 @@ jobs: - name: Set BOUNCER_KEY env run: | echo "BOUNCER_KEY=$(ddev create-bouncer)" >> $GITHUB_ENV + - name: Create watcher + run: ddev create-watcher - name: Clone Lapi Client files if: inputs.is_call != true diff --git a/.gitignore b/.gitignore index 79f6bc0..0b8021a 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ composer-dev* #log *.log + +/cfssl diff --git a/composer.json b/composer.json index 0943522..7f07a10 100644 --- a/composer.json +++ b/composer.json @@ -37,14 +37,16 @@ }, "require": { "php": "^7.2.5 || ^8.0", - "crowdsec/common": "^3.0.0", "ext-json": "*", + "crowdsec/common": "^3.0.0", + "psr/cache": "^1.0 || ^2.0 || ^3.0", "symfony/config": "^4.4.44 || ^5.4.11 || ^6.0.11 || ^7.2.0" }, "require-dev": { + "ext-curl": "*", "phpunit/phpunit": "^8.5.30 || ^9.3", "mikey179/vfsstream": "^1.6.11", - "ext-curl": "*" + "symfony/cache": "^5.4.11 || ^6.0.11 || ^7.2.1" }, "suggest": { "ext-curl": "*" diff --git a/docs/DEVELOPER.md b/docs/DEVELOPER.md index ae5bb72..c0f8dfc 100644 --- a/docs/DEVELOPER.md +++ b/docs/DEVELOPER.md @@ -82,8 +82,8 @@ ddev config --project-type=php --php-version=8.2 --project-name=crowdsec-lapi-cl - Add some DDEV add-ons: ```bash -ddev get julienloizelet/ddev-tools -ddev get julienloizelet/ddev-crowdsec-php +ddev add-on get julienloizelet/ddev-tools +ddev add-on get julienloizelet/ddev-crowdsec-php ``` - Clone this repo sources in a `my-code/lapi-client` folder: diff --git a/src/AbstractLapiClient.php b/src/AbstractLapiClient.php new file mode 100644 index 0000000..becbc3f --- /dev/null +++ b/src/AbstractLapiClient.php @@ -0,0 +1,128 @@ +configure($configs); + $this->headers = [Constants::HEADER_LAPI_USER_AGENT => $this->formatUserAgent($this->configs)]; + if (!empty($this->configs['api_key'])) { + $this->headers[Constants::HEADER_LAPI_API_KEY] = $this->configs['api_key']; + } + parent::__construct($this->configs, $requestHandler, $logger); + } + + /** + * Process and validate input configurations. + */ + private function configure(array $configs): void + { + $configuration = new Configuration(); + $processor = new Processor(); + $this->configs = $processor->processConfiguration($configuration, [$configuration->cleanConfigs($configs)]); + } + + /** + * Make a request to LAPI. + * + * @throws ClientException + */ + protected function manageRequest( + string $method, + string $endpoint, + array $parameters = [] + ): array { + try { + $this->logger->debug('Now processing a bouncer request', [ + 'type' => 'BOUNCER_CLIENT_REQUEST', + 'method' => $method, + 'endpoint' => $endpoint, + 'parameters' => $parameters, + ]); + + return $this->request($method, $endpoint, $parameters, $this->headers); + } catch (CommonTimeoutException $e) { + throw new TimeoutException($e->getMessage(), $e->getCode(), $e); + } catch (CommonClientException $e) { + throw new ClientException($e->getMessage(), $e->getCode(), $e); + } + } + + /** + * Make a request to the AppSec component of LAPI. + * + * @throws ClientException + */ + protected function manageAppSecRequest( + string $method, + array $headers = [], + string $rawBody = '', + ): array { + try { + $this->logger->debug('Now processing a bouncer AppSec request', [ + 'type' => 'BOUNCER_CLIENT_APPSEC_REQUEST', + 'method' => $method, + 'raw body' => $this->cleanRawBodyForLog($rawBody, 200), + 'raw body length' => strlen($rawBody), + 'headers' => $this->cleanHeadersForLog($headers), + ]); + + return $this->requestAppSec($method, $headers, $rawBody); + } catch (CommonTimeoutException $e) { + throw new TimeoutException($e->getMessage(), $e->getCode(), $e); + } catch (CommonClientException $e) { + throw new ClientException($e->getMessage(), $e->getCode(), $e); + } + } + + protected function cleanHeadersForLog(array $headers): array + { + $cleanedHeaders = $headers; + if (array_key_exists(Constants::HEADER_APPSEC_API_KEY, $cleanedHeaders)) { + $cleanedHeaders[Constants::HEADER_APPSEC_API_KEY] = '***'; + } + + return $cleanedHeaders; + } + + protected function cleanRawBodyForLog(string $rawBody, int $maxLength): string + { + return strlen($rawBody) > $maxLength ? substr($rawBody, 0, $maxLength) . '...[TRUNCATED]' : $rawBody; + } + + /** + * Format User-Agent header. _/. + */ + protected function formatUserAgent(array $configs = []): string + { + $userAgentSuffix = !empty($configs['user_agent_suffix']) ? '_' . $configs['user_agent_suffix'] : ''; + $userAgentVersion = + !empty($configs['user_agent_version']) ? $configs['user_agent_version'] : Constants::VERSION; + + return Constants::USER_AGENT_PREFIX . $userAgentSuffix . '/' . $userAgentVersion; + } +} diff --git a/src/AlertsClient.php b/src/AlertsClient.php new file mode 100644 index 0000000..9c5b9e5 --- /dev/null +++ b/src/AlertsClient.php @@ -0,0 +1,170 @@ +, + * events: list, + * events_count: int, + * id: int, + * labels: null|array, + * leakspeed: string, + * machine_id: string, + * message: string, + * meta: list, + * scenario: string, + * scenario_hash: string, + * scenario_version: string, + * simulated: bool, + * source: TSource, + * start_at: string, + * stop_at: string, + * uuid: string + * } + */ +class AlertsClient extends AbstractLapiClient +{ + /** + * @var TokenStorageInterface + */ + private $tokenStorage; + + public function __construct( + array $configs, + TokenStorageInterface $tokenStorage, + ?RequestHandlerInterface $requestHandler = null, + ?LoggerInterface $logger = null + ) { + $this->tokenStorage = $tokenStorage; + parent::__construct($configs, $requestHandler, $logger); + } + + /** + * @param list $alerts + * + * @return list + */ + public function push(array $alerts): array + { + $this->login(); + return $this->manageRequest( + 'POST', + Constants::ALERTS, + $alerts + ); + } + + /** + * Search for alerts. + * + * scope - Show alerts for this scope. + * value - Show alerts for this value (used with scope). + * scenario - Show alerts for this scenario. + * ip - IP to search for (shorthand for scope=ip&value=). + * range - Range to search for (shorthand for scope=range&value=). + * since - Search alerts newer than delay (format must be compatible with time.ParseDuration). + * until - Search alerts older than delay (format must be compatible with time.ParseDuration). + * simulated - If set to true, decisions in simulation mode will be returned as well. + * has_active_decision: Only return alerts with decisions not expired yet. + * decision_type: Restrict results to alerts with decisions matching given type. + * limit: Number of alerts to return. + * origin: Restrict results to this origin (ie. lists,CAPI,cscli). + * + * @param TSearchQuery $query + * @return list + */ + public function search(array $query): array + { + $this->login(); + return $this->manageRequest( + 'GET', + Constants::ALERTS, + $query + ); + } + + /** + * Delete alerts by condition. Can be used only on the same machine than the local API. + * + * @param TDeleteQuery $query + */ + public function delete(array $query): array + { + $this->login(); + return $this->manageRequest( + 'DELETE', + Constants::ALERTS, + $query + ); + } + + /** + * @param positive-int $id + * @return TStoredAlert + */ + public function getById(int $id): ?array + { + $this->login(); + $result = $this->manageRequest( + 'GET', + \sprintf('%s/%d', Constants::ALERTS, $id) + ); + // workaround for mutes 404 status. + if (empty($result['id'])) { + \assert($result['message'] === 'object not found'); + return null; + } + return $result; + } + + private function login(): void + { + $token = $this->tokenStorage->retrieveToken(); + if (null === $token) { + throw new ClientException('Login fail'); + } + $this->headers['Authorization'] = "Bearer $token"; + } +} diff --git a/src/Bouncer.php b/src/Bouncer.php index a142aeb..00aaf05 100644 --- a/src/Bouncer.php +++ b/src/Bouncer.php @@ -4,13 +4,6 @@ namespace CrowdSec\LapiClient; -use CrowdSec\Common\Client\AbstractClient; -use CrowdSec\Common\Client\ClientException as CommonClientException; -use CrowdSec\Common\Client\RequestHandler\RequestHandlerInterface; -use CrowdSec\Common\Client\TimeoutException as CommonTimeoutException; -use Psr\Log\LoggerInterface; -use Symfony\Component\Config\Definition\Processor; - /** * The Bouncer Client. * @@ -21,35 +14,14 @@ * @copyright Copyright (c) 2022+ CrowdSec * @license MIT License * - * @psalm-import-type TMetric from Metrics - * @psalm-import-type TOS from Metrics - * @psalm-import-type TMeta from Metrics - * @psalm-import-type TItem from Metrics + * @psalm-import-type TMetric from \CrowdSec\LapiClient\Metrics + * @psalm-import-type TOS from \CrowdSec\LapiClient\Metrics + * @psalm-import-type TMeta from \CrowdSec\LapiClient\Metrics + * @psalm-import-type TItem from \CrowdSec\LapiClient\Metrics + * @psalm-import-type TBouncerConfig from \CrowdSec\LapiClient\Configuration */ -class Bouncer extends AbstractClient +class Bouncer extends AbstractLapiClient { - /** - * @var array - */ - protected $configs; - /** - * @var array - */ - private $headers; - - public function __construct( - array $configs, - ?RequestHandlerInterface $requestHandler = null, - ?LoggerInterface $logger = null - ) { - $this->configure($configs); - $this->headers = [Constants::HEADER_LAPI_USER_AGENT => $this->formatUserAgent($this->configs)]; - if (!empty($this->configs['api_key'])) { - $this->headers[Constants::HEADER_LAPI_API_KEY] = $this->configs['api_key']; - } - parent::__construct($this->configs, $requestHandler, $logger); - } - /** * Helper to create well formatted metrics array. * @@ -62,19 +34,18 @@ public function __construct( * 'version' => (string) Bouncer version * 'feature_flags' => (array) Should be empty for bouncer * 'utc_startup_timestamp' => (integer) Bouncer startup timestamp + * 'os' => (array) OS information * 'os' = [ * 'name' => (string) OS name * 'version' => (string) OS version * ] * ]; - * * @param TMeta $meta Array containing meta data. * * $meta = [ * 'window_size_seconds' => (integer) Window size in seconds * 'utc_now_timestamp' => (integer) Current timestamp * ]; - * * @param list $items Array of items. Each item is an array too. * * $items = [ @@ -196,43 +167,6 @@ public function pushUsageMetrics(array $usageMetrics): array ); } - private function cleanHeadersForLog(array $headers): array - { - $cleanedHeaders = $headers; - if (array_key_exists(Constants::HEADER_APPSEC_API_KEY, $cleanedHeaders)) { - $cleanedHeaders[Constants::HEADER_APPSEC_API_KEY] = '***'; - } - - return $cleanedHeaders; - } - - private function cleanRawBodyForLog(string $rawBody, int $maxLength): string - { - return strlen($rawBody) > $maxLength ? substr($rawBody, 0, $maxLength) . '...[TRUNCATED]' : $rawBody; - } - - /** - * Process and validate input configurations. - */ - private function configure(array $configs): void - { - $configuration = new Configuration(); - $processor = new Processor(); - $this->configs = $processor->processConfiguration($configuration, [$configuration->cleanConfigs($configs)]); - } - - /** - * Format User-Agent header. _/. - */ - private function formatUserAgent(array $configs = []): string - { - $userAgentSuffix = !empty($configs['user_agent_suffix']) ? '_' . $configs['user_agent_suffix'] : ''; - $userAgentVersion = - !empty($configs['user_agent_version']) ? $configs['user_agent_version'] : Constants::VERSION; - - return Constants::USER_AGENT_PREFIX . $userAgentSuffix . '/' . $userAgentVersion; - } - /** * @return TOS */ @@ -243,57 +177,4 @@ private function getOs(): array 'version' => php_uname('v'), ]; } - - /** - * Make a request to the AppSec component of LAPI. - * - * @throws ClientException - */ - private function manageAppSecRequest( - string $method, - array $headers = [], - string $rawBody = '' - ): array { - try { - $this->logger->debug('Now processing a bouncer AppSec request', [ - 'type' => 'BOUNCER_CLIENT_APPSEC_REQUEST', - 'method' => $method, - 'raw body' => $this->cleanRawBodyForLog($rawBody, 200), - 'raw body length' => strlen($rawBody), - 'headers' => $this->cleanHeadersForLog($headers), - ]); - - return $this->requestAppSec($method, $headers, $rawBody); - } catch (CommonTimeoutException $e) { - throw new TimeoutException($e->getMessage(), $e->getCode(), $e); - } catch (CommonClientException $e) { - throw new ClientException($e->getMessage(), $e->getCode(), $e); - } - } - - /** - * Make a request to LAPI. - * - * @throws ClientException - */ - private function manageRequest( - string $method, - string $endpoint, - array $parameters = [] - ): array { - try { - $this->logger->debug('Now processing a bouncer request', [ - 'type' => 'BOUNCER_CLIENT_REQUEST', - 'method' => $method, - 'endpoint' => $endpoint, - 'parameters' => $parameters, - ]); - - return $this->request($method, $endpoint, $parameters, $this->headers); - } catch (CommonTimeoutException $e) { - throw new TimeoutException($e->getMessage(), $e->getCode(), $e); - } catch (CommonClientException $e) { - throw new ClientException($e->getMessage(), $e->getCode(), $e); - } - } } diff --git a/src/Configuration.php b/src/Configuration.php index 732b518..82aac1a 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -25,7 +25,7 @@ * api_url?: string, * appsec_url?: string, * auth_type?: string, - * api_key: string, + * api_key?: string, * tls_cert_path?: string, * tls_key_path?: string, * tls_ca_cert_path?: string, @@ -34,6 +34,8 @@ * api_connect_timeout?: int, * appsec_timeout_ms?: int, * appsec_connect_timeout_ms?: int, + * machine_id?: non-empty-string, + * password?: non-empty-string * } */ class Configuration extends AbstractConfiguration @@ -54,6 +56,8 @@ class Configuration extends AbstractConfiguration 'api_connect_timeout', 'appsec_timeout_ms', 'appsec_connect_timeout_ms', + 'machine_id', + 'password', ]; /** @@ -92,6 +96,7 @@ public function getConfigTreeBuilder(): TreeBuilder $this->addConnectionNodes($rootNode); $this->addAppSecNodes($rootNode); $this->validate($rootNode); + $this->watcher($rootNode); return $treeBuilder; } @@ -105,7 +110,7 @@ public function getConfigTreeBuilder(): TreeBuilder * * @throws \InvalidArgumentException */ - private function addAppSecNodes($rootNode) + private function addAppSecNodes($rootNode): void { $rootNode->children() ->scalarNode('appsec_url')->cannotBeEmpty()->defaultValue(Constants::DEFAULT_APPSEC_URL)->end() @@ -123,7 +128,7 @@ private function addAppSecNodes($rootNode) * * @throws \InvalidArgumentException */ - private function addConnectionNodes($rootNode) + private function addConnectionNodes($rootNode): void { $rootNode->children() ->scalarNode('api_url')->cannotBeEmpty()->defaultValue(Constants::DEFAULT_LAPI_URL)->end() @@ -162,7 +167,7 @@ private function addConnectionNodes($rootNode) * @throws \InvalidArgumentException * @throws \RuntimeException */ - private function validate($rootNode) + private function validate($rootNode): void { $rootNode ->validate() @@ -196,4 +201,12 @@ private function validate($rootNode) ->thenInvalid('CA path is required for tls authentification with verify_peer.') ->end(); } + + private function watcher(ArrayNodeDefinition $rootNode): void + { + $rootNode->children() + ->stringNode('machine_id')->end() + ->stringNode('password')->end() + ->end(); + } } diff --git a/src/Configuration/Alert.php b/src/Configuration/Alert.php new file mode 100644 index 0000000..eeae807 --- /dev/null +++ b/src/Configuration/Alert.php @@ -0,0 +1,54 @@ + The list of each configuration tree key */ + protected $keys = [ + 'scenario', + 'scenario_hash', + 'scenario_version', + 'message', + 'events_count', + 'start_at', + 'stop_at', + 'capacity', + 'leakspeed', + 'simulated', + 'remediation', + ]; + + public function getConfigTreeBuilder(): TreeBuilder + { + $treeBuilder = new TreeBuilder('alert'); + /** @var ArrayNodeDefinition $rootNode */ + $rootNode = $treeBuilder->getRootNode(); + + // @formatter:off + $rootNode + ->children() + ->stringNode('scenario')->isRequired()->cannotBeEmpty()->end() + ->stringNode('scenario_hash')->isRequired()->cannotBeEmpty()->end() + ->stringNode('scenario_version')->isRequired()->cannotBeEmpty()->end() + ->stringNode('message')->isRequired()->cannotBeEmpty()->end() + ->integerNode('events_count')->isRequired()->min(0)->end() + ->scalarNode('start_at')->isRequired()->cannotBeEmpty()->end() + ->scalarNode('stop_at')->isRequired()->cannotBeEmpty()->end() + ->integerNode('capacity')->isRequired()->min(0)->end() + ->scalarNode('leakspeed')->isRequired()->cannotBeEmpty()->end() + ->booleanNode('simulated')->isRequired()->end() + ->booleanNode('remediation')->isRequired()->end() + ->end() + ; + // @formatter:on + + return $treeBuilder; + } +} diff --git a/src/Configuration/Alert/Decision.php b/src/Configuration/Alert/Decision.php new file mode 100644 index 0000000..5927275 --- /dev/null +++ b/src/Configuration/Alert/Decision.php @@ -0,0 +1,42 @@ + The list of each configuration tree key */ + protected $keys = [ + 'origin', + 'type', + 'scope', + 'value', + 'duration', + 'until', + 'scenario', + ]; + + public function getConfigTreeBuilder(): TreeBuilder + { + $treeBuilder = new TreeBuilder('decision'); + $rootNode = $treeBuilder->getRootNode(); + + // @formatter:off + $rootNode + ->children() + ->stringNode('origin')->isRequired()->cannotBeEmpty()->end() + ->stringNode('type')->isRequired()->cannotBeEmpty()->end() + ->stringNode('scope')->isRequired()->cannotBeEmpty()->end() + ->stringNode('value')->isRequired()->cannotBeEmpty()->end() + ->stringNode('duration')->isRequired()->cannotBeEmpty()->end() + ->stringNode('until')->cannotBeEmpty()->end() + ->stringNode('scenario')->isRequired()->cannotBeEmpty()->end() + ->end() + ; + // @formatter:on + + return $treeBuilder; + } +} diff --git a/src/Configuration/Alert/Event.php b/src/Configuration/Alert/Event.php new file mode 100644 index 0000000..75f4750 --- /dev/null +++ b/src/Configuration/Alert/Event.php @@ -0,0 +1,39 @@ + The list of each configuration tree key */ + protected $keys = [ + 'meta', + 'timestamp', + ]; + + public function getConfigTreeBuilder(): TreeBuilder + { + $treeBuilder = new TreeBuilder('event'); + $rootNode = $treeBuilder->getRootNode(); + + // @formatter:off + $rootNode + ->children() + ->arrayNode('meta')->isRequired() + ->arrayPrototype() + ->children() + ->stringNode('key')->isRequired()->cannotBeEmpty()->end() + ->stringNode('value')->isRequired()->cannotBeEmpty()->end() + ->end() + ->end() + ->end() + ->scalarNode('timestamp')->isRequired()->cannotBeEmpty()->end() + ->end() + ; + // @formatter:on + + return $treeBuilder; + } +} diff --git a/src/Configuration/Alert/Meta.php b/src/Configuration/Alert/Meta.php new file mode 100644 index 0000000..e56b064 --- /dev/null +++ b/src/Configuration/Alert/Meta.php @@ -0,0 +1,31 @@ + The list of each configuration tree key */ + protected $keys = [ + 'key', + 'value', + ]; + + public function getConfigTreeBuilder(): TreeBuilder + { + $treeBuilder = new TreeBuilder('meta'); + $root = $treeBuilder->getRootNode(); + + // @formatter:off + $root + ->children() + ->scalarNode('key')->isRequired()->cannotBeEmpty()->end() + ->scalarNode('value')->isRequired()->cannotBeEmpty()->end() + ->end(); + // @formatter:on + + return $treeBuilder; + } +} diff --git a/src/Configuration/Alert/Source.php b/src/Configuration/Alert/Source.php new file mode 100644 index 0000000..8b4215d --- /dev/null +++ b/src/Configuration/Alert/Source.php @@ -0,0 +1,46 @@ + The list of each configuration tree key */ + protected $keys = [ + 'scope', + 'value', + 'ip', + 'range', + 'as_number', + 'as_name', + 'cn', + 'latitude', + 'longitude', + ]; + + public function getConfigTreeBuilder(): TreeBuilder + { + $treeBuilder = new TreeBuilder('source'); + $rootNode = $treeBuilder->getRootNode(); + + // @formatter:off + $rootNode + ->children() + ->stringNode('scope')->isRequired()->cannotBeEmpty()->end() + ->stringNode('value')->isRequired()->cannotBeEmpty()->end() + ->stringNode('ip')->cannotBeEmpty()->end() + ->stringNode('range')->cannotBeEmpty()->end() + ->scalarNode('as_number')->cannotBeEmpty()->end() + ->stringNode('as_name')->cannotBeEmpty()->end() + ->stringNode('cn')->cannotBeEmpty()->end() + ->floatNode('latitude')->min(-90)->max(90)->end() + ->floatNode('longitude')->min(-180)->max(180)->end() + ->end() + ; + // @formatter:on + + return $treeBuilder; + } +} diff --git a/src/Constants.php b/src/Constants.php index 6a233eb..dfc5e2d 100644 --- a/src/Constants.php +++ b/src/Constants.php @@ -18,34 +18,50 @@ */ class Constants extends CommonConstants { + // /** * @var string The decisions endpoint */ public const DECISIONS_FILTER_ENDPOINT = '/v1/decisions'; + /** * @var string The decisions stream endpoint */ public const DECISIONS_STREAM_ENDPOINT = '/v1/decisions/stream'; + + public const ALERTS = '/v1/alerts'; + + /** + * @var string Authenticate current to get session ID + */ + public const WATCHER_LOGIN_ENDPOINT = '/v1/watchers/login'; + + /** + * @var string The usage metrics endpoint + */ + public const METRICS_ENDPOINT = '/v1/usage-metrics'; + // + /** * @var string The Default URL of the CrowdSec AppSec endpoint */ public const DEFAULT_APPSEC_URL = 'http://localhost:7422'; + /** * @var string The Default URL of the CrowdSec LAPI */ public const DEFAULT_LAPI_URL = 'http://localhost:8080'; - /** - * @var string The usage metrics endpoint - */ - public const METRICS_ENDPOINT = '/v1/usage-metrics'; + /** * @var string The metrics type */ public const METRICS_TYPE = 'crowdsec-php-bouncer'; + /** * @var string The user agent prefix used to send request to LAPI */ public const USER_AGENT_PREFIX = 'csphplapi'; + /** * @var string The current version of this library */ diff --git a/src/Metrics.php b/src/Metrics.php index 0697ac2..3bc9276 100644 --- a/src/Metrics.php +++ b/src/Metrics.php @@ -92,7 +92,7 @@ class Metrics public function __construct( array $properties, array $meta, - array $items = [] + array $items = [], ) { $this->configureProperties($properties); $this->configureMeta($meta); diff --git a/src/Payload/Alert.php b/src/Payload/Alert.php new file mode 100644 index 0000000..15e45e7 --- /dev/null +++ b/src/Payload/Alert.php @@ -0,0 +1,237 @@ +, + * timestamp: string + * } + * + * @psalm-type TAlertFull = array{ + * scenario: string, + * scenario_hash: string, + * scenario_version: string, + * message: string, + * events_count: int, + * start_at: string, + * stop_at: string, + * capacity: int, + * leakspeed: string, + * simulated: bool, + * remediation: bool, + * source: TSource, + * events: list, + * decisions: list, + * meta: list, + * labels: list + * } + */ +class Alert implements \JsonSerializable +{ + /** + * @var list + */ + private $properties; + + /** + * @var list + */ + private $events; + + /** + * @var list + */ + private $decisions = []; + + /** + * @var TSource + */ + private $source; + + /** + * @var list + */ + private $meta = []; + + /** + * @var list + */ + private $labels = []; + + /** + * @param TProps $properties + * @param TSource $source + * @param list $events + * @param list $decisions + * @param list $meta + * @param list $labels + */ + public function __construct( + array $properties, + array $source, + array $events = [], + array $decisions = [], + array $meta = [], + array $labels = [] + ) { + $processor = new Processor(); + $this->configureProperties($processor, $properties); + $this->configureSource($processor, $source); + $this->configureDecisions($processor, $decisions); + $this->configureEvents($processor, $events); + $this->configureMetaList($processor, $meta); + $this->labels = \array_filter($labels); + } + + /** + * @param TAlertFull $data + * @return void + */ + public static function fromArray(array $data): self + { + return new self( + $data, + $data['source'] ?? [], + $data['events'] ?? [], + $data['decisions'] ?? [], + $data['meta'] ?? [], + $data['labels'] ?? [] + ); + } + + /** + * @return TAlertFull + */ + public function toArray(): array + { + $result = $this->properties; + if ([] !== $this->decisions) { + $result['decisions'] = $this->decisions; + } + if ([] !== $this->events) { + $result['events'] = $this->events; + } + if (null !== $this->source) { + $result['source'] = $this->source; + } + if ([] !== $this->meta) { + $result['meta'] = $this->meta; + } + if ([] !== $this->labels) { + $result['labels'] = $this->labels; + } + return $result; + } + + private function configureProperties(Processor $processor, array $properties): void + { + $configuration = new AlertConf(); + $this->properties = $processor->processConfiguration( + $configuration, + [$configuration->cleanConfigs($properties)] + ); + } + + /** + * @param ?TSource $source + */ + private function configureSource(Processor $processor, ?array $source): void + { + if (null === $source) { + return; + } + + $configuration = new Source(); + $this->source = $processor->processConfiguration($configuration, [$configuration->cleanConfigs($source)]); + } + + /** + * @param list $list + */ + private function configureDecisions(Processor $processor, array $list): void + { + $this->decisions = $this->handleList($processor, new Decision(), $list); + } + + /** + * @param list $list + */ + private function configureEvents(Processor $processor, array $list): void + { + $this->events = $this->handleList($processor, new Event(), $list); + } + + /** + * @param list $list + */ + private function configureMetaList(Processor $processor, array $list): void + { + $this->meta = $this->handleList($processor, new Meta(), $list); + } + + private function handleList(Processor $processor, AbstractConfiguration $param, array $list): array + { + $result = []; + foreach ($list as $item) { + $result[] = $processor->processConfiguration($param, [$param->cleanConfigs($item)]); + } + return $result; + } + + public function jsonSerialize() + { + return $this->toArray(); + } +} diff --git a/src/Storage/TokenStorage.php b/src/Storage/TokenStorage.php new file mode 100644 index 0000000..0c629f8 --- /dev/null +++ b/src/Storage/TokenStorage.php @@ -0,0 +1,52 @@ +watcher = $watcher; + $this->cache = $cache; + $this->scenarios = $scenarios; + } + + public function retrieveToken(): ?string + { + $ci = $this->cache->getItem('crowdsec_token'); + if (!$ci->isHit()) { + $tokenInfo = $this->watcher->login($this->scenarios); + if (200 !== $tokenInfo['code']) { + return null; + } + \assert(!empty($tokenInfo['token'])); + $ci + ->set($tokenInfo['token']) + ->expiresAt(new \DateTime($tokenInfo['expire'])); + $this->cache->save($ci); + } + return $ci->get(); + } +} diff --git a/src/Storage/TokenStorageInterface.php b/src/Storage/TokenStorageInterface.php new file mode 100644 index 0000000..e16dab7 --- /dev/null +++ b/src/Storage/TokenStorageInterface.php @@ -0,0 +1,14 @@ + $scenarios, + ]; + if ($this->configs['auth_type'] === Constants::AUTH_KEY) { + $data['machine_id'] = $this->configs['machine_id']; + $data['password'] = $this->configs['password']; + } + + return $this->manageRequest( + 'POST', + Constants::WATCHER_LOGIN_ENDPOINT, + $data + ); + } +} diff --git a/tests/Integration/AlertsClientTest.php b/tests/Integration/AlertsClientTest.php new file mode 100644 index 0000000..57d954d --- /dev/null +++ b/tests/Integration/AlertsClientTest.php @@ -0,0 +1,406 @@ +useTls = (string)getenv('BOUNCER_TLS_PATH'); + + $bouncerConfigs = [ + 'auth_type' => $this->useTls ? Constants::AUTH_TLS : Constants::AUTH_KEY, + 'api_key' => getenv('BOUNCER_KEY'), + 'api_url' => getenv('LAPI_URL'), + 'appsec_url' => getenv('APPSEC_URL'), + 'user_agent_suffix' => TestConstants::USER_AGENT_SUFFIX, + ]; + if ($this->useTls) { + $this->addTlsConfig($bouncerConfigs, $this->useTls); + } + + $this->configs = $bouncerConfigs; + + $watcher = new WatcherClient($this->configs); + $tokenStorage = new TokenStorage($watcher, new ArrayAdapter()); + $this->alertsClient = new AlertsClient($this->configs, $tokenStorage); + } + + /** + * @covers ::delete + */ + public function testDelete(): void + { + self::expectException(\RuntimeException::class); + $this->alertsClient->delete([]); + } + + /** + * @covers ::push + */ + public function testPush(): array + { + $now = new \DateTimeImmutable(); + $alert01 = new Alert( + [ + 'scenario' => 'crowdsec-lapi-test/with-decision', + 'scenario_hash' => 'alert01', + 'scenario_version' => '1.0', + 'message' => 'alert01', + 'events_count' => 3, + 'start_at' => $now->format(self::DT_FORMAT), + 'stop_at' => $now + ->add(new \DateInterval('PT4H')) + ->format(self::DT_FORMAT), + 'capacity' => 10, + 'leakspeed' => '10/1s', + 'simulated' => false, + 'remediation' => false, + ], + // source + [ + 'scope' => 'ip', + 'value' => '1.1.0.1', + 'as_number' => 'AS12345', + 'as_name' => 'EXAMPLE-AS', + 'cn' => 'US', + 'latitude' => 40.7128, + 'longitude' => -74.0060, + ], + // events + [ + [ + 'meta' => [ + ['key' => 'path', 'value' => '/alert11'], + ], + 'timestamp' => $now->format(self::DT_FORMAT), + ], + ], + // decisions + [ + [ + 'origin' => 'lapi', + 'type' => 'ban', + 'scope' => 'ip', + 'value' => '1.1.0.1', + 'duration' => '4h', + 'until' => $now + ->add(new \DateInterval('PT4H')) + ->format(self::DT_FORMAT), + 'scenario' => 'crowdsec-lapi-test/with-decision', + ], + ], + [ + ['key' => 'service', 'value' => 'phpunit'], + ], + ['http', 'probing'] + ); + $alert02 = new Alert( + [ + 'scenario' => 'crowdsec-lapi-test/with-decision', + 'scenario_hash' => 'alert02', + 'scenario_version' => '1.0', + 'message' => 'alert02', + 'events_count' => 3, + 'start_at' => $now->format(self::DT_FORMAT), + 'stop_at' => $now + ->add(new \DateInterval('PT4H')) + ->format(self::DT_FORMAT), + 'capacity' => 10, + 'leakspeed' => '10/1s', + 'simulated' => true, + 'remediation' => true, + ], + // source + [ + 'scope' => 'range', + 'value' => '1.1.0.0/16', + 'as_number' => 'AS12345', + 'as_name' => 'EXAMPLE-AS', + 'cn' => 'US', + 'latitude' => 40.7128, + 'longitude' => -74.0060, + ], + // events + [ + [ + 'meta' => [ + ['key' => 'path', 'value' => '/alert12'], + ], + 'timestamp' => $now->format(self::DT_FORMAT), + ], + ], + // decisions + [ + [ + 'origin' => 'phpunit', + 'type' => 'captcha', + 'scope' => 'range', + 'value' => '1.1.0.0/16', + 'duration' => '4h', + 'until' => $now + ->add(new \DateInterval('PT4H')) + ->format(self::DT_FORMAT), + 'scenario' => 'crowdsec-lapi-test/with-decision', + ], + ] + ); + $alert11 = new Alert( + [ + 'scenario' => 'crowdsec-lapi-test/integration11', + 'scenario_hash' => 'alert11', + 'scenario_version' => '1.0', + 'message' => 'alert10', + 'events_count' => 3, + 'start_at' => $now->format(self::DT_FORMAT), + 'stop_at' => $now + ->add(new \DateInterval('PT4H')) + ->format(self::DT_FORMAT), + 'capacity' => 11, + 'leakspeed' => '10/2s', + 'simulated' => false, + 'remediation' => false, + ], + // source + [ + 'scope' => 'ip', + 'value' => '2.0.1.1', + 'as_number' => 'AS12345', + 'as_name' => 'EXAMPLE-AS', + 'cn' => 'US', + 'latitude' => 40.7128, + 'longitude' => -74.0060, + ], + // events + [ + [ + 'meta' => [ + ['key' => 'path', 'value' => '/alert21'], + ], + 'timestamp' => $now->format(self::DT_FORMAT), + ], + ] + ); + $alert12 = new Alert( + [ + 'scenario' => 'crowdsec-lapi-test/integration12', + 'scenario_hash' => 'alert12', + 'scenario_version' => '1.0', + 'message' => 'alert12', + 'events_count' => 3, + 'start_at' => $now->format(self::DT_FORMAT), + 'stop_at' => $now + ->add(new \DateInterval('PT4H')) + ->format(self::DT_FORMAT), + 'capacity' => 12, + 'leakspeed' => '10/2s', + 'simulated' => true, + 'remediation' => true, + ], + // source + [ + 'scope' => 'range', + 'value' => '2.0.0.0/16', + 'as_number' => 'AS12345', + 'as_name' => 'EXAMPLE-AS', + 'cn' => 'US', + 'latitude' => 40.7128, + 'longitude' => -74.0060, + ], + // events + [ + [ + 'meta' => [ + ['key' => 'path', 'value' => '/alert21'], + ], + 'timestamp' => $now->format(self::DT_FORMAT), + ], + ] + ); + $result = $this->alertsClient->push([ + // with decisions + $alert01, + $alert02, + // without decisions + $alert11, + $alert12, + ]); + self::assertIsArray($result); + self::assertCount(4, $result); + return $result; + } + + /** + * @covers ::search + * @depends testPush + * @dataProvider searchProvider + */ + public function testSearch(array $query, int $expectedCount): void + { + $result = $this->alertsClient->search($query); + self::assertCount($expectedCount, $result); + } + + public static function searchProvider(): iterable + { + yield 'empty' => [ + [], + 4 + ]; + + yield 'ip - no' => [ + ['ip' => '19.17.11.7'], + 0 + ]; + + yield 'ip - 1.1.0.1' => [ + ['ip' => '1.1.0.1'], // alert01 (scope=ip;value=1.1.0.1 +decision) and alert02(scope=range;value=1.1.0.0/16 +decision) + 2 + ]; + yield 'ip - 2.0.1.1' => [ + ['ip' => '2.0.1.1'], // alert12 (range no decision) + 1 + ]; + + yield 'scope - ip' => [ + ['scope' => 'ip'], + 2, + ]; + yield 'scope - range' => [ + ['scope' => 'range'], + 2, + ]; + + yield 'scope - ip:1.1.0.1' => [ + ['scope' => 'ip', 'value' => '1.1.0.1'], + 1, + ]; + + yield 'scenario' => [ + ['scenario' => 'crowdsec-lapi-test/with-decision'], + 2 + ]; + + yield 'has_active_decision=true' => [ + ['has_active_decision' => 'true'], + 0, + ]; + + yield 'has_active_decision=false' => [ + ['has_active_decision' => 'false'], + 1, //crowdsec-lapi-test/integration11 + ]; +// TODO: why 4 byt not 2 ? +// yield 'simulated=true' => [ +// ['simulated' => 'true'], +// 4, +// ]; + yield 'simulated=false' => [ + ['simulated' => 'false'], + 2, + ]; + + yield 'since -1h' => [ + [ + 'since' => '-1h', + ], + 0, + ]; + yield 'since 1s' => [ + ['since' => '1s'], + 0, + ]; + yield 'since 1h' => [ + ['since' => '10h'], + 4, + ]; + + yield 'until -1h' => [ + ['until' => '-1h'], + 4, + ]; + yield 'until 1s' => [ + ['until' => '1s'], + 4, + ]; + yield 'until 1h' => [ + ['until' => '1h'], + 0, + ]; + yield 'until 10h' => [ + ['until' => '10h'], + 0, + ]; + yield 'until 100h' => [ + ['until' => '10h'], + 0, + ]; + + yield 'origin=phpunit' => [ + ['origin' => 'phpunit'], + 1, + ]; + yield 'decision_type=ban' => [ + ['decision_type' => 'ban'], + 2, + ]; + } + + /** + * @depends testPush + */ + public function testGetById(array $idList): void + { + foreach ($idList as $id) { + self::assertIsNumeric($id); + $result = $this->alertsClient->getById(\intval($id)); + self::assertIsArray($result); + } + } + + public function testAlertInfoNotFound(): void + { + $result = $this->alertsClient->getById(PHP_INT_MAX); + self::assertNull($result); + } +} diff --git a/tests/Integration/BouncerTest.php b/tests/Integration/BouncerTest.php index 175d5e1..b4b15a9 100644 --- a/tests/Integration/BouncerTest.php +++ b/tests/Integration/BouncerTest.php @@ -36,7 +36,7 @@ final class BouncerTest extends TestCase */ protected $useTls; /** - * @var WatcherClient + * @var TestWatcherClient */ protected $watcherClient; @@ -64,7 +64,7 @@ protected function setUp(): void } $this->configs = $bouncerConfigs; - $this->watcherClient = new WatcherClient($this->configs); + $this->watcherClient = new TestWatcherClient($this->configs); // Delete all decisions $this->watcherClient->deleteAllDecisions(); usleep(200000); // 200ms diff --git a/tests/Integration/WatcherClient.php b/tests/Integration/TestWatcherClient.php similarity index 93% rename from tests/Integration/WatcherClient.php rename to tests/Integration/TestWatcherClient.php index e36323b..c36b943 100644 --- a/tests/Integration/WatcherClient.php +++ b/tests/Integration/TestWatcherClient.php @@ -7,11 +7,10 @@ use CrowdSec\Common\Client\AbstractClient; use CrowdSec\LapiClient\ClientException; use CrowdSec\LapiClient\Constants; +use CrowdSec\LapiClient\WatcherClient; -class WatcherClient extends AbstractClient +class TestWatcherClient extends AbstractClient { - public const WATCHER_LOGIN_ENDPOINT = '/v1/watchers/login'; - public const WATCHER_DECISIONS_ENDPOINT = '/v1/decisions'; public const WATCHER_ALERT_ENDPOINT = '/v1/alerts'; @@ -25,6 +24,9 @@ class WatcherClient extends AbstractClient */ protected $headers = []; + /** @var WatcherClient */ + protected $watcher; + public function __construct(array $configs) { $this->configs = $configs; @@ -38,6 +40,8 @@ public function __construct(array $configs) $this->configs['tls_key_path'] = $agentTlsPath . '/agent-key.pem'; $this->configs['tls_verify_peer'] = false; + $this->watcher = new WatcherClient($this->configs); + parent::__construct($this->configs); } @@ -96,15 +100,7 @@ public function setSecondState(): void private function ensureLogin(): void { if (!$this->token) { - $data = [ - 'scenarios' => [], - ]; - $credentials = $this->manageRequest( - 'POST', - self::WATCHER_LOGIN_ENDPOINT, - $data - ); - + $credentials = $this->watcher->login(); $this->token = $credentials['token']; $this->headers['Authorization'] = 'Bearer ' . $this->token; } @@ -160,8 +156,7 @@ public function addDecision( 'value' => $value, ], ], - 'events' => [ - ], + 'events' => [], 'events_count' => 1, 'labels' => null, 'leakspeed' => '0', diff --git a/tests/Integration/WatcherClientTest.php b/tests/Integration/WatcherClientTest.php new file mode 100644 index 0000000..eb62547 --- /dev/null +++ b/tests/Integration/WatcherClientTest.php @@ -0,0 +1,76 @@ + getenv('LAPI_URL'), + 'appsec_url' => getenv('APPSEC_URL'), + 'user_agent_suffix' => TestConstants::USER_AGENT_SUFFIX, + 'auth_type' => Constants::AUTH_TLS, + 'tls_cert_path' => "{$agentTlsPath}/agent.pem", + 'tls_key_path' => "{$agentTlsPath}/agent-key.pem", + 'tls_verify_peer' => false, + ]; + + $watcher = new WatcherClient($bouncerConfigs); + self::assertLoginResult($watcher->login()); + } + + public function testLoginApiKey(): void + { + $machineId = getenv('MACHINE_ID') ?: 'watcherLogin'; + $password = getenv('PASSWORD') ?: 'watcherPassword'; + + $bouncerConfigs = [ + 'api_url' => getenv('LAPI_URL'), + 'appsec_url' => getenv('APPSEC_URL'), + 'user_agent_suffix' => TestConstants::USER_AGENT_SUFFIX, + 'auth_type' => Constants::AUTH_KEY, + 'api_key' => getenv('BOUNCER_KEY'), + 'machine_id' => $machineId, + 'password' => $password, + ]; + + $watcher = new WatcherClient($bouncerConfigs); + self::assertLoginResult($watcher->login()); + } + + private static function assertLoginResult(array $data): void + { + self::assertArrayHasKey('code', $data); + self::assertArrayHasKey('expire', $data); + self::assertArrayHasKey('token', $data); + + self::assertSame(200, $data['code']); + self::assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/', $data['expire']); + // JWT + $parts = explode('.', $data['token']); + self::assertCount(3, $parts); + $payloadStr = \base64_decode($parts[1]); + self::assertNotSame(false, $payloadStr); + $payload = \json_decode($payloadStr, true); + self::assertNotEmpty($payload); + self::assertArrayHasKey('exp', $payload); + self::assertTrue(\is_int($payload['exp'])); + self::assertArrayHasKey('id', $payload); + self::assertTrue(\is_string($payload['id'])); + self::assertArrayHasKey('orig_iat', $payload); + self::assertTrue(\is_int($payload['orig_iat'])); + } +} diff --git a/tests/Unit/FileGetContentsTest.php b/tests/Unit/FileGetContentsTest.php index b034b88..27ea5b7 100644 --- a/tests/Unit/FileGetContentsTest.php +++ b/tests/Unit/FileGetContentsTest.php @@ -17,7 +17,6 @@ * @license MIT License */ -use CrowdSec\Common\Client\HttpMessage\Request; use CrowdSec\LapiClient\Bouncer; use CrowdSec\LapiClient\Tests\MockedData; use CrowdSec\LapiClient\TimeoutException; diff --git a/tests/Unit/Payload/AlertTest.php b/tests/Unit/Payload/AlertTest.php new file mode 100644 index 0000000..22fc26e --- /dev/null +++ b/tests/Unit/Payload/AlertTest.php @@ -0,0 +1,93 @@ +toArray()); + } + + public function dpConstruct(): iterable + { + $base = [ + 'scenario' => 'crowdsecurity/http-probing', + 'scenario_hash' => 'abc123', + 'scenario_version' => '1.0', + 'message' => 'Probing detected', + 'events_count' => 3, + 'start_at' => '2025-01-01T00:00:00Z', + 'stop_at' => '2025-01-01T00:10:00Z', + 'capacity' => 10, + 'leakspeed' => '10/1s', + 'simulated' => false, + 'remediation' => true, + 'source' => [ + 'scope' => 'ip', + 'value' => '1.2.3.4', + 'ip' => '1.2.3.4', + 'range' => '1.2.3.4/32', + 'as_number' => 'AS12345', + 'as_name' => 'EXAMPLE-AS', + 'cn' => 'US', + 'latitude' => 40.7128, + 'longitude' => -74.0060, + ], + 'decisions' => [ + [ + 'origin' => 'lapi', + 'type' => 'ban', + 'scope' => 'ip', + 'value' => '1.2.3.4', + 'duration' => '4h', + 'until' => '2025-01-01T04:00:00Z', + 'scenario' => 'crowdsecurity/http-probing', + ], + ], + 'events' => [ + [ + 'meta' => [ + ['key' => 'path', 'value' => '/admin'], + ], + 'timestamp' => '2025-01-01T00:00:01Z', + ], + ], + 'meta' => [ + ['key' => 'service', 'value' => 'nginx'], + ], + 'labels' => ['http', 'probing'], + ]; + yield 'full example' => [ + $base, + $base, + ]; + + $minimal = $base; + unset( + $minimal['event'], + $minimal['decisions'], + $minimal['source'], + $minimal['meta'], + $minimal['labels'], + ); + yield 'minimal example' => [ + $minimal, + $minimal, + ]; + } +} diff --git a/tests/Unit/Storage/TokenStorageTest.php b/tests/Unit/Storage/TokenStorageTest.php new file mode 100644 index 0000000..ece5d73 --- /dev/null +++ b/tests/Unit/Storage/TokenStorageTest.php @@ -0,0 +1,34 @@ +createMock(WatcherClient::class); + $expire = time() + 3600; + $watcher + ->expects(self::once()) + ->method('login') + ->willReturn([ + 'code' => 200, + 'expire' => $expire, + 'token' => 'j.w.t', + ]); + $cache = new ArrayAdapter(); + $storage = new TokenStorage($watcher, $cache); + self::assertSame('j.w.t', $storage->retrieveToken()); + self::assertTrue($cache->hasItem('crowdsec_token')); + $ci = $cache->getItem('crowdsec_token'); + self::assertSame('j.w.t', $ci->get()); + } +} From 491b40ddc8f9aa4fe996c52e6a764500244b978b Mon Sep 17 00:00:00 2001 From: Alexander Strizhak Date: Fri, 7 Nov 2025 15:46:19 +0300 Subject: [PATCH 02/43] try to fix phpmd --- src/Storage/TokenStorage.php | 3 ++- src/WatcherClient.php | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Storage/TokenStorage.php b/src/Storage/TokenStorage.php index 0c629f8..5d189d0 100644 --- a/src/Storage/TokenStorage.php +++ b/src/Storage/TokenStorage.php @@ -5,6 +5,7 @@ namespace CrowdSec\LapiClient\Storage; use CrowdSec\LapiClient\WatcherClient; +use DateTime; use Psr\Cache\CacheItemPoolInterface; final class TokenStorage implements TokenStorageInterface @@ -44,7 +45,7 @@ public function retrieveToken(): ?string \assert(!empty($tokenInfo['token'])); $ci ->set($tokenInfo['token']) - ->expiresAt(new \DateTime($tokenInfo['expire'])); + ->expiresAt(new DateTime($tokenInfo['expire'])); $this->cache->save($ci); } return $ci->get(); diff --git a/src/WatcherClient.php b/src/WatcherClient.php index f644936..bc37373 100644 --- a/src/WatcherClient.php +++ b/src/WatcherClient.php @@ -5,6 +5,7 @@ namespace CrowdSec\LapiClient; use CrowdSec\Common\Client\RequestHandler\RequestHandlerInterface; +use LogicException; use Psr\Log\LoggerInterface; /** @@ -25,7 +26,7 @@ public function __construct( ) { if ($configs['auth_type'] === Constants::AUTH_KEY) { if (empty($configs['machine_id']) || empty($configs['password'])) { - throw new \LogicException('Missing required config: machine_id or password.'); + throw new LogicException('Missing required config: machine_id or password.'); } } From 7edec8e26a426706f450d7739bf21bbc50210666 Mon Sep 17 00:00:00 2001 From: Alexander Strizhak Date: Fri, 7 Nov 2025 16:12:49 +0300 Subject: [PATCH 03/43] up --- src/Payload/Alert.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Payload/Alert.php b/src/Payload/Alert.php index 15e45e7..52673ca 100644 --- a/src/Payload/Alert.php +++ b/src/Payload/Alert.php @@ -230,7 +230,7 @@ private function handleList(Processor $processor, AbstractConfiguration $param, return $result; } - public function jsonSerialize() + public function jsonSerialize(): mixed { return $this->toArray(); } From 46c399d499b9c83ad676ee09e9bb645dcdd33139 Mon Sep 17 00:00:00 2001 From: Alexander Strizhak Date: Fri, 7 Nov 2025 16:18:49 +0300 Subject: [PATCH 04/43] up --- src/WatcherClient.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/WatcherClient.php b/src/WatcherClient.php index bc37373..8617399 100644 --- a/src/WatcherClient.php +++ b/src/WatcherClient.php @@ -43,8 +43,8 @@ public function login(array $scenarios = []): array 'scenarios' => $scenarios, ]; if ($this->configs['auth_type'] === Constants::AUTH_KEY) { - $data['machine_id'] = $this->configs['machine_id']; - $data['password'] = $this->configs['password']; + $data['machine_id'] = $this->configs['machine_id'] ?? ''; + $data['password'] = $this->configs['password'] ?? ''; } return $this->manageRequest( From 444dd536ddb571a1a3e3ac07d5fac5b37857428d Mon Sep 17 00:00:00 2001 From: Alexander Strizhak Date: Fri, 7 Nov 2025 16:20:05 +0300 Subject: [PATCH 05/43] up --- src/Payload/Alert.php | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/Payload/Alert.php b/src/Payload/Alert.php index 52673ca..5472f07 100644 --- a/src/Payload/Alert.php +++ b/src/Payload/Alert.php @@ -157,15 +157,11 @@ public static function fromArray(array $data): self public function toArray(): array { $result = $this->properties; + $result['events'] = $this->events; + $result['source'] = $this->source; if ([] !== $this->decisions) { $result['decisions'] = $this->decisions; } - if ([] !== $this->events) { - $result['events'] = $this->events; - } - if (null !== $this->source) { - $result['source'] = $this->source; - } if ([] !== $this->meta) { $result['meta'] = $this->meta; } From 046dc7807ff366f2f7c5a0dfc00c1cfd1d90a397 Mon Sep 17 00:00:00 2001 From: Alexander Strizhak Date: Fri, 7 Nov 2025 16:27:16 +0300 Subject: [PATCH 06/43] up --- src/Payload/Alert.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Payload/Alert.php b/src/Payload/Alert.php index 5472f07..55b933e 100644 --- a/src/Payload/Alert.php +++ b/src/Payload/Alert.php @@ -217,6 +217,9 @@ private function configureMetaList(Processor $processor, array $list): void $this->meta = $this->handleList($processor, new Meta(), $list); } + /** + * @return list + */ private function handleList(Processor $processor, AbstractConfiguration $param, array $list): array { $result = []; From a782589d5f410d913b90f0045188b7e6a19fc11c Mon Sep 17 00:00:00 2001 From: Alexander Strizhak Date: Fri, 7 Nov 2025 16:31:02 +0300 Subject: [PATCH 07/43] up --- src/Payload/Alert.php | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/Payload/Alert.php b/src/Payload/Alert.php index 55b933e..5472f07 100644 --- a/src/Payload/Alert.php +++ b/src/Payload/Alert.php @@ -217,9 +217,6 @@ private function configureMetaList(Processor $processor, array $list): void $this->meta = $this->handleList($processor, new Meta(), $list); } - /** - * @return list - */ private function handleList(Processor $processor, AbstractConfiguration $param, array $list): array { $result = []; From dc0ce78e71c4ecc746617f6cec8d97214a969477 Mon Sep 17 00:00:00 2001 From: Alexander Strizhak Date: Fri, 7 Nov 2025 16:31:35 +0300 Subject: [PATCH 08/43] up --- src/Storage/TokenStorage.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Storage/TokenStorage.php b/src/Storage/TokenStorage.php index 5d189d0..b577d1d 100644 --- a/src/Storage/TokenStorage.php +++ b/src/Storage/TokenStorage.php @@ -42,7 +42,7 @@ public function retrieveToken(): ?string if (200 !== $tokenInfo['code']) { return null; } - \assert(!empty($tokenInfo['token'])); + \assert(isset($tokenInfo['token'])); $ci ->set($tokenInfo['token']) ->expiresAt(new DateTime($tokenInfo['expire'])); From cb0e8b2ab07ffea80d1f16f9866fda8aaa3f9801 Mon Sep 17 00:00:00 2001 From: Alexander Strizhak Date: Fri, 7 Nov 2025 16:46:02 +0300 Subject: [PATCH 09/43] try to satisfy psalm --- src/Payload/Alert.php | 4 ++-- src/WatcherClient.php | 2 +- tools/coding-standards/psalm/psalm.xml | 9 +++++++++ 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/Payload/Alert.php b/src/Payload/Alert.php index 5472f07..0944cea 100644 --- a/src/Payload/Alert.php +++ b/src/Payload/Alert.php @@ -131,7 +131,7 @@ public function __construct( $this->configureSource($processor, $source); $this->configureDecisions($processor, $decisions); $this->configureEvents($processor, $events); - $this->configureMetaList($processor, $meta); + $this->configureMeta($processor, $meta); $this->labels = \array_filter($labels); } @@ -212,7 +212,7 @@ private function configureEvents(Processor $processor, array $list): void /** * @param list $list */ - private function configureMetaList(Processor $processor, array $list): void + private function configureMeta(Processor $processor, array $list): void { $this->meta = $this->handleList($processor, new Meta(), $list); } diff --git a/src/WatcherClient.php b/src/WatcherClient.php index 8617399..41a7fc4 100644 --- a/src/WatcherClient.php +++ b/src/WatcherClient.php @@ -42,7 +42,7 @@ public function login(array $scenarios = []): array $data = [ 'scenarios' => $scenarios, ]; - if ($this->configs['auth_type'] === Constants::AUTH_KEY) { + if (isset($this->configs['auth_type']) && $this->configs['auth_type'] === Constants::AUTH_KEY) { $data['machine_id'] = $this->configs['machine_id'] ?? ''; $data['password'] = $this->configs['password'] ?? ''; } diff --git a/tools/coding-standards/psalm/psalm.xml b/tools/coding-standards/psalm/psalm.xml index a7780fa..7e6e52c 100644 --- a/tools/coding-standards/psalm/psalm.xml +++ b/tools/coding-standards/psalm/psalm.xml @@ -25,5 +25,14 @@ + + + + + + + + + From 10f445d307390b6888d9a0b07233ce64fe51d17b Mon Sep 17 00:00:00 2001 From: Alexander Strizhak Date: Fri, 7 Nov 2025 17:28:29 +0300 Subject: [PATCH 10/43] try to satisfy psalm --- tools/coding-standards/psalm/psalm.xml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tools/coding-standards/psalm/psalm.xml b/tools/coding-standards/psalm/psalm.xml index 7e6e52c..ceb459d 100644 --- a/tools/coding-standards/psalm/psalm.xml +++ b/tools/coding-standards/psalm/psalm.xml @@ -27,11 +27,11 @@ - - - - - + + + + + From 9cbce91cf4472482b0f1b3c0e4c9f4315ebcdd2f Mon Sep 17 00:00:00 2001 From: Alexander Strizhak Date: Fri, 7 Nov 2025 18:44:08 +0300 Subject: [PATCH 11/43] try to satisfy psalm --- tools/coding-standards/psalm/psalm.xml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tools/coding-standards/psalm/psalm.xml b/tools/coding-standards/psalm/psalm.xml index ceb459d..8bb3696 100644 --- a/tools/coding-standards/psalm/psalm.xml +++ b/tools/coding-standards/psalm/psalm.xml @@ -27,11 +27,7 @@ - - - - - + From b64740f2358fb7e3302bed05b5730fa50acff092 Mon Sep 17 00:00:00 2001 From: Alexander Strizhak Date: Fri, 7 Nov 2025 18:47:03 +0300 Subject: [PATCH 12/43] try to satisfy psalm --- tools/coding-standards/psalm/psalm.xml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tools/coding-standards/psalm/psalm.xml b/tools/coding-standards/psalm/psalm.xml index 8bb3696..0c26b8c 100644 --- a/tools/coding-standards/psalm/psalm.xml +++ b/tools/coding-standards/psalm/psalm.xml @@ -27,7 +27,10 @@ - + + + + From 4caf39ed12fd844c61783c3d33fb7c96e886e7e3 Mon Sep 17 00:00:00 2001 From: Alexander Strizhak Date: Sat, 8 Nov 2025 23:46:57 +0300 Subject: [PATCH 13/43] try to satisfy psalm --- src/Payload/Alert.php | 8 ++++---- tools/coding-standards/psalm/psalm.xml | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Payload/Alert.php b/src/Payload/Alert.php index 0944cea..99cb5fa 100644 --- a/src/Payload/Alert.php +++ b/src/Payload/Alert.php @@ -73,9 +73,9 @@ * remediation: bool, * source: TSource, * events: list, - * decisions: list, - * meta: list, - * labels: list + * decisions?: list, + * meta?: list, + * labels?: list * } */ class Alert implements \JsonSerializable @@ -157,8 +157,8 @@ public static function fromArray(array $data): self public function toArray(): array { $result = $this->properties; - $result['events'] = $this->events; $result['source'] = $this->source; + $result['events'] = $this->events; if ([] !== $this->decisions) { $result['decisions'] = $this->decisions; } diff --git a/tools/coding-standards/psalm/psalm.xml b/tools/coding-standards/psalm/psalm.xml index 0c26b8c..ceb459d 100644 --- a/tools/coding-standards/psalm/psalm.xml +++ b/tools/coding-standards/psalm/psalm.xml @@ -27,6 +27,7 @@ + From 7371ba58fe8680fd2f19408adab1e09152c70f2b Mon Sep 17 00:00:00 2001 From: Alexander Strizhak Date: Sat, 8 Nov 2025 23:50:41 +0300 Subject: [PATCH 14/43] try to satisfy psalm --- tools/coding-standards/psalm/psalm.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tools/coding-standards/psalm/psalm.xml b/tools/coding-standards/psalm/psalm.xml index ceb459d..16ac56d 100644 --- a/tools/coding-standards/psalm/psalm.xml +++ b/tools/coding-standards/psalm/psalm.xml @@ -34,5 +34,10 @@ + + + + + From 31ccc2e2a156f0918ad904b08cfc7abc6ebc08ff Mon Sep 17 00:00:00 2001 From: Alexander Strizhak Date: Sat, 8 Nov 2025 23:52:01 +0300 Subject: [PATCH 15/43] try to satisfy psalm --- src/Configuration/Alert.php | 2 +- src/Configuration/Alert/Decision.php | 2 +- src/Configuration/Alert/Event.php | 2 +- src/Configuration/Alert/Meta.php | 2 +- src/Configuration/Alert/Source.php | 2 +- src/Configuration/Metrics.php | 2 +- src/Configuration/Metrics/Items.php | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Configuration/Alert.php b/src/Configuration/Alert.php index eeae807..c210036 100644 --- a/src/Configuration/Alert.php +++ b/src/Configuration/Alert.php @@ -10,7 +10,7 @@ class Alert extends AbstractConfiguration { - /** @var list The list of each configuration tree key */ + /** @var string[] The list of each configuration tree key */ protected $keys = [ 'scenario', 'scenario_hash', diff --git a/src/Configuration/Alert/Decision.php b/src/Configuration/Alert/Decision.php index 5927275..9618b6d 100644 --- a/src/Configuration/Alert/Decision.php +++ b/src/Configuration/Alert/Decision.php @@ -7,7 +7,7 @@ class Decision extends AbstractConfiguration { - /** @var list The list of each configuration tree key */ + /** @var string[] The list of each configuration tree key */ protected $keys = [ 'origin', 'type', diff --git a/src/Configuration/Alert/Event.php b/src/Configuration/Alert/Event.php index 75f4750..09db054 100644 --- a/src/Configuration/Alert/Event.php +++ b/src/Configuration/Alert/Event.php @@ -7,7 +7,7 @@ class Event extends AbstractConfiguration { - /** @var list The list of each configuration tree key */ + /** @var string[] The list of each configuration tree key */ protected $keys = [ 'meta', 'timestamp', diff --git a/src/Configuration/Alert/Meta.php b/src/Configuration/Alert/Meta.php index e56b064..ea14e05 100644 --- a/src/Configuration/Alert/Meta.php +++ b/src/Configuration/Alert/Meta.php @@ -7,7 +7,7 @@ class Meta extends AbstractConfiguration { - /** @var list The list of each configuration tree key */ + /** @var string[] The list of each configuration tree key */ protected $keys = [ 'key', 'value', diff --git a/src/Configuration/Alert/Source.php b/src/Configuration/Alert/Source.php index 8b4215d..70542e1 100644 --- a/src/Configuration/Alert/Source.php +++ b/src/Configuration/Alert/Source.php @@ -7,7 +7,7 @@ class Source extends AbstractConfiguration { - /** @var list The list of each configuration tree key */ + /** @var string[] The list of each configuration tree key */ protected $keys = [ 'scope', 'value', diff --git a/src/Configuration/Metrics.php b/src/Configuration/Metrics.php index 279e7d2..80f46ab 100644 --- a/src/Configuration/Metrics.php +++ b/src/Configuration/Metrics.php @@ -21,7 +21,7 @@ */ class Metrics extends AbstractConfiguration { - /** @var list The list of each configuration tree key */ + /** @var string[] The list of each configuration tree key */ protected $keys = [ 'name', 'type', diff --git a/src/Configuration/Metrics/Items.php b/src/Configuration/Metrics/Items.php index 4b698e2..e01a646 100644 --- a/src/Configuration/Metrics/Items.php +++ b/src/Configuration/Metrics/Items.php @@ -20,7 +20,7 @@ */ class Items extends AbstractConfiguration { - /** @var list The list of each configuration tree key */ + /** @var string[] The list of each configuration tree key */ protected $keys = [ 'name', 'value', From be4a9906bfa74f334af17db92cfe58b840f452db Mon Sep 17 00:00:00 2001 From: Alexander Strizhak Date: Sun, 9 Nov 2025 00:01:37 +0300 Subject: [PATCH 16/43] try to satisfy psalm --- src/AbstractLapiClient.php | 4 ++-- src/Configuration.php | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/AbstractLapiClient.php b/src/AbstractLapiClient.php index becbc3f..967cbc3 100644 --- a/src/AbstractLapiClient.php +++ b/src/AbstractLapiClient.php @@ -15,7 +15,7 @@ abstract class AbstractLapiClient extends AbstractClient { /** - * @var TBouncerConfig + * @var array|TBouncerConfig */ protected $configs; /** @@ -30,7 +30,7 @@ public function __construct( ) { $this->configure($configs); $this->headers = [Constants::HEADER_LAPI_USER_AGENT => $this->formatUserAgent($this->configs)]; - if (!empty($this->configs['api_key'])) { + if (isset($this->configs['api_key'])) { $this->headers[Constants::HEADER_LAPI_API_KEY] = $this->configs['api_key']; } parent::__construct($this->configs, $requestHandler, $logger); diff --git a/src/Configuration.php b/src/Configuration.php index 82aac1a..72d4505 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -40,7 +40,7 @@ */ class Configuration extends AbstractConfiguration { - /** @var list The list of each configuration tree key */ + /** @var string[] The list of each configuration tree key */ protected $keys = [ 'user_agent_suffix', 'user_agent_version', From 6d2f3b7d06c1a4411f764b0e10a19db0372ef3c6 Mon Sep 17 00:00:00 2001 From: Alexander Strizhak Date: Sun, 9 Nov 2025 00:02:25 +0300 Subject: [PATCH 17/43] try to satisfy psalm --- src/Payload/Alert.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Payload/Alert.php b/src/Payload/Alert.php index 99cb5fa..1a4e7af 100644 --- a/src/Payload/Alert.php +++ b/src/Payload/Alert.php @@ -137,7 +137,6 @@ public function __construct( /** * @param TAlertFull $data - * @return void */ public static function fromArray(array $data): self { From c3cdd1797acb4012eb632390a457aa06b2ebc7a2 Mon Sep 17 00:00:00 2001 From: Alexander Strizhak Date: Sun, 9 Nov 2025 00:09:51 +0300 Subject: [PATCH 18/43] try to satisfy psalm --- src/Configuration/Metrics/Items.php | 4 ++-- src/Payload/Alert.php | 2 +- tools/coding-standards/psalm/psalm.xml | 3 ++- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Configuration/Metrics/Items.php b/src/Configuration/Metrics/Items.php index e01a646..e8a67a5 100644 --- a/src/Configuration/Metrics/Items.php +++ b/src/Configuration/Metrics/Items.php @@ -63,13 +63,13 @@ public function getConfigTreeBuilder(): TreeBuilder ->variableNode('labels') // Remove empty labels totally ->beforeNormalization() - ->ifTrue(function ($value) { + ->ifTrue(function (mixed $value) { return empty($value); }) ->thenUnset() ->end() ->validate() - ->ifTrue(function ($value) { + ->ifTrue(function (mixed $value) { // Ensure all values in the array are strings if (!is_array($value)) { return true; diff --git a/src/Payload/Alert.php b/src/Payload/Alert.php index 1a4e7af..f52622a 100644 --- a/src/Payload/Alert.php +++ b/src/Payload/Alert.php @@ -132,7 +132,7 @@ public function __construct( $this->configureDecisions($processor, $decisions); $this->configureEvents($processor, $events); $this->configureMeta($processor, $meta); - $this->labels = \array_filter($labels); + $this->labels = $labels; } /** diff --git a/tools/coding-standards/psalm/psalm.xml b/tools/coding-standards/psalm/psalm.xml index 16ac56d..252d95b 100644 --- a/tools/coding-standards/psalm/psalm.xml +++ b/tools/coding-standards/psalm/psalm.xml @@ -18,6 +18,7 @@ + @@ -31,7 +32,7 @@ - + From 7d671210b2284ba6f66e9b6db4f59551ecba2852 Mon Sep 17 00:00:00 2001 From: Alexander Strizhak Date: Sun, 9 Nov 2025 00:36:05 +0300 Subject: [PATCH 19/43] try to satisfy psalm --- src/AbstractLapiClient.php | 2 +- tools/coding-standards/psalm/psalm.xml | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/AbstractLapiClient.php b/src/AbstractLapiClient.php index 967cbc3..a84f95e 100644 --- a/src/AbstractLapiClient.php +++ b/src/AbstractLapiClient.php @@ -15,7 +15,7 @@ abstract class AbstractLapiClient extends AbstractClient { /** - * @var array|TBouncerConfig + * @var TBouncerConfig */ protected $configs; /** diff --git a/tools/coding-standards/psalm/psalm.xml b/tools/coding-standards/psalm/psalm.xml index 252d95b..98a7615 100644 --- a/tools/coding-standards/psalm/psalm.xml +++ b/tools/coding-standards/psalm/psalm.xml @@ -40,5 +40,10 @@ + + + + + From a6a6b7071b319b17b630ada3a090020aa08cd030 Mon Sep 17 00:00:00 2001 From: Alexander Strizhak Date: Mon, 10 Nov 2025 21:15:24 +0300 Subject: [PATCH 20/43] try to satisfy psalm --- tools/coding-standards/psalm/psalm.xml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tools/coding-standards/psalm/psalm.xml b/tools/coding-standards/psalm/psalm.xml index 98a7615..5a6bb4d 100644 --- a/tools/coding-standards/psalm/psalm.xml +++ b/tools/coding-standards/psalm/psalm.xml @@ -45,5 +45,15 @@ + + + + + + + + + + From 0d31985abb0f74556a8b01242de1711eda11a5a2 Mon Sep 17 00:00:00 2001 From: Alexander Strizhak Date: Mon, 10 Nov 2025 21:21:49 +0300 Subject: [PATCH 21/43] try to satisfy psalm --- tools/coding-standards/psalm/psalm.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/coding-standards/psalm/psalm.xml b/tools/coding-standards/psalm/psalm.xml index 5a6bb4d..65537b5 100644 --- a/tools/coding-standards/psalm/psalm.xml +++ b/tools/coding-standards/psalm/psalm.xml @@ -46,12 +46,12 @@ - + - + From b697bb4a35e6150b19fc3eb3257eadd8d99955c5 Mon Sep 17 00:00:00 2001 From: Alexander Strizhak Date: Mon, 10 Nov 2025 21:26:42 +0300 Subject: [PATCH 22/43] try to satisfy psalm --- tools/coding-standards/psalm/psalm.xml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tools/coding-standards/psalm/psalm.xml b/tools/coding-standards/psalm/psalm.xml index 65537b5..14def45 100644 --- a/tools/coding-standards/psalm/psalm.xml +++ b/tools/coding-standards/psalm/psalm.xml @@ -55,5 +55,20 @@ + + + + + + + + + + + + + + + From f94b4b16774282e761c3b2a75722f191eae5de22 Mon Sep 17 00:00:00 2001 From: Alexander Strizhak Date: Mon, 10 Nov 2025 21:30:13 +0300 Subject: [PATCH 23/43] try to satisfy psalm --- tools/coding-standards/psalm/psalm.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/coding-standards/psalm/psalm.xml b/tools/coding-standards/psalm/psalm.xml index 14def45..e435f73 100644 --- a/tools/coding-standards/psalm/psalm.xml +++ b/tools/coding-standards/psalm/psalm.xml @@ -62,7 +62,7 @@ - + From f95c73eeba389abeba8e507b41543244c7259758 Mon Sep 17 00:00:00 2001 From: Alexander Strizhak Date: Mon, 10 Nov 2025 21:37:10 +0300 Subject: [PATCH 24/43] try to satisfy psalm --- tools/coding-standards/psalm/psalm.xml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tools/coding-standards/psalm/psalm.xml b/tools/coding-standards/psalm/psalm.xml index e435f73..e8ef274 100644 --- a/tools/coding-standards/psalm/psalm.xml +++ b/tools/coding-standards/psalm/psalm.xml @@ -55,6 +55,11 @@ + + + + + @@ -70,5 +75,10 @@ + + + + + From 702ba3a1b19074207e15e03a2ef6a7f294986248 Mon Sep 17 00:00:00 2001 From: Alexander Strizhak Date: Mon, 10 Nov 2025 21:42:46 +0300 Subject: [PATCH 25/43] try to satisfy psalm --- tools/coding-standards/psalm/psalm.xml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tools/coding-standards/psalm/psalm.xml b/tools/coding-standards/psalm/psalm.xml index e8ef274..249b623 100644 --- a/tools/coding-standards/psalm/psalm.xml +++ b/tools/coding-standards/psalm/psalm.xml @@ -32,6 +32,7 @@ + @@ -63,6 +64,7 @@ + @@ -75,6 +77,11 @@ + + + + + From fb8586c180d7d2bb93107cc1bc69218dffbd8b44 Mon Sep 17 00:00:00 2001 From: Alexander Strizhak Date: Mon, 10 Nov 2025 21:49:09 +0300 Subject: [PATCH 26/43] try to satisfy psalm --- tools/coding-standards/psalm/psalm.xml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tools/coding-standards/psalm/psalm.xml b/tools/coding-standards/psalm/psalm.xml index 249b623..557185c 100644 --- a/tools/coding-standards/psalm/psalm.xml +++ b/tools/coding-standards/psalm/psalm.xml @@ -24,6 +24,8 @@ + + @@ -87,5 +89,13 @@ + + + + + + + + From cc31e1a2693fafe3f0f36c4715139b96f1ae8333 Mon Sep 17 00:00:00 2001 From: Alexander Strizhak Date: Mon, 10 Nov 2025 21:54:19 +0300 Subject: [PATCH 27/43] try to satisfy psalm --- tools/coding-standards/psalm/psalm.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/coding-standards/psalm/psalm.xml b/tools/coding-standards/psalm/psalm.xml index 557185c..c89cb61 100644 --- a/tools/coding-standards/psalm/psalm.xml +++ b/tools/coding-standards/psalm/psalm.xml @@ -25,6 +25,7 @@ + From d712b7d69fe8087ec9e27bffb53f0ac474aaac52 Mon Sep 17 00:00:00 2001 From: Alexander Strizhak Date: Mon, 10 Nov 2025 21:55:10 +0300 Subject: [PATCH 28/43] try to satisfy psalm --- src/Storage/TokenStorage.php | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/Storage/TokenStorage.php b/src/Storage/TokenStorage.php index b577d1d..206fdb2 100644 --- a/src/Storage/TokenStorage.php +++ b/src/Storage/TokenStorage.php @@ -24,11 +24,7 @@ final class TokenStorage implements TokenStorageInterface */ private $scenarios; - public function __construct( - WatcherClient $watcher, - CacheItemPoolInterface $cache, - array $scenarios = [] - ) { + public function __construct(WatcherClient $watcher, CacheItemPoolInterface $cache, array $scenarios = []) { $this->watcher = $watcher; $this->cache = $cache; $this->scenarios = $scenarios; From e118d05c8ba2aeaa1bedfbd6cfe9fd6f6d681337 Mon Sep 17 00:00:00 2001 From: Alexander Strizhak Date: Mon, 10 Nov 2025 21:57:38 +0300 Subject: [PATCH 29/43] try to satisfy psalm --- src/Storage/TokenStorage.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Storage/TokenStorage.php b/src/Storage/TokenStorage.php index 206fdb2..3f9d9d9 100644 --- a/src/Storage/TokenStorage.php +++ b/src/Storage/TokenStorage.php @@ -24,7 +24,8 @@ final class TokenStorage implements TokenStorageInterface */ private $scenarios; - public function __construct(WatcherClient $watcher, CacheItemPoolInterface $cache, array $scenarios = []) { + public function __construct(WatcherClient $watcher, CacheItemPoolInterface $cache, array $scenarios = []) + { $this->watcher = $watcher; $this->cache = $cache; $this->scenarios = $scenarios; From c3def619105686e7a7d1bd2f4e34628c36e8bb57 Mon Sep 17 00:00:00 2001 From: Julien Loizelet Date: Fri, 28 Nov 2025 14:52:16 +0900 Subject: [PATCH 30/43] test(unit): Fix unit tests --- src/AbstractLapiClient.php | 2 +- src/Configuration.php | 4 ++-- src/Configuration/Alert.php | 11 +++++------ src/Configuration/Alert/Decision.php | 14 +++++++------- src/Configuration/Alert/Event.php | 4 ++-- src/Configuration/Alert/Source.php | 12 ++++++------ src/Configuration/Metrics/Items.php | 4 ++-- src/Metrics.php | 2 +- src/Payload/Alert.php | 8 +++++--- src/Storage/TokenStorage.php | 2 +- tests/Unit/Payload/AlertTest.php | 2 +- 11 files changed, 33 insertions(+), 32 deletions(-) diff --git a/src/AbstractLapiClient.php b/src/AbstractLapiClient.php index a84f95e..760a708 100644 --- a/src/AbstractLapiClient.php +++ b/src/AbstractLapiClient.php @@ -80,7 +80,7 @@ protected function manageRequest( protected function manageAppSecRequest( string $method, array $headers = [], - string $rawBody = '', + string $rawBody = '' ): array { try { $this->logger->debug('Now processing a bouncer AppSec request', [ diff --git a/src/Configuration.php b/src/Configuration.php index 72d4505..289f501 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -205,8 +205,8 @@ private function validate($rootNode): void private function watcher(ArrayNodeDefinition $rootNode): void { $rootNode->children() - ->stringNode('machine_id')->end() - ->stringNode('password')->end() + ->scalarNode('machine_id')->end() + ->scalarNode('password')->end() ->end(); } } diff --git a/src/Configuration/Alert.php b/src/Configuration/Alert.php index c210036..2cb2571 100644 --- a/src/Configuration/Alert.php +++ b/src/Configuration/Alert.php @@ -30,14 +30,13 @@ public function getConfigTreeBuilder(): TreeBuilder $treeBuilder = new TreeBuilder('alert'); /** @var ArrayNodeDefinition $rootNode */ $rootNode = $treeBuilder->getRootNode(); - // @formatter:off $rootNode ->children() - ->stringNode('scenario')->isRequired()->cannotBeEmpty()->end() - ->stringNode('scenario_hash')->isRequired()->cannotBeEmpty()->end() - ->stringNode('scenario_version')->isRequired()->cannotBeEmpty()->end() - ->stringNode('message')->isRequired()->cannotBeEmpty()->end() + ->scalarNode('scenario')->isRequired()->cannotBeEmpty()->end() + ->scalarNode('scenario_hash')->isRequired()->cannotBeEmpty()->end() + ->scalarNode('scenario_version')->isRequired()->cannotBeEmpty()->end() + ->scalarNode('message')->isRequired()->cannotBeEmpty()->end() ->integerNode('events_count')->isRequired()->min(0)->end() ->scalarNode('start_at')->isRequired()->cannotBeEmpty()->end() ->scalarNode('stop_at')->isRequired()->cannotBeEmpty()->end() @@ -48,7 +47,7 @@ public function getConfigTreeBuilder(): TreeBuilder ->end() ; // @formatter:on - + return $treeBuilder; } } diff --git a/src/Configuration/Alert/Decision.php b/src/Configuration/Alert/Decision.php index 9618b6d..3b98227 100644 --- a/src/Configuration/Alert/Decision.php +++ b/src/Configuration/Alert/Decision.php @@ -26,13 +26,13 @@ public function getConfigTreeBuilder(): TreeBuilder // @formatter:off $rootNode ->children() - ->stringNode('origin')->isRequired()->cannotBeEmpty()->end() - ->stringNode('type')->isRequired()->cannotBeEmpty()->end() - ->stringNode('scope')->isRequired()->cannotBeEmpty()->end() - ->stringNode('value')->isRequired()->cannotBeEmpty()->end() - ->stringNode('duration')->isRequired()->cannotBeEmpty()->end() - ->stringNode('until')->cannotBeEmpty()->end() - ->stringNode('scenario')->isRequired()->cannotBeEmpty()->end() + ->scalarNode('origin')->isRequired()->cannotBeEmpty()->end() + ->scalarNode('type')->isRequired()->cannotBeEmpty()->end() + ->scalarNode('scope')->isRequired()->cannotBeEmpty()->end() + ->scalarNode('value')->isRequired()->cannotBeEmpty()->end() + ->scalarNode('duration')->isRequired()->cannotBeEmpty()->end() + ->scalarNode('until')->cannotBeEmpty()->end() + ->scalarNode('scenario')->isRequired()->cannotBeEmpty()->end() ->end() ; // @formatter:on diff --git a/src/Configuration/Alert/Event.php b/src/Configuration/Alert/Event.php index 09db054..006963d 100644 --- a/src/Configuration/Alert/Event.php +++ b/src/Configuration/Alert/Event.php @@ -24,8 +24,8 @@ public function getConfigTreeBuilder(): TreeBuilder ->arrayNode('meta')->isRequired() ->arrayPrototype() ->children() - ->stringNode('key')->isRequired()->cannotBeEmpty()->end() - ->stringNode('value')->isRequired()->cannotBeEmpty()->end() + ->scalarNode('key')->isRequired()->cannotBeEmpty()->end() + ->scalarNode('value')->isRequired()->cannotBeEmpty()->end() ->end() ->end() ->end() diff --git a/src/Configuration/Alert/Source.php b/src/Configuration/Alert/Source.php index 70542e1..86c34c6 100644 --- a/src/Configuration/Alert/Source.php +++ b/src/Configuration/Alert/Source.php @@ -28,13 +28,13 @@ public function getConfigTreeBuilder(): TreeBuilder // @formatter:off $rootNode ->children() - ->stringNode('scope')->isRequired()->cannotBeEmpty()->end() - ->stringNode('value')->isRequired()->cannotBeEmpty()->end() - ->stringNode('ip')->cannotBeEmpty()->end() - ->stringNode('range')->cannotBeEmpty()->end() + ->scalarNode('scope')->isRequired()->cannotBeEmpty()->end() + ->scalarNode('value')->isRequired()->cannotBeEmpty()->end() + ->scalarNode('ip')->cannotBeEmpty()->end() + ->scalarNode('range')->cannotBeEmpty()->end() ->scalarNode('as_number')->cannotBeEmpty()->end() - ->stringNode('as_name')->cannotBeEmpty()->end() - ->stringNode('cn')->cannotBeEmpty()->end() + ->scalarNode('as_name')->cannotBeEmpty()->end() + ->scalarNode('cn')->cannotBeEmpty()->end() ->floatNode('latitude')->min(-90)->max(90)->end() ->floatNode('longitude')->min(-180)->max(180)->end() ->end() diff --git a/src/Configuration/Metrics/Items.php b/src/Configuration/Metrics/Items.php index e8a67a5..e01a646 100644 --- a/src/Configuration/Metrics/Items.php +++ b/src/Configuration/Metrics/Items.php @@ -63,13 +63,13 @@ public function getConfigTreeBuilder(): TreeBuilder ->variableNode('labels') // Remove empty labels totally ->beforeNormalization() - ->ifTrue(function (mixed $value) { + ->ifTrue(function ($value) { return empty($value); }) ->thenUnset() ->end() ->validate() - ->ifTrue(function (mixed $value) { + ->ifTrue(function ($value) { // Ensure all values in the array are strings if (!is_array($value)) { return true; diff --git a/src/Metrics.php b/src/Metrics.php index 3bc9276..0697ac2 100644 --- a/src/Metrics.php +++ b/src/Metrics.php @@ -92,7 +92,7 @@ class Metrics public function __construct( array $properties, array $meta, - array $items = [], + array $items = [] ) { $this->configureProperties($properties); $this->configureMeta($meta); diff --git a/src/Payload/Alert.php b/src/Payload/Alert.php index f52622a..f4e136c 100644 --- a/src/Payload/Alert.php +++ b/src/Payload/Alert.php @@ -112,7 +112,7 @@ class Alert implements \JsonSerializable /** * @param TProps $properties - * @param TSource $source + * @param ?TSource $source * @param list $events * @param list $decisions * @param list $meta @@ -120,7 +120,7 @@ class Alert implements \JsonSerializable */ public function __construct( array $properties, - array $source, + ?array $source, array $events = [], array $decisions = [], array $meta = [], @@ -156,7 +156,9 @@ public static function fromArray(array $data): self public function toArray(): array { $result = $this->properties; - $result['source'] = $this->source; + if (null !== $this->source) { + $result['source'] = $this->source; + } $result['events'] = $this->events; if ([] !== $this->decisions) { $result['decisions'] = $this->decisions; diff --git a/src/Storage/TokenStorage.php b/src/Storage/TokenStorage.php index 3f9d9d9..ffa78cf 100644 --- a/src/Storage/TokenStorage.php +++ b/src/Storage/TokenStorage.php @@ -42,7 +42,7 @@ public function retrieveToken(): ?string \assert(isset($tokenInfo['token'])); $ci ->set($tokenInfo['token']) - ->expiresAt(new DateTime($tokenInfo['expire'])); + ->expiresAt(new DateTime('@' . $tokenInfo['expire'])); $this->cache->save($ci); } return $ci->get(); diff --git a/tests/Unit/Payload/AlertTest.php b/tests/Unit/Payload/AlertTest.php index 22fc26e..966f6ec 100644 --- a/tests/Unit/Payload/AlertTest.php +++ b/tests/Unit/Payload/AlertTest.php @@ -15,8 +15,8 @@ public function testConstruct(array $in, array $expected): void $alert = new Alert( $in ?? [], $in['source'] ?? null, - $in['decisions'] ?? [], $in['events'] ?? [], + $in['decisions'] ?? [], $in['meta'] ?? [], $in['labels'] ?? [] ); From 0f5d74fa3c1b32ee1309ba3e9dc4e048afcdd2ae Mon Sep 17 00:00:00 2001 From: Julien Loizelet Date: Fri, 28 Nov 2025 16:01:32 +0900 Subject: [PATCH 31/43] test(integration): Fix integration tests --- .../workflows/unit-and-integration-test.yml | 29 +++++--- docs/DEVELOPER.md | 68 +++++++++---------- src/Payload/Alert.php | 6 +- src/Storage/TokenStorage.php | 2 +- tests/Integration/AlertsClientTest.php | 45 ++++++------ tests/Unit/Storage/TokenStorageTest.php | 2 +- 6 files changed, 81 insertions(+), 71 deletions(-) diff --git a/.github/workflows/unit-and-integration-test.yml b/.github/workflows/unit-and-integration-test.yml index 07c7f4d..d49194b 100644 --- a/.github/workflows/unit-and-integration-test.yml +++ b/.github/workflows/unit-and-integration-test.yml @@ -35,7 +35,7 @@ jobs: strategy: fail-fast: false matrix: - php-version: ["7.2", "7.3", "7.4", "8.0", "8.1", "8.2", "8.3", "8.4"] + php-version: [ "7.2", "7.3", "7.4", "8.0", "8.1", "8.2", "8.3", "8.4" ] name: Unit and integration test runs-on: ubuntu-latest @@ -85,6 +85,9 @@ jobs: run: | echo "BOUNCER_KEY=$(ddev create-bouncer)" >> $GITHUB_ENV + - name: Create Watcher for Integration tests + run: ddev create-watcher + - name: Clone sources uses: actions/checkout@v4 with: @@ -101,32 +104,42 @@ jobs: - name: Run Unit tests if: | github.event.inputs.unit_tests == 'true' || - github.event_name == 'push' + github.event_name == 'push' || + github.event_name == 'pull_request' run: ddev php ./${{env.EXTENSION_PATH}}/vendor/bin/phpunit --debug ./${{env.EXTENSION_PATH}}/tests/Unit --testdox - name: Run Integration tests (without TLS) if: | github.event.inputs.integration_tests == 'true' || - github.event_name == 'push' - run: ddev exec BOUNCER_KEY=${{ env.BOUNCER_KEY }} AGENT_TLS_PATH=/var/www/html/cfssl APPSEC_URL=http://crowdsec:7422 LAPI_URL=https://crowdsec:8080 /usr/bin/php ./${{env.EXTENSION_PATH}}/vendor/bin/phpunit --testdox --colors --exclude-group timeout,appsec ./${{env.EXTENSION_PATH}}/tests/Integration + github.event_name == 'push' || + github.event_name == 'pull_request' + run: | + ddev exec -s crowdsec cscli alerts delete --all + ddev exec BOUNCER_KEY=${{ env.BOUNCER_KEY }} AGENT_TLS_PATH=/var/www/html/cfssl APPSEC_URL=http://crowdsec:7422 LAPI_URL=https://crowdsec:8080 /usr/bin/php ./${{env.EXTENSION_PATH}}/vendor/bin/phpunit --testdox --colors --exclude-group timeout,appsec ./${{env.EXTENSION_PATH}}/tests/Integration - name: Run Integration tests (with TLS) if: | github.event.inputs.integration_tests == 'true' || - github.event_name == 'push' - run: ddev exec BOUNCER_KEY=${{ env.BOUNCER_KEY }} AGENT_TLS_PATH=/var/www/html/cfssl BOUNCER_TLS_PATH=/var/www/html/cfssl APPSEC_URL=http://crowdsec:7422 LAPI_URL=https://crowdsec:8080 /usr/bin/php ./${{env.EXTENSION_PATH}}/vendor/bin/phpunit --testdox --colors --exclude-group timeout,appsec ./${{env.EXTENSION_PATH}}/tests/Integration + github.event_name == 'push' || + github.event_name == 'pull_request' + run: | + ddev exec -s crowdsec cscli alerts delete --all + ddev exec BOUNCER_KEY=${{ env.BOUNCER_KEY }} AGENT_TLS_PATH=/var/www/html/cfssl BOUNCER_TLS_PATH=/var/www/html/cfssl APPSEC_URL=http://crowdsec:7422 LAPI_URL=https://crowdsec:8080 /usr/bin/php ./${{env.EXTENSION_PATH}}/vendor/bin/phpunit --testdox --colors --exclude-group timeout,appsec ./${{env.EXTENSION_PATH}}/tests/Integration + - name: Run AppSec tests if: | github.event.inputs.integration_tests == 'true' || - github.event_name == 'push' + github.event_name == 'push' || + github.event_name == 'pull_request' run: | ddev exec BOUNCER_KEY=${{ env.BOUNCER_KEY }} AGENT_TLS_PATH=/var/www/html/cfssl APPSEC_URL=http://crowdsec:7422 LAPI_URL=https://crowdsec:8080 /usr/bin/php ./${{env.EXTENSION_PATH}}/vendor/bin/phpunit --testdox --colors --group appsec ./${{env.EXTENSION_PATH}}/tests/Integration - name: Run AppSec tests with timeout if: | github.event.inputs.integration_tests == 'true' || - github.event_name == 'push' + github.event_name == 'push' || + github.event_name == 'pull_request' run: | ddev exec -s crowdsec apk add iproute2 ddev exec -s crowdsec tc qdisc add dev eth0 root netem delay 500ms diff --git a/docs/DEVELOPER.md b/docs/DEVELOPER.md index c0f8dfc..5a6a9c0 100644 --- a/docs/DEVELOPER.md +++ b/docs/DEVELOPER.md @@ -1,32 +1,30 @@ ![CrowdSec Logo](images/logo_crowdsec.png) + # CrowdSec LAPI PHP client ## Developer guide - **Table of Contents** - [Local development](#local-development) - - [DDEV setup](#ddev-setup) - - [DDEV installation](#ddev-installation) - - [Prepare DDEV PHP environment](#prepare-ddev-php-environment) - - [DDEV Usage](#ddev-usage) - - [Use composer to update or install the lib](#use-composer-to-update-or-install-the-lib) - - [Unit test](#unit-test) - - [Integration test](#integration-test) - - [Coding standards](#coding-standards) - - [Testing timeout in the CrowdSec container](#testing-timeout-in-the-crowdsec-container) + - [DDEV setup](#ddev-setup) + - [DDEV installation](#ddev-installation) + - [Prepare DDEV PHP environment](#prepare-ddev-php-environment) + - [DDEV Usage](#ddev-usage) + - [Use composer to update or install the lib](#use-composer-to-update-or-install-the-lib) + - [Unit test](#unit-test) + - [Integration test](#integration-test) + - [Coding standards](#coding-standards) + - [Testing timeout in the CrowdSec container](#testing-timeout-in-the-crowdsec-container) - [Commit message](#commit-message) - - [Allowed message `type` values](#allowed-message-type-values) + - [Allowed message `type` values](#allowed-message-type-values) - [Update documentation table of contents](#update-documentation-table-of-contents) - [Release process](#release-process) - - ## Local development There are many ways to install this library on a local PHP environment. @@ -35,16 +33,15 @@ We are using [DDEV](https://docs.ddev.com/en/stable/) because it is quite simple Of course, you may use your own local stack, but we provide here some useful tools that depends on DDEV. - ### DDEV setup For a quick start, follow the below steps. - #### DDEV installation This project is fully compatible with DDEV 1.21.4, and it is recommended to use this specific version. -For the DDEV installation, please follow the [official instructions](https://docs.ddev.com/en/stable/users/install/ddev-installation/). +For the DDEV installation, please follow +the [official instructions](https://docs.ddev.com/en/stable/users/install/ddev-installation/). #### Prepare DDEV PHP environment @@ -68,6 +65,7 @@ crowdsec-lapi-dev-project (choose the name you want for this folder) ``` - Create an empty folder that will contain all necessary sources: + ```bash mkdir crowdsec-lapi-dev-project ``` @@ -93,10 +91,8 @@ mkdir -p my-code/lapi-client cd my-code/lapi-client && git clone git@github.com:crowdsecurity/php-lapi-client.git ./ ``` - ### DDEV Usage - #### Use composer to update or install the lib Run: @@ -119,6 +115,12 @@ First, create a bouncer and keep the result key. ddev create-bouncer ``` +Create also a watcher with default login/password for integration tests: + +```bash +ddev create-watcher +``` + Then, as we use a TLS ready CrowdSec container, you have to copy some certificates and key: ```bash @@ -159,7 +161,6 @@ We are using the [PHP Coding Standards Fixer](https://cs.symfony.com/) With ddev, you can do the following: - ```bash ddev phpcsfixer my-code/lapi-client/tools/coding-standards/php-cs-fixer ../ ``` @@ -168,13 +169,11 @@ ddev phpcsfixer my-code/lapi-client/tools/coding-standards/php-cs-fixer ../ To use the [PHPSTAN](https://github.com/phpstan/phpstan) tool, you can run: - ```bash ddev phpstan /var/www/html/my-code/lapi-client/tools/coding-standards phpstan/phpstan.neon /var/www/html/my-code/lapi-client/src ``` - ##### PHP Mess Detector To use the [PHPMD](https://github.com/phpmd/phpmd) tool, you can run: @@ -198,7 +197,6 @@ and: ddev phpcbf ./my-code/lapi-client/tools/coding-standards my-code/lapi-client/src PSR12 ``` - ##### PSALM To use [PSALM](https://github.com/vimeo/psalm) tools, you can run: @@ -211,20 +209,20 @@ ddev psalm ./my-code/lapi-client/tools/coding-standards ./my-code/lapi-client/to In order to generate a code coverage report, you have to: - - Enable `xdebug`: + ```bash ddev xdebug ``` To generate a html report, you can run: + ```bash ddev php -dxdebug.mode=coverage ./my-code/lapi-client/tools/coding-standards/vendor/bin/phpunit --configuration ./my-code/lapi-client/tools/coding-standards/phpunit/phpunit.xml ``` You should find the main report file `dashboard.html` in `tools/coding-standards/phpunit/code-coverage` folder. - If you want to generate a text report in the same folder: ```bash @@ -236,15 +234,19 @@ ddev php -dxdebug.mode=coverage ./my-code/lapi-client/tools/coding-standards/ven If you need to test a timeout, you can use the following command: Install `iproute2` + ```bash ddev exec -s crowdsec apk add iproute2 ``` + Add the delay you want: + ```bash ddev exec -s crowdsec tc qdisc add dev eth0 root netem delay 500ms ``` To remove the delay: + ```bash ddev exec -s crowdsec tc qdisc del dev eth0 root netem ``` @@ -256,8 +258,6 @@ ddev exec BOUNCER_KEY= AGENT_TLS_PATH=/var/www/html/cfssl APPSEC_UR LAPI_URL=https://crowdsec:8080 php ./my-code/lapi-client/vendor/bin/phpunit ./my-code/lapi-client/tests/Integration --testdox --group timeout ``` - - ## Commit message In order to have an explicit commit history, we are using some commits message convention with the following format: @@ -273,8 +273,7 @@ Example: feat(bouncer): Add a new endpoint for bouncer - -You can use the `commit-msg` git hook that you will find in the `.githooks` folder : +You can use the `commit-msg` git hook that you will find in the `.githooks` folder : ``` cp .githooks/commit-msg .git/hooks/commit-msg @@ -293,10 +292,10 @@ chmod +x .git/hooks/commit-msg - style (formatting; no production code change) - test (adding missing tests, refactoring tests; no production code change) - ## Update documentation table of contents -To update the table of contents in the documentation, you can use [the `doctoc` tool](https://github.com/thlorenz/doctoc). +To update the table of contents in the documentation, you can use [the +`doctoc` tool](https://github.com/thlorenz/doctoc). First, install it: @@ -310,20 +309,19 @@ Then, run it in the documentation folder: doctoc docs/* --maxlevel 4 ``` - ## Release process -We are using [semantic versioning](https://semver.org/) to determine a version number. +We are using [semantic versioning](https://semver.org/) to determine a version number. Before publishing a new release, there are some manual steps to take: - Change the version number in the `Constants.php` file - Update the `CHANGELOG.md` file -Then, you have to [run the action manually from the GitHub repository](https://github.com/crowdsecurity/php-lapi-client/actions/workflows/release.yml) - +Then, you have +to [run the action manually from the GitHub repository](https://github.com/crowdsecurity/php-lapi-client/actions/workflows/release.yml) -Alternatively, you could use the [GitHub CLI](https://github.com/cli/cli) to publish a release: +Alternatively, you could use the [GitHub CLI](https://github.com/cli/cli) to publish a release: ``` gh workflow run release.yml -f tag_name=vx.y.z diff --git a/src/Payload/Alert.php b/src/Payload/Alert.php index f4e136c..74eb582 100644 --- a/src/Payload/Alert.php +++ b/src/Payload/Alert.php @@ -227,7 +227,11 @@ private function handleList(Processor $processor, AbstractConfiguration $param, return $result; } - public function jsonSerialize(): mixed + /** + * @return array + */ + #[\ReturnTypeWillChange] + public function jsonSerialize() { return $this->toArray(); } diff --git a/src/Storage/TokenStorage.php b/src/Storage/TokenStorage.php index ffa78cf..3f9d9d9 100644 --- a/src/Storage/TokenStorage.php +++ b/src/Storage/TokenStorage.php @@ -42,7 +42,7 @@ public function retrieveToken(): ?string \assert(isset($tokenInfo['token'])); $ci ->set($tokenInfo['token']) - ->expiresAt(new DateTime('@' . $tokenInfo['expire'])); + ->expiresAt(new DateTime($tokenInfo['expire'])); $this->cache->save($ci); } return $ci->get(); diff --git a/tests/Integration/AlertsClientTest.php b/tests/Integration/AlertsClientTest.php index 57d954d..59e0a09 100644 --- a/tests/Integration/AlertsClientTest.php +++ b/tests/Integration/AlertsClientTest.php @@ -14,7 +14,7 @@ use Symfony\Component\Cache\Adapter\ArrayAdapter; /** - * @note You must delete all alerts manually before run this TestCase. Command `cscli alerts delete --all`. + * @note You must delete all alerts manually before running this TestCase. Command: `cscli alerts delete --all`. * * @coversDefaultClass \CrowdSec\LapiClient\AlertsClient */ @@ -36,7 +36,7 @@ final class AlertsClientTest extends TestCase */ protected $alertsClient; - private function addTlsConfig(&$bouncerConfigs, $tlsPath) + private function addTlsConfig(array &$bouncerConfigs, string $tlsPath): void { $bouncerConfigs['tls_cert_path'] = $tlsPath . '/bouncer.pem'; $bouncerConfigs['tls_key_path'] = $tlsPath . '/bouncer-key.pem'; @@ -57,6 +57,9 @@ protected function setUp(): void ]; if ($this->useTls) { $this->addTlsConfig($bouncerConfigs, $this->useTls); + } else { + $bouncerConfigs['machine_id'] = getenv('MACHINE_ID') ?: 'watcherLogin'; + $bouncerConfigs['password'] = getenv('PASSWORD') ?: 'watcherPassword'; } $this->configs = $bouncerConfigs; @@ -66,15 +69,6 @@ protected function setUp(): void $this->alertsClient = new AlertsClient($this->configs, $tokenStorage); } - /** - * @covers ::delete - */ - public function testDelete(): void - { - self::expectException(\RuntimeException::class); - $this->alertsClient->delete([]); - } - /** * @covers ::push */ @@ -272,7 +266,7 @@ public function testPush(): array /** * @covers ::search - * @depends testPush + * @depends testPush * @dataProvider searchProvider */ public function testSearch(array $query, int $expectedCount): void @@ -294,7 +288,8 @@ public static function searchProvider(): iterable ]; yield 'ip - 1.1.0.1' => [ - ['ip' => '1.1.0.1'], // alert01 (scope=ip;value=1.1.0.1 +decision) and alert02(scope=range;value=1.1.0.0/16 +decision) + ['ip' => '1.1.0.1'], + // alert01 (scope=ip;value=1.1.0.1 +decision) and alert02(scope=range;value=1.1.0.0/16 +decision) 2 ]; yield 'ip - 2.0.1.1' => [ @@ -321,34 +316,34 @@ public static function searchProvider(): iterable 2 ]; + // has_active_decision is a FILTER: true = only with decisions, false = only without yield 'has_active_decision=true' => [ ['has_active_decision' => 'true'], - 0, + 3, // alert01, alert02 have decisions; alert02 simulated also counted ]; yield 'has_active_decision=false' => [ ['has_active_decision' => 'false'], - 1, //crowdsec-lapi-test/integration11 + 1, // alert11 only (alert12 is simulated and excluded by default) + ]; + // simulated is an INCLUSION flag: true = include simulated, false = exclude simulated + yield 'simulated=true' => [ + ['simulated' => 'true'], + 4, // All alerts (both simulated and non-simulated) ]; -// TODO: why 4 byt not 2 ? -// yield 'simulated=true' => [ -// ['simulated' => 'true'], -// 4, -// ]; yield 'simulated=false' => [ ['simulated' => 'false'], - 2, + 2, // Only non-simulated: alert01, alert11 ]; - yield 'since -1h' => [ [ 'since' => '-1h', ], 0, ]; - yield 'since 1s' => [ - ['since' => '1s'], - 0, + yield 'since 1m' => [ + ['since' => '1m'], + 4, // All alerts were just created ]; yield 'since 1h' => [ ['since' => '10h'], diff --git a/tests/Unit/Storage/TokenStorageTest.php b/tests/Unit/Storage/TokenStorageTest.php index ece5d73..57adaad 100644 --- a/tests/Unit/Storage/TokenStorageTest.php +++ b/tests/Unit/Storage/TokenStorageTest.php @@ -15,7 +15,7 @@ final class TokenStorageTest extends TestCase public function testLoginSuccess(): void { $watcher = $this->createMock(WatcherClient::class); - $expire = time() + 3600; + $expire = (new \DateTime('+1 hour'))->format('Y-m-d\TH:i:s\Z'); $watcher ->expects(self::once()) ->method('login') From 57b086b0e28c19cf2d9f9df160584a8432bc2f48 Mon Sep 17 00:00:00 2001 From: Julien Loizelet Date: Fri, 28 Nov 2025 16:34:31 +0900 Subject: [PATCH 32/43] test(coding standards): Fix some psalm errors --- src/AlertsClient.php | 16 +++---- src/Configuration/Alert.php | 2 +- src/Payload/Alert.php | 24 +++++++--- tools/coding-standards/psalm/psalm.xml | 64 ++++---------------------- 4 files changed, 36 insertions(+), 70 deletions(-) diff --git a/src/AlertsClient.php b/src/AlertsClient.php index 9c5b9e5..2de40e1 100644 --- a/src/AlertsClient.php +++ b/src/AlertsClient.php @@ -23,10 +23,10 @@ * range?: string, * since?: string, * until?: string, - * simulated?: boolean, - * has_active_decision?: boolean, + * simulated?: bool, + * has_active_decision?: bool, * decision_type?: string, - * limit?: number, + * limit?: int, * origin?: string * } * @@ -38,7 +38,7 @@ * range?: string, * since?: string, * until?: string, - * has_active_decision?: boolean, + * has_active_decision?: bool, * alert_source?: string * } * @@ -142,7 +142,7 @@ public function delete(array $query): array /** * @param positive-int $id - * @return TStoredAlert + * @return ?TStoredAlert */ public function getById(int $id): ?array { @@ -151,11 +151,11 @@ public function getById(int $id): ?array 'GET', \sprintf('%s/%d', Constants::ALERTS, $id) ); - // workaround for mutes 404 status. - if (empty($result['id'])) { - \assert($result['message'] === 'object not found'); + // workaround for muted 404 status. + if (!isset($result['id'])) { return null; } + /** @var TStoredAlert */ return $result; } diff --git a/src/Configuration/Alert.php b/src/Configuration/Alert.php index 2cb2571..dc1cac2 100644 --- a/src/Configuration/Alert.php +++ b/src/Configuration/Alert.php @@ -47,7 +47,7 @@ public function getConfigTreeBuilder(): TreeBuilder ->end() ; // @formatter:on - + return $treeBuilder; } } diff --git a/src/Payload/Alert.php b/src/Payload/Alert.php index 74eb582..4b3bdf0 100644 --- a/src/Payload/Alert.php +++ b/src/Payload/Alert.php @@ -71,17 +71,17 @@ * leakspeed: string, * simulated: bool, * remediation: bool, - * source: TSource, + * source?: TSource, * events: list, * decisions?: list, * meta?: list, - * labels?: list + * labels?: list * } */ class Alert implements \JsonSerializable { /** - * @var list + * @var TProps */ private $properties; @@ -96,7 +96,7 @@ class Alert implements \JsonSerializable private $decisions = []; /** - * @var TSource + * @var ?TSource */ private $source; @@ -141,8 +141,20 @@ public function __construct( public static function fromArray(array $data): self { return new self( - $data, - $data['source'] ?? [], + [ + 'scenario' => $data['scenario'], + 'scenario_hash' => $data['scenario_hash'], + 'scenario_version' => $data['scenario_version'], + 'message' => $data['message'], + 'events_count' => $data['events_count'], + 'start_at' => $data['start_at'], + 'stop_at' => $data['stop_at'], + 'capacity' => $data['capacity'], + 'leakspeed' => $data['leakspeed'], + 'simulated' => $data['simulated'], + 'remediation' => $data['remediation'], + ], + $data['source'] ?? null, $data['events'] ?? [], $data['decisions'] ?? [], $data['meta'] ?? [], diff --git a/tools/coding-standards/psalm/psalm.xml b/tools/coding-standards/psalm/psalm.xml index c89cb61..7d12456 100644 --- a/tools/coding-standards/psalm/psalm.xml +++ b/tools/coding-standards/psalm/psalm.xml @@ -1,16 +1,16 @@ - + - - + + @@ -18,7 +18,6 @@ - @@ -29,36 +28,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -67,7 +36,6 @@ - @@ -75,27 +43,13 @@ - - - - - - - - - - - - - - - + From 455876431db6c5d905ede03b2e72f9cf34f7d0dc Mon Sep 17 00:00:00 2001 From: Julien Loizelet Date: Fri, 28 Nov 2025 17:31:45 +0900 Subject: [PATCH 33/43] test(integration): Always use login/password for alert client test --- src/AlertsClient.php | 8 ++++---- src/Constants.php | 2 +- tests/Integration/AlertsClientTest.php | 23 +++++------------------ tests/Integration/TestWatcherClient.php | 10 +++------- tests/Integration/WatcherClientTest.php | 13 ++++++++----- 5 files changed, 21 insertions(+), 35 deletions(-) diff --git a/src/AlertsClient.php b/src/AlertsClient.php index 2de40e1..fa91803 100644 --- a/src/AlertsClient.php +++ b/src/AlertsClient.php @@ -91,7 +91,7 @@ public function push(array $alerts): array $this->login(); return $this->manageRequest( 'POST', - Constants::ALERTS, + Constants::ALERTS_ENDPOINT, $alerts ); } @@ -120,7 +120,7 @@ public function search(array $query): array $this->login(); return $this->manageRequest( 'GET', - Constants::ALERTS, + Constants::ALERTS_ENDPOINT, $query ); } @@ -135,7 +135,7 @@ public function delete(array $query): array $this->login(); return $this->manageRequest( 'DELETE', - Constants::ALERTS, + Constants::ALERTS_ENDPOINT, $query ); } @@ -149,7 +149,7 @@ public function getById(int $id): ?array $this->login(); $result = $this->manageRequest( 'GET', - \sprintf('%s/%d', Constants::ALERTS, $id) + \sprintf('%s/%d', Constants::ALERTS_ENDPOINT, $id) ); // workaround for muted 404 status. if (!isset($result['id'])) { diff --git a/src/Constants.php b/src/Constants.php index dfc5e2d..9870301 100644 --- a/src/Constants.php +++ b/src/Constants.php @@ -29,7 +29,7 @@ class Constants extends CommonConstants */ public const DECISIONS_STREAM_ENDPOINT = '/v1/decisions/stream'; - public const ALERTS = '/v1/alerts'; + public const ALERTS_ENDPOINT = '/v1/alerts'; /** * @var string Authenticate current to get session ID diff --git a/tests/Integration/AlertsClientTest.php b/tests/Integration/AlertsClientTest.php index 59e0a09..6c63c0a 100644 --- a/tests/Integration/AlertsClientTest.php +++ b/tests/Integration/AlertsClientTest.php @@ -36,33 +36,20 @@ final class AlertsClientTest extends TestCase */ protected $alertsClient; - private function addTlsConfig(array &$bouncerConfigs, string $tlsPath): void - { - $bouncerConfigs['tls_cert_path'] = $tlsPath . '/bouncer.pem'; - $bouncerConfigs['tls_key_path'] = $tlsPath . '/bouncer-key.pem'; - $bouncerConfigs['tls_ca_cert_path'] = $tlsPath . '/ca-chain.pem'; - $bouncerConfigs['tls_verify_peer'] = true; - } - protected function setUp(): void { - $this->useTls = (string)getenv('BOUNCER_TLS_PATH'); - - $bouncerConfigs = [ + $watcherConfigs = [ 'auth_type' => $this->useTls ? Constants::AUTH_TLS : Constants::AUTH_KEY, 'api_key' => getenv('BOUNCER_KEY'), 'api_url' => getenv('LAPI_URL'), 'appsec_url' => getenv('APPSEC_URL'), 'user_agent_suffix' => TestConstants::USER_AGENT_SUFFIX, ]; - if ($this->useTls) { - $this->addTlsConfig($bouncerConfigs, $this->useTls); - } else { - $bouncerConfigs['machine_id'] = getenv('MACHINE_ID') ?: 'watcherLogin'; - $bouncerConfigs['password'] = getenv('PASSWORD') ?: 'watcherPassword'; - } - $this->configs = $bouncerConfigs; + $watcherConfigs['machine_id'] = getenv('MACHINE_ID') ?: 'watcherLogin'; + $watcherConfigs['password'] = getenv('PASSWORD') ?: 'watcherPassword'; + + $this->configs = $watcherConfigs; $watcher = new WatcherClient($this->configs); $tokenStorage = new TokenStorage($watcher, new ArrayAdapter()); diff --git a/tests/Integration/TestWatcherClient.php b/tests/Integration/TestWatcherClient.php index c36b943..c663661 100644 --- a/tests/Integration/TestWatcherClient.php +++ b/tests/Integration/TestWatcherClient.php @@ -11,10 +11,6 @@ class TestWatcherClient extends AbstractClient { - public const WATCHER_DECISIONS_ENDPOINT = '/v1/decisions'; - - public const WATCHER_ALERT_ENDPOINT = '/v1/alerts'; - public const HOURS24 = '+24 hours'; /** @var string */ @@ -113,7 +109,7 @@ public function deleteAllDecisions(): void $this->manageRequest( 'DELETE', - self::WATCHER_DECISIONS_ENDPOINT, + Constants::DECISIONS_FILTER_ENDPOINT, [] ); } @@ -150,7 +146,7 @@ public function addDecision( 'duration' => $durationString, 'origin' => 'cscli', 'scenario' => $type . ' for scope/value (' . $scope . '/' . $value . ') for ' - . $durationString . ' for PHPUnit tests', + . $durationString . ' for PHPUnit tests', 'scope' => $this->getFinalScope($scope, $value), 'type' => $type, 'value' => $value, @@ -175,7 +171,7 @@ public function addDecision( $result = $this->manageRequest( 'POST', - self::WATCHER_ALERT_ENDPOINT, + Constants::ALERTS_ENDPOINT, [$body] ); } diff --git a/tests/Integration/WatcherClientTest.php b/tests/Integration/WatcherClientTest.php index eb62547..bf9b30e 100644 --- a/tests/Integration/WatcherClientTest.php +++ b/tests/Integration/WatcherClientTest.php @@ -17,8 +17,11 @@ final class WatcherClientTest extends TestCase public function testLoginTls(): void { $agentTlsPath = getenv('AGENT_TLS_PATH'); - - $bouncerConfigs = [ + if (!$agentTlsPath) { + throw new \Exception('Using TLS auth for agent is required. Please set AGENT_TLS_PATH env.'); + } + + $watcherConfigs = [ 'api_url' => getenv('LAPI_URL'), 'appsec_url' => getenv('APPSEC_URL'), 'user_agent_suffix' => TestConstants::USER_AGENT_SUFFIX, @@ -28,7 +31,7 @@ public function testLoginTls(): void 'tls_verify_peer' => false, ]; - $watcher = new WatcherClient($bouncerConfigs); + $watcher = new WatcherClient($watcherConfigs); self::assertLoginResult($watcher->login()); } @@ -37,7 +40,7 @@ public function testLoginApiKey(): void $machineId = getenv('MACHINE_ID') ?: 'watcherLogin'; $password = getenv('PASSWORD') ?: 'watcherPassword'; - $bouncerConfigs = [ + $watcherConfigs = [ 'api_url' => getenv('LAPI_URL'), 'appsec_url' => getenv('APPSEC_URL'), 'user_agent_suffix' => TestConstants::USER_AGENT_SUFFIX, @@ -47,7 +50,7 @@ public function testLoginApiKey(): void 'password' => $password, ]; - $watcher = new WatcherClient($bouncerConfigs); + $watcher = new WatcherClient($watcherConfigs); self::assertLoginResult($watcher->login()); } From fa795a2dacefc0ce7ea8ffb2def4b2cbd0ca09b5 Mon Sep 17 00:00:00 2001 From: Julien Loizelet Date: Fri, 28 Nov 2025 18:08:12 +0900 Subject: [PATCH 34/43] test(unit): Fix test for php 7.2 --- tests/Integration/WatcherClientTest.php | 11 ++++++----- tests/Unit/Payload/AlertTest.php | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/Integration/WatcherClientTest.php b/tests/Integration/WatcherClientTest.php index bf9b30e..4ce4a39 100644 --- a/tests/Integration/WatcherClientTest.php +++ b/tests/Integration/WatcherClientTest.php @@ -6,6 +6,7 @@ use CrowdSec\LapiClient\Constants; use CrowdSec\LapiClient\Tests\Constants as TestConstants; +use CrowdSec\LapiClient\Tests\PHPUnitUtil; use CrowdSec\LapiClient\WatcherClient; use PHPUnit\Framework\TestCase; @@ -20,7 +21,7 @@ public function testLoginTls(): void if (!$agentTlsPath) { throw new \Exception('Using TLS auth for agent is required. Please set AGENT_TLS_PATH env.'); } - + $watcherConfigs = [ 'api_url' => getenv('LAPI_URL'), 'appsec_url' => getenv('APPSEC_URL'), @@ -32,7 +33,7 @@ public function testLoginTls(): void ]; $watcher = new WatcherClient($watcherConfigs); - self::assertLoginResult($watcher->login()); + $this->assertLoginResult($watcher->login()); } public function testLoginApiKey(): void @@ -51,17 +52,17 @@ public function testLoginApiKey(): void ]; $watcher = new WatcherClient($watcherConfigs); - self::assertLoginResult($watcher->login()); + $this->assertLoginResult($watcher->login()); } - private static function assertLoginResult(array $data): void + private function assertLoginResult(array $data): void { self::assertArrayHasKey('code', $data); self::assertArrayHasKey('expire', $data); self::assertArrayHasKey('token', $data); self::assertSame(200, $data['code']); - self::assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/', $data['expire']); + PHPUnitUtil::assertRegExp($this, '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/', $data['expire']); // JWT $parts = explode('.', $data['token']); self::assertCount(3, $parts); diff --git a/tests/Unit/Payload/AlertTest.php b/tests/Unit/Payload/AlertTest.php index 966f6ec..b6de555 100644 --- a/tests/Unit/Payload/AlertTest.php +++ b/tests/Unit/Payload/AlertTest.php @@ -83,7 +83,7 @@ public function dpConstruct(): iterable $minimal['decisions'], $minimal['source'], $minimal['meta'], - $minimal['labels'], + $minimal['labels'] ); yield 'minimal example' => [ $minimal, From b3bf5fb752ecef3ebbf06b401c736b6570253134 Mon Sep 17 00:00:00 2001 From: Julien Loizelet Date: Fri, 12 Dec 2025 14:21:18 +0900 Subject: [PATCH 35/43] ci(sdk test): Remove chore exclusion --- .github/workflows/sdk-chain-tests.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/sdk-chain-tests.yml b/.github/workflows/sdk-chain-tests.yml index a62c50d..77ca116 100644 --- a/.github/workflows/sdk-chain-tests.yml +++ b/.github/workflows/sdk-chain-tests.yml @@ -26,7 +26,6 @@ jobs: test-bouncer-lib: name: Run Bouncer lib tests - if: ${{ !contains(github.event.head_commit.message, 'chore(') }} uses: crowdsecurity/php-cs-bouncer/.github/workflows/php-sdk-development-tests.yml@main with: php_common_json: '["main"]' @@ -36,7 +35,6 @@ jobs: test-remediation-engine: name: Run Remediation Engine tests - if: ${{ !contains(github.event.head_commit.message, 'chore(') }} uses: crowdsecurity/php-remediation-engine/.github/workflows/php-sdk-development-tests.yml@main with: php_common_json: '["main"]' @@ -48,7 +46,6 @@ jobs: test-magento-engine: name: Run Magento 2 Engine module tests - if: ${{ !contains(github.event.head_commit.message, 'chore(') }} uses: crowdsecurity/magento-cs-extension/.github/workflows/php-sdk-development-tests.yml@main with: php_common_json: '["main"]' From b72c164c76442c78bbc81c5b44384b3ea3fe16de Mon Sep 17 00:00:00 2001 From: Julien Loizelet Date: Fri, 12 Dec 2025 14:28:58 +0900 Subject: [PATCH 36/43] ci(*): Various updates --- .github/workflows/coding-standards.yml | 10 ++++------ .github/workflows/doc-links.yml | 2 +- .github/workflows/keepalive.yml | 2 +- .github/workflows/php-sdk-development-tests.yml | 8 ++++---- .github/workflows/release.yml | 2 +- .github/workflows/unit-and-integration-test.yml | 8 +++----- 6 files changed, 14 insertions(+), 18 deletions(-) diff --git a/.github/workflows/coding-standards.yml b/.github/workflows/coding-standards.yml index 6658d0b..7591fa3 100644 --- a/.github/workflows/coding-standards.yml +++ b/.github/workflows/coding-standards.yml @@ -1,8 +1,6 @@ name: Coding Standards on: push: - branches: - - main paths-ignore: - '**.md' pull_request: @@ -29,7 +27,7 @@ jobs: strategy: fail-fast: false matrix: - php-version: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4'] + php-version: [ '7.4', '8.0', '8.1', '8.2', '8.3', '8.4' ] name: Coding standards test runs-on: ubuntu-latest @@ -52,7 +50,7 @@ jobs: run: ddev config --project-type=php --project-name=crowdsec-lapi-client --php-version=${{ matrix.php-version }} - name: Add-ons install - run: ddev get julienloizelet/ddev-tools + run: ddev add-on get julienloizelet/ddev-tools - name: Start DDEV uses: nick-fields/retry@v3 @@ -68,12 +66,12 @@ jobs: ddev exec php -v - name: Clone sources - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: path: ${{env.EXTENSION_PATH}} - name: Validate composer.json - run: | + run: | ddev composer validate --strict --working-dir ./${{env.EXTENSION_PATH}} - name: Install dependencies and Coding standards tools diff --git a/.github/workflows/doc-links.yml b/.github/workflows/doc-links.yml index 798a53a..baeaed7 100644 --- a/.github/workflows/doc-links.yml +++ b/.github/workflows/doc-links.yml @@ -18,7 +18,7 @@ jobs: steps: - name: Clone sources - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: path: extension diff --git a/.github/workflows/keepalive.yml b/.github/workflows/keepalive.yml index 3f92278..78a2e57 100644 --- a/.github/workflows/keepalive.yml +++ b/.github/workflows/keepalive.yml @@ -16,7 +16,7 @@ jobs: steps: - name: Clone project files - uses: actions/checkout@v4 + uses: actions/checkout@v5 # keepalive-workflow keeps GitHub from turning off tests after 60 days - uses: gautamkrishnar/keepalive-workflow@v2 diff --git a/.github/workflows/php-sdk-development-tests.yml b/.github/workflows/php-sdk-development-tests.yml index 50e1343..c8f361e 100644 --- a/.github/workflows/php-sdk-development-tests.yml +++ b/.github/workflows/php-sdk-development-tests.yml @@ -37,7 +37,7 @@ jobs: strategy: fail-fast: false matrix: - php-version: ["7.2", "7.3", "7.4", "8.0", "8.1", "8.2", "8.3", "8.4"] + php-version: [ "7.2", "7.3", "7.4", "8.0", "8.1", "8.2", "8.3", "8.4" ] name: Unit and integration test runs-on: ubuntu-20.04 @@ -104,20 +104,20 @@ jobs: - name: Clone Lapi Client files if: inputs.is_call != true - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: path: ${{env.EXTENSION_PATH}} - name: Clone Lapi Client files if: inputs.is_call == true - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: repository: ${{ env.LAPI_CLIENT_REPO }} path: ${{env.EXTENSION_PATH}} ref: "main" - name: Clone PHP common files - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: repository: ${{ steps.set-common-data.outputs.repo}} ref: ${{ steps.set-common-data.outputs.branch }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 93d497f..412baad 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,7 +35,7 @@ jobs: echo "version_number=$(echo ${{ env.TAG_NAME }} | sed 's/v//g' )" >> $GITHUB_OUTPUT - name: Clone sources - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Check version consistency in files # Check src/Constants.php and CHANGELOG.md diff --git a/.github/workflows/unit-and-integration-test.yml b/.github/workflows/unit-and-integration-test.yml index d49194b..7784123 100644 --- a/.github/workflows/unit-and-integration-test.yml +++ b/.github/workflows/unit-and-integration-test.yml @@ -1,8 +1,6 @@ name: Unit & integration tests on: push: - branches: - - main paths-ignore: - "**.md" pull_request: @@ -59,8 +57,8 @@ jobs: - name: Add-ons install run: | - ddev get julienloizelet/ddev-tools - ddev get julienloizelet/ddev-crowdsec-php + ddev add-on get julienloizelet/ddev-tools + ddev add-on get julienloizelet/ddev-crowdsec-php - name: Prepare for TLS tests run: | @@ -89,7 +87,7 @@ jobs: run: ddev create-watcher - name: Clone sources - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: path: ${{env.EXTENSION_PATH}} From 1780da6e48ad7d3ee63b298fb80e809da6cf2eac Mon Sep 17 00:00:00 2001 From: Julien Loizelet Date: Fri, 12 Dec 2025 14:34:08 +0900 Subject: [PATCH 37/43] style(*): Pass through coding standards --- src/AlertsClient.php | 8 ++++++-- src/Configuration.php | 6 ------ src/Constants.php | 4 ++-- src/Metrics.php | 9 ++------- src/Payload/Alert.php | 17 +++++++---------- src/Storage/TokenStorage.php | 4 ++-- src/WatcherClient.php | 10 +++++----- tests/Integration/AlertsClientTest.php | 15 +++++++++------ 8 files changed, 33 insertions(+), 40 deletions(-) diff --git a/src/AlertsClient.php b/src/AlertsClient.php index fa91803..ca3f210 100644 --- a/src/AlertsClient.php +++ b/src/AlertsClient.php @@ -29,7 +29,6 @@ * limit?: int, * origin?: string * } - * * @psalm-type TDeleteQuery = array{ * scope?: string, * value?: string, @@ -41,7 +40,6 @@ * has_active_decision?: bool, * alert_source?: string * } - * * @psalm-type TStoredAlert = array{ * capacity: int, * created_at: string, @@ -89,6 +87,7 @@ public function __construct( public function push(array $alerts): array { $this->login(); + return $this->manageRequest( 'POST', Constants::ALERTS_ENDPOINT, @@ -113,11 +112,13 @@ public function push(array $alerts): array * origin: Restrict results to this origin (ie. lists,CAPI,cscli). * * @param TSearchQuery $query + * * @return list */ public function search(array $query): array { $this->login(); + return $this->manageRequest( 'GET', Constants::ALERTS_ENDPOINT, @@ -133,6 +134,7 @@ public function search(array $query): array public function delete(array $query): array { $this->login(); + return $this->manageRequest( 'DELETE', Constants::ALERTS_ENDPOINT, @@ -142,6 +144,7 @@ public function delete(array $query): array /** * @param positive-int $id + * * @return ?TStoredAlert */ public function getById(int $id): ?array @@ -155,6 +158,7 @@ public function getById(int $id): ?array if (!isset($result['id'])) { return null; } + /** @var TStoredAlert */ return $result; } diff --git a/src/Configuration.php b/src/Configuration.php index 289f501..38e12c2 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -106,8 +106,6 @@ public function getConfigTreeBuilder(): TreeBuilder * * @param NodeDefinition|ArrayNodeDefinition $rootNode * - * @return void - * * @throws \InvalidArgumentException */ private function addAppSecNodes($rootNode): void @@ -124,8 +122,6 @@ private function addAppSecNodes($rootNode): void * * @param NodeDefinition|ArrayNodeDefinition $rootNode * - * @return void - * * @throws \InvalidArgumentException */ private function addConnectionNodes($rootNode): void @@ -162,8 +158,6 @@ private function addConnectionNodes($rootNode): void * * @param NodeDefinition|ArrayNodeDefinition $rootNode * - * @return void - * * @throws \InvalidArgumentException * @throws \RuntimeException */ diff --git a/src/Constants.php b/src/Constants.php index 9870301..be11f0f 100644 --- a/src/Constants.php +++ b/src/Constants.php @@ -18,7 +18,7 @@ */ class Constants extends CommonConstants { - // + // /** * @var string The decisions endpoint */ @@ -40,7 +40,7 @@ class Constants extends CommonConstants * @var string The usage metrics endpoint */ public const METRICS_ENDPOINT = '/v1/usage-metrics'; - // + // /** * @var string The Default URL of the CrowdSec AppSec endpoint diff --git a/src/Metrics.php b/src/Metrics.php index 0697ac2..81809de 100644 --- a/src/Metrics.php +++ b/src/Metrics.php @@ -25,7 +25,6 @@ * name: string, * version: string * } - * * @psalm-type TMetric = array{ * name: string, * type?: string, @@ -35,24 +34,20 @@ * feature_flags?: array, * utc_startup_timestamp: int * } - * * @psalm-type TLabel = array{ * key: non-empty-string, * value: string * } - * * @psalm-type TItem = array{ * name: string, * value: non-negative-int, * unit: mixed, * labels: list * } - * * @psalm-type TMeta = array{ * window_size_seconds: int, * utc_now_timestamp: positive-int * } - * * @psalm-type TRemediationComponents = array{ * name: string, * type?: string, @@ -85,8 +80,8 @@ class Metrics private $properties; /** - * @param TMetric $properties - * @param TMeta $meta + * @param TMetric $properties + * @param TMeta $meta * @param list $items */ public function __construct( diff --git a/src/Payload/Alert.php b/src/Payload/Alert.php index 4b3bdf0..f992eda 100644 --- a/src/Payload/Alert.php +++ b/src/Payload/Alert.php @@ -26,7 +26,6 @@ * simulated: bool, * remediation: bool * } - * * @psalm-type TSource = array{ * scope: string, * value: string, @@ -38,7 +37,6 @@ * latitude?: float, * longitude?: float * } - * * @psalm-type TDecision = array{ * origin: string, * type: string, @@ -48,17 +46,14 @@ * until?: string, * scenario: string * } - * * @psalm-type TMeta = array{ * key: string, * value: string * } - * * @psalm-type TEvent = array{ * meta: list, * timestamp: string * } - * * @psalm-type TAlertFull = array{ * scenario: string, * scenario_hash: string, @@ -111,12 +106,12 @@ class Alert implements \JsonSerializable private $labels = []; /** - * @param TProps $properties - * @param ?TSource $source - * @param list $events + * @param TProps $properties + * @param ?TSource $source + * @param list $events * @param list $decisions - * @param list $meta - * @param list $labels + * @param list $meta + * @param list $labels */ public function __construct( array $properties, @@ -181,6 +176,7 @@ public function toArray(): array if ([] !== $this->labels) { $result['labels'] = $this->labels; } + return $result; } @@ -236,6 +232,7 @@ private function handleList(Processor $processor, AbstractConfiguration $param, foreach ($list as $item) { $result[] = $processor->processConfiguration($param, [$param->cleanConfigs($item)]); } + return $result; } diff --git a/src/Storage/TokenStorage.php b/src/Storage/TokenStorage.php index 3f9d9d9..2b3e260 100644 --- a/src/Storage/TokenStorage.php +++ b/src/Storage/TokenStorage.php @@ -5,7 +5,6 @@ namespace CrowdSec\LapiClient\Storage; use CrowdSec\LapiClient\WatcherClient; -use DateTime; use Psr\Cache\CacheItemPoolInterface; final class TokenStorage implements TokenStorageInterface @@ -42,9 +41,10 @@ public function retrieveToken(): ?string \assert(isset($tokenInfo['token'])); $ci ->set($tokenInfo['token']) - ->expiresAt(new DateTime($tokenInfo['expire'])); + ->expiresAt(new \DateTime($tokenInfo['expire'])); $this->cache->save($ci); } + return $ci->get(); } } diff --git a/src/WatcherClient.php b/src/WatcherClient.php index 41a7fc4..346f7fd 100644 --- a/src/WatcherClient.php +++ b/src/WatcherClient.php @@ -5,7 +5,6 @@ namespace CrowdSec\LapiClient; use CrowdSec\Common\Client\RequestHandler\RequestHandlerInterface; -use LogicException; use Psr\Log\LoggerInterface; /** @@ -24,9 +23,9 @@ public function __construct( ?RequestHandlerInterface $requestHandler = null, ?LoggerInterface $logger = null ) { - if ($configs['auth_type'] === Constants::AUTH_KEY) { + if (Constants::AUTH_KEY === $configs['auth_type']) { if (empty($configs['machine_id']) || empty($configs['password'])) { - throw new LogicException('Missing required config: machine_id or password.'); + throw new \LogicException('Missing required config: machine_id or password.'); } } @@ -34,15 +33,16 @@ public function __construct( } /** - * @throws ClientException * @return TLoginResponse + * + * @throws ClientException */ public function login(array $scenarios = []): array { $data = [ 'scenarios' => $scenarios, ]; - if (isset($this->configs['auth_type']) && $this->configs['auth_type'] === Constants::AUTH_KEY) { + if (isset($this->configs['auth_type']) && Constants::AUTH_KEY === $this->configs['auth_type']) { $data['machine_id'] = $this->configs['machine_id'] ?? ''; $data['password'] = $this->configs['password'] ?? ''; } diff --git a/tests/Integration/AlertsClientTest.php b/tests/Integration/AlertsClientTest.php index 6c63c0a..0171e09 100644 --- a/tests/Integration/AlertsClientTest.php +++ b/tests/Integration/AlertsClientTest.php @@ -248,12 +248,15 @@ public function testPush(): array ]); self::assertIsArray($result); self::assertCount(4, $result); + return $result; } /** * @covers ::search + * * @depends testPush + * * @dataProvider searchProvider */ public function testSearch(array $query, int $expectedCount): void @@ -266,22 +269,22 @@ public static function searchProvider(): iterable { yield 'empty' => [ [], - 4 + 4, ]; yield 'ip - no' => [ ['ip' => '19.17.11.7'], - 0 + 0, ]; yield 'ip - 1.1.0.1' => [ ['ip' => '1.1.0.1'], // alert01 (scope=ip;value=1.1.0.1 +decision) and alert02(scope=range;value=1.1.0.0/16 +decision) - 2 + 2, ]; yield 'ip - 2.0.1.1' => [ ['ip' => '2.0.1.1'], // alert12 (range no decision) - 1 + 1, ]; yield 'scope - ip' => [ @@ -300,7 +303,7 @@ public static function searchProvider(): iterable yield 'scenario' => [ ['scenario' => 'crowdsec-lapi-test/with-decision'], - 2 + 2, ]; // has_active_decision is a FILTER: true = only with decisions, false = only without @@ -382,7 +385,7 @@ public function testGetById(array $idList): void public function testAlertInfoNotFound(): void { - $result = $this->alertsClient->getById(PHP_INT_MAX); + $result = $this->alertsClient->getById(\PHP_INT_MAX); self::assertNull($result); } } From 6c334680b4182252f8f384c5538f1b907a6e3b53 Mon Sep 17 00:00:00 2001 From: Julien Loizelet Date: Fri, 12 Dec 2025 15:43:23 +0900 Subject: [PATCH 38/43] feat(config): Use separate configuration for watcher --- .github/workflows/sdk-chain-tests.yml | 3 +- src/AbstractLapiClient.php | 12 +- src/Configuration.php | 13 -- src/Configuration/Watcher.php | 118 +++++++++++ src/Storage/TokenStorage.php | 3 +- src/WatcherClient.php | 32 +-- tests/Integration/BouncerTest.php | 85 ++++++-- tests/MockedData.php | 24 +++ tests/Unit/AbstractClientTest.php | 13 +- tests/Unit/AlertsClientTest.php | 235 +++++++++++++++++++++ tests/Unit/BouncerTest.php | 50 +++-- tests/Unit/CurlTest.php | 19 +- tests/Unit/FileGetContentsTest.php | 17 +- tests/Unit/Payload/AlertTest.php | 96 +++++++++ tests/Unit/Storage/TokenStorageTest.php | 19 +- tests/Unit/WatcherClientTest.php | 175 +++++++++++++++ tools/coding-standards/phpunit/phpunit.xml | 2 +- tools/coding-standards/psalm/psalm.xml | 1 + 18 files changed, 825 insertions(+), 92 deletions(-) create mode 100644 src/Configuration/Watcher.php create mode 100644 tests/Unit/AlertsClientTest.php create mode 100644 tests/Unit/WatcherClientTest.php diff --git a/.github/workflows/sdk-chain-tests.yml b/.github/workflows/sdk-chain-tests.yml index 77ca116..6922595 100644 --- a/.github/workflows/sdk-chain-tests.yml +++ b/.github/workflows/sdk-chain-tests.yml @@ -15,8 +15,7 @@ env: jobs: test-standalone-bouncer: name: Run Standalone Bouncer tests - if: ${{ !contains(github.event.head_commit.message, 'chore(') }} - uses: crowdsecurity/cs-standalone-php-bouncer/.github/workflows/php-sdk-development-tests.yml@21a85d5696ba607e2028330c4ddda4b5e361547a + uses: crowdsecurity/cs-standalone-php-bouncer/.github/workflows/php-sdk-development-tests.yml@main with: php_common_json: '["main"]' lapi_client_json: '["${{ github.ref_name }}"]' diff --git a/src/AbstractLapiClient.php b/src/AbstractLapiClient.php index 760a708..faec236 100644 --- a/src/AbstractLapiClient.php +++ b/src/AbstractLapiClient.php @@ -39,13 +39,21 @@ public function __construct( /** * Process and validate input configurations. */ - private function configure(array $configs): void + protected function configure(array $configs): void { - $configuration = new Configuration(); + $configuration = $this->getConfiguration(); $processor = new Processor(); $this->configs = $processor->processConfiguration($configuration, [$configuration->cleanConfigs($configs)]); } + /** + * Get the configuration class to use. + */ + protected function getConfiguration(): Configuration + { + return new Configuration(); + } + /** * Make a request to LAPI. * diff --git a/src/Configuration.php b/src/Configuration.php index 38e12c2..1583627 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -34,8 +34,6 @@ * api_connect_timeout?: int, * appsec_timeout_ms?: int, * appsec_connect_timeout_ms?: int, - * machine_id?: non-empty-string, - * password?: non-empty-string * } */ class Configuration extends AbstractConfiguration @@ -56,8 +54,6 @@ class Configuration extends AbstractConfiguration 'api_connect_timeout', 'appsec_timeout_ms', 'appsec_connect_timeout_ms', - 'machine_id', - 'password', ]; /** @@ -96,7 +92,6 @@ public function getConfigTreeBuilder(): TreeBuilder $this->addConnectionNodes($rootNode); $this->addAppSecNodes($rootNode); $this->validate($rootNode); - $this->watcher($rootNode); return $treeBuilder; } @@ -195,12 +190,4 @@ private function validate($rootNode): void ->thenInvalid('CA path is required for tls authentification with verify_peer.') ->end(); } - - private function watcher(ArrayNodeDefinition $rootNode): void - { - $rootNode->children() - ->scalarNode('machine_id')->end() - ->scalarNode('password')->end() - ->end(); - } } diff --git a/src/Configuration/Watcher.php b/src/Configuration/Watcher.php new file mode 100644 index 0000000..917fbeb --- /dev/null +++ b/src/Configuration/Watcher.php @@ -0,0 +1,118 @@ +getRootNode(); + + $this->addWatcherNodes($rootNode); + $this->validateWatcher($rootNode); + + return $treeBuilder; + } + + /** + * Watcher-specific settings. + * + * @param ArrayNodeDefinition $rootNode + * + * @throws \InvalidArgumentException + */ + private function addWatcherNodes(ArrayNodeDefinition $rootNode): void + { + $rootNode->children() + ->scalarNode('machine_id')->end() + ->scalarNode('password')->end() + ->end(); + } + + /** + * Watcher-specific validation. + * + * @param ArrayNodeDefinition $rootNode + * + * @throws \InvalidArgumentException + * @throws \RuntimeException + */ + private function validateWatcher(ArrayNodeDefinition $rootNode): void + { + $rootNode + ->validate() + ->ifTrue(function (array $v) { + if (Constants::AUTH_KEY === $v['auth_type']) { + return empty($v['machine_id']) || empty($v['password']); + } + + return false; + }) + ->thenInvalid('machine_id and password are required when auth_type is api_key') + ->end(); + } +} diff --git a/src/Storage/TokenStorage.php b/src/Storage/TokenStorage.php index 2b3e260..63378cb 100644 --- a/src/Storage/TokenStorage.php +++ b/src/Storage/TokenStorage.php @@ -5,6 +5,7 @@ namespace CrowdSec\LapiClient\Storage; use CrowdSec\LapiClient\WatcherClient; +use DateTime; use Psr\Cache\CacheItemPoolInterface; final class TokenStorage implements TokenStorageInterface @@ -41,7 +42,7 @@ public function retrieveToken(): ?string \assert(isset($tokenInfo['token'])); $ci ->set($tokenInfo['token']) - ->expiresAt(new \DateTime($tokenInfo['expire'])); + ->expiresAt(new DateTime($tokenInfo['expire'])); $this->cache->save($ci); } diff --git a/src/WatcherClient.php b/src/WatcherClient.php index 346f7fd..973b144 100644 --- a/src/WatcherClient.php +++ b/src/WatcherClient.php @@ -4,12 +4,12 @@ namespace CrowdSec\LapiClient; -use CrowdSec\Common\Client\RequestHandler\RequestHandlerInterface; -use Psr\Log\LoggerInterface; +use CrowdSec\LapiClient\Configuration\Watcher; /** * If you use `auth_type = api_key` you must provide configs `machine_id` and `password`. * + * @psalm-import-type TWatcherConfig from Watcher * @psalm-type TLoginResponse = array{ * code: positive-int, * expire: non-empty-string, @@ -18,18 +18,16 @@ */ class WatcherClient extends AbstractLapiClient { - public function __construct( - array $configs, - ?RequestHandlerInterface $requestHandler = null, - ?LoggerInterface $logger = null - ) { - if (Constants::AUTH_KEY === $configs['auth_type']) { - if (empty($configs['machine_id']) || empty($configs['password'])) { - throw new \LogicException('Missing required config: machine_id or password.'); - } - } - - parent::__construct($configs, $requestHandler, $logger); + /** + * @var TWatcherConfig + */ + protected $configs; + /** + * @inheritDoc + */ + protected function getConfiguration(): Configuration + { + return new Watcher(); } /** @@ -43,8 +41,10 @@ public function login(array $scenarios = []): array 'scenarios' => $scenarios, ]; if (isset($this->configs['auth_type']) && Constants::AUTH_KEY === $this->configs['auth_type']) { - $data['machine_id'] = $this->configs['machine_id'] ?? ''; - $data['password'] = $this->configs['password'] ?? ''; + /** @var array{machine_id?: string, password?: string} $configs */ + $configs = $this->configs; + $data['machine_id'] = $configs['machine_id'] ?? ''; + $data['password'] = $configs['password'] ?? ''; } return $this->manageRequest( diff --git a/tests/Integration/BouncerTest.php b/tests/Integration/BouncerTest.php index b4b15a9..a2ce743 100644 --- a/tests/Integration/BouncerTest.php +++ b/tests/Integration/BouncerTest.php @@ -22,9 +22,6 @@ use CrowdSec\LapiClient\TimeoutException; use PHPUnit\Framework\TestCase; -/** - * @coversNothing - */ final class BouncerTest extends TestCase { /** @@ -50,7 +47,7 @@ private function addTlsConfig(&$bouncerConfigs, $tlsPath) protected function setUp(): void { - $this->useTls = (string) getenv('BOUNCER_TLS_PATH'); + $this->useTls = (string)getenv('BOUNCER_TLS_PATH'); $bouncerConfigs = [ 'auth_type' => $this->useTls ? Constants::AUTH_TLS : Constants::AUTH_KEY, @@ -105,11 +102,28 @@ public function testDecisionsStream($requestHandler) // Add decisions $now = new \DateTime(); $this->watcherClient->addDecision($now, '12h', '+12 hours', TestConstants::BAD_IP, 'captcha'); - $this->watcherClient->addDecision($now, '24h', '+24 hours', TestConstants::BAD_IP . '/' . TestConstants::IP_RANGE, 'ban'); - $this->watcherClient->addDecision($now, '24h', '+24 hours', TestConstants::JAPAN, 'captcha', Constants::SCOPE_COUNTRY); + $this->watcherClient->addDecision( + $now, + '24h', + '+24 hours', + TestConstants::BAD_IP . '/' . TestConstants::IP_RANGE, + 'ban' + ); + $this->watcherClient->addDecision( + $now, + '24h', + '+24 hours', + TestConstants::JAPAN, + 'captcha', + Constants::SCOPE_COUNTRY + ); // Retrieve default decisions (Ip and Range) without startup $response = $client->getStreamDecisions(false); - $this->assertCount(2, $response['new'], 'Should be 2 active decisions for default scopes Ip and Range. Response: ' . json_encode($response)); + $this->assertCount( + 2, + $response['new'], + 'Should be 2 active decisions for default scopes Ip and Range. Response: ' . json_encode($response) + ); // Retrieve all decisions (Ip, Range and Country) with startup $response = $client->getStreamDecisions( true, @@ -117,7 +131,11 @@ public function testDecisionsStream($requestHandler) 'scopes' => Constants::SCOPE_IP . ',' . Constants::SCOPE_RANGE . ',' . Constants::SCOPE_COUNTRY, ] ); - $this->assertCount(3, $response['new'], 'Should be 3 active decisions for all scopes. Response: ' . json_encode($response)); + $this->assertCount( + 3, + $response['new'], + 'Should be 3 active decisions for all scopes. Response: ' . json_encode($response) + ); // Retrieve all decisions (Ip, Range and Country) without startup $response = $client->getStreamDecisions( false, @@ -125,7 +143,10 @@ public function testDecisionsStream($requestHandler) 'scopes' => Constants::SCOPE_IP . ',' . Constants::SCOPE_RANGE . ',' . Constants::SCOPE_COUNTRY, ] ); - $this->assertNull($response['new'], 'Should be no new if startup has been done. Response: ' . json_encode($response)); + $this->assertNull( + $response['new'], + 'Should be no new if startup has been done. Response: ' . json_encode($response) + ); // Delete all decisions $this->watcherClient->deleteAllDecisions(); $response = $client->getStreamDecisions( @@ -135,7 +156,10 @@ public function testDecisionsStream($requestHandler) ] ); $this->assertNull($response['new'], 'Should be no new decision yet. Response: ' . json_encode($response)); - $this->assertNotNull($response['deleted'], 'Should be deleted decisions now. Response: ' . json_encode($response)); + $this->assertNotNull( + $response['deleted'], + 'Should be deleted decisions now. Response: ' . json_encode($response) + ); } /** @@ -219,10 +243,18 @@ public function testFilteredDecisions($requestHandler) $now = new \DateTime(); $this->watcherClient->addDecision($now, '12h', '+12 hours', TestConstants::BAD_IP, 'captcha'); $this->watcherClient->addDecision($now, '24h', '+24 hours', '1.2.3.0/' . TestConstants::IP_RANGE, 'ban'); - $this->watcherClient->addDecision($now, '24h', '+24 hours', TestConstants::JAPAN, 'captcha', Constants::SCOPE_COUNTRY); + $this->watcherClient->addDecision( + $now, + '24h', + '+24 hours', + TestConstants::JAPAN, + 'captcha', + Constants::SCOPE_COUNTRY + ); $response = $client->getFilteredDecisions(['ip' => TestConstants::BAD_IP]); $this->assertCount(2, $response, '2 decisions for specified IP. Response: ' . json_encode($response)); - $response = $client->getFilteredDecisions(['scope' => Constants::SCOPE_COUNTRY, 'value' => TestConstants::JAPAN]); + $response = $client->getFilteredDecisions(['scope' => Constants::SCOPE_COUNTRY, 'value' => TestConstants::JAPAN] + ); $this->assertCount(1, $response, '1 decision for specified country. Response: ' . json_encode($response)); $response = $client->getFilteredDecisions(['range' => '1.2.3.0/' . TestConstants::IP_RANGE]); $this->assertCount(1, $response, '1 decision for specified range. Response: ' . json_encode($response)); @@ -233,7 +265,11 @@ public function testFilteredDecisions($requestHandler) // Delete all decisions $this->watcherClient->deleteAllDecisions(); $response = $client->getFilteredDecisions(['ip' => TestConstants::BAD_IP]); - $this->assertCount(0, $response, '0 decision after delete for specified IP. Response: ' . json_encode($response)); + $this->assertCount( + 0, + $response, + '0 decision after delete for specified IP. Response: ' . json_encode($response) + ); } /** @@ -271,16 +307,22 @@ public function testAppSecDecision($requestHandler) // Test 1: clean GET request $response = $client->getAppSecDecision($headers); - $this->assertEquals(['action' => 'allow', 'http_status' => 200], $response, 'Should receive 200. Response: ' . json_encode($response)); + $this->assertEquals(['action' => 'allow', 'http_status' => 200], + $response, + 'Should receive 200. Response: ' . json_encode($response)); // Test 2: malicious GET request $headers['X-Crowdsec-Appsec-Uri'] = '/.env'; $response = $client->getAppSecDecision($headers); - $this->assertEquals(['action' => 'ban', 'http_status' => 403], $response, 'Should receive 403. Response: ' . json_encode($response)); + $this->assertEquals(['action' => 'ban', 'http_status' => 403], + $response, + 'Should receive 403. Response: ' . json_encode($response)); // Test 3: clean POST request $headers['X-Crowdsec-Appsec-Verb'] = 'POST'; $headers['X-Crowdsec-Appsec-Uri'] = '/login'; $response = $client->getAppSecDecision($headers, 'something'); - $this->assertEquals(['action' => 'allow', 'http_status' => 200], $response, 'Should receive 200. Response: ' . json_encode($response)); + $this->assertEquals(['action' => 'allow', 'http_status' => 200], + $response, + 'Should receive 200. Response: ' . json_encode($response)); // Test 4: malicious POST request $headers['X-Crowdsec-Appsec-Uri'] = '/login'; $rawBody = 'class.module.classLoader.resources.'; // Malicious payload (@see /etc/crowdsec/appsec-rules/vpatch-CVE-2022-22965.yaml) @@ -290,7 +332,9 @@ public function testAppSecDecision($requestHandler) } $response = $client->getAppSecDecision($headers, $rawBody); - $this->assertEquals(['action' => 'ban', 'http_status' => 403], $response, 'Should receive 403. Response: ' . json_encode($response)); + $this->assertEquals(['action' => 'ban', 'http_status' => 403], + $response, + 'Should receive 403. Response: ' . json_encode($response)); } /** @@ -328,7 +372,12 @@ public function testAppSecDecisionTimeout($requestHandler) } catch (TimeoutException $e) { $error = $e->getMessage(); if ('FileGetContents' === $requestHandler) { - PHPUnitUtil::assertRegExp($this, '/^file_get_contents call timeout/', $error, 'Should be file_get_contents timeout'); + PHPUnitUtil::assertRegExp( + $this, + '/^file_get_contents call timeout/', + $error, + 'Should be file_get_contents timeout' + ); } else { // Curl by default PHPUnitUtil::assertRegExp($this, '/^CURL call timeout/', $error, 'Should be CURL timeout'); diff --git a/tests/MockedData.php b/tests/MockedData.php index 79ce7a9..b9bb7c8 100644 --- a/tests/MockedData.php +++ b/tests/MockedData.php @@ -36,5 +36,29 @@ class MockedData public const APPSEC_ALLOWED = <<createMock(TokenStorageInterface::class); + $tokenStorage->method('retrieveToken')->willReturn($token); + + return $tokenStorage; + } + + private function createFailingTokenStorage(): TokenStorageInterface + { + $tokenStorage = $this->createMock(TokenStorageInterface::class); + $tokenStorage->method('retrieveToken')->willReturn(null); + + return $tokenStorage; + } + + public function testAlertsClientInit() + { + $tokenStorage = $this->createTokenStorage(); + $client = new AlertsClient($this->configs, $tokenStorage); + + $this->assertInstanceOf( + AlertsClient::class, + $client, + 'AlertsClient should be instantiated' + ); + } + + public function testPush() + { + $mockCurl = $this->getCurlMock(['handle']); + $tokenStorage = $this->createTokenStorage(); + + $mockClient = $this->getMockBuilder(AlertsClient::class) + ->enableOriginalConstructor() + ->setConstructorArgs([ + 'configs' => $this->configs, + 'tokenStorage' => $tokenStorage, + 'requestHandler' => $mockCurl, + ]) + ->onlyMethods(['sendRequest']) + ->getMock(); + + $mockCurl->expects($this->exactly(1))->method('handle')->will( + $this->returnValue( + new Response(MockedData::ALERTS_PUSH_SUCCESS, MockedData::HTTP_200, []) + ) + ); + + $alerts = [ + [ + 'scenario' => 'test/scenario', + 'scenario_hash' => 'abc123', + 'scenario_version' => '1.0', + 'message' => 'Test alert', + 'events_count' => 1, + 'start_at' => '2025-01-01T00:00:00Z', + 'stop_at' => '2025-01-01T00:00:01Z', + 'capacity' => 10, + 'leakspeed' => '10/1s', + 'simulated' => false, + 'remediation' => true, + 'events' => [], + ], + ]; + + $response = $mockClient->push($alerts); + + $this->assertEquals( + ['1'], + $response, + 'Should return alert IDs' + ); + } + + public function testSearch() + { + $mockCurl = $this->getCurlMock(['handle']); + $tokenStorage = $this->createTokenStorage(); + + $mockClient = $this->getMockBuilder(AlertsClient::class) + ->enableOriginalConstructor() + ->setConstructorArgs([ + 'configs' => $this->configs, + 'tokenStorage' => $tokenStorage, + 'requestHandler' => $mockCurl, + ]) + ->onlyMethods(['sendRequest']) + ->getMock(); + + $mockCurl->expects($this->exactly(1))->method('handle')->will( + $this->returnValue( + new Response(MockedData::ALERTS_SEARCH_SUCCESS, MockedData::HTTP_200, []) + ) + ); + + $response = $mockClient->search(['scope' => 'ip', 'value' => '1.2.3.4']); + + $this->assertIsArray($response); + } + + public function testDelete() + { + $mockCurl = $this->getCurlMock(['handle']); + $tokenStorage = $this->createTokenStorage(); + + $mockClient = $this->getMockBuilder(AlertsClient::class) + ->enableOriginalConstructor() + ->setConstructorArgs([ + 'configs' => $this->configs, + 'tokenStorage' => $tokenStorage, + 'requestHandler' => $mockCurl, + ]) + ->onlyMethods(['sendRequest']) + ->getMock(); + + $mockCurl->expects($this->exactly(1))->method('handle')->will( + $this->returnValue( + new Response(MockedData::ALERTS_DELETE_SUCCESS, MockedData::HTTP_200, []) + ) + ); + + $response = $mockClient->delete(['scope' => 'ip', 'value' => '1.2.3.4']); + + $this->assertIsArray($response); + } + + public function testGetById() + { + $mockCurl = $this->getCurlMock(['handle']); + $tokenStorage = $this->createTokenStorage(); + + $mockClient = $this->getMockBuilder(AlertsClient::class) + ->enableOriginalConstructor() + ->setConstructorArgs([ + 'configs' => $this->configs, + 'tokenStorage' => $tokenStorage, + 'requestHandler' => $mockCurl, + ]) + ->onlyMethods(['sendRequest']) + ->getMock(); + + $mockCurl->expects($this->exactly(1))->method('handle')->will( + $this->returnValue( + new Response(MockedData::ALERT_BY_ID_SUCCESS, MockedData::HTTP_200, []) + ) + ); + + $response = $mockClient->getById(1); + + $this->assertIsArray($response); + $this->assertEquals(1, $response['id']); + } + + public function testGetByIdNotFound() + { + $mockCurl = $this->getCurlMock(['handle']); + $tokenStorage = $this->createTokenStorage(); + + $mockClient = $this->getMockBuilder(AlertsClient::class) + ->enableOriginalConstructor() + ->setConstructorArgs([ + 'configs' => $this->configs, + 'tokenStorage' => $tokenStorage, + 'requestHandler' => $mockCurl, + ]) + ->onlyMethods(['sendRequest']) + ->getMock(); + + $mockCurl->expects($this->exactly(1))->method('handle')->will( + $this->returnValue( + new Response(MockedData::ALERT_NOT_FOUND, MockedData::HTTP_200, []) + ) + ); + + $response = $mockClient->getById(999); + + $this->assertNull($response); + } + + public function testLoginFailure() + { + $tokenStorage = $this->createFailingTokenStorage(); + + $client = new AlertsClient($this->configs, $tokenStorage); + + $error = ''; + try { + $client->search([]); + } catch (ClientException $e) { + $error = $e->getMessage(); + } + + PHPUnitUtil::assertRegExp( + $this, + '/Login fail/', + $error, + 'Should throw ClientException on login failure' + ); + } +} \ No newline at end of file diff --git a/tests/Unit/BouncerTest.php b/tests/Unit/BouncerTest.php index bcf284d..a83985c 100644 --- a/tests/Unit/BouncerTest.php +++ b/tests/Unit/BouncerTest.php @@ -25,7 +25,7 @@ /** * @covers \CrowdSec\LapiClient\Bouncer::__construct - * @covers \CrowdSec\LapiClient\Bouncer::configure + * @covers \CrowdSec\LapiClient\AbstractLapiClient::configure * @covers \CrowdSec\LapiClient\Bouncer::manageRequest * @covers \CrowdSec\LapiClient\Bouncer::getStreamDecisions * @covers \CrowdSec\LapiClient\Bouncer::getFilteredDecisions @@ -47,9 +47,10 @@ * @covers \CrowdSec\LapiClient\Metrics::configureMeta * @covers \CrowdSec\LapiClient\Metrics::configureProperties * @covers \CrowdSec\LapiClient\Metrics::toArray + * @covers \CrowdSec\LapiClient\AbstractLapiClient::getConfiguration * - * @uses \CrowdSec\LapiClient\Bouncer::cleanHeadersForLog - * @uses \CrowdSec\LapiClient\Bouncer::cleanRawBodyForLog() + * @uses \CrowdSec\LapiClient\Bouncer::cleanHeadersForLog + * @uses \CrowdSec\LapiClient\Bouncer::cleanRawBodyForLog() */ final class BouncerTest extends AbstractClient { @@ -69,7 +70,7 @@ public function testDecisionsStreamParams() ['startup' => true], [ 'User-Agent' => Constants::USER_AGENT_PREFIX . '_' . TestConstants::USER_AGENT_SUFFIX - . '/' . TestConstants::USER_AGENT_VERSION, + . '/' . TestConstants::USER_AGENT_VERSION, 'X-Api-Key' => TestConstants::API_KEY, ], ] @@ -93,7 +94,7 @@ public function testFilteredDecisionsParams() ['ip' => '1.2.3.4'], [ 'User-Agent' => Constants::USER_AGENT_PREFIX . '_' . TestConstants::USER_AGENT_SUFFIX - . '/' . TestConstants::USER_AGENT_VERSION, + . '/' . TestConstants::USER_AGENT_VERSION, 'X-Api-Key' => TestConstants::API_KEY, ], ] @@ -267,7 +268,7 @@ public function testAppSecDecisionParams() $headers = [ 'User-Agent' => Constants::USER_AGENT_PREFIX . '_' . TestConstants::USER_AGENT_SUFFIX - . '/' . TestConstants::USER_AGENT_VERSION, + . '/' . TestConstants::USER_AGENT_VERSION, 'X-Api-Key' => TestConstants::API_KEY, ]; @@ -296,9 +297,11 @@ public function testRequest() ->onlyMethods(['sendRequest']) ->getMock(); - $mockCurl->expects($this->exactly(1))->method('handle')->will($this->returnValue( - new Response(MockedData::DECISIONS_FILTER, MockedData::HTTP_200, []) - )); + $mockCurl->expects($this->exactly(1))->method('handle')->will( + $this->returnValue( + new Response(MockedData::DECISIONS_FILTER, MockedData::HTTP_200, []) + ) + ); $response = PHPUnitUtil::callMethod( $mockClient, @@ -333,7 +336,11 @@ public function testRequest() 'Not allowed method should throw an exception before sending request' ); - $this->assertEquals('CrowdSec\LapiClient\ClientException', $errorClass, 'Thrown exception should be an instance of CrowdSec\LapiClient\ClientException'); + $this->assertEquals( + 'CrowdSec\LapiClient\ClientException', + $errorClass, + 'Thrown exception should be an instance of CrowdSec\LapiClient\ClientException' + ); } public function testRequestAppSec() @@ -351,9 +358,11 @@ public function testRequestAppSec() ->onlyMethods(['sendRequest']) ->getMock(); - $mockCurl->expects($this->exactly(1))->method('handle')->will($this->returnValue( - new Response(MockedData::APPSEC_ALLOWED, MockedData::HTTP_200, []) - )); + $mockCurl->expects($this->exactly(1))->method('handle')->will( + $this->returnValue( + new Response(MockedData::APPSEC_ALLOWED, MockedData::HTTP_200, []) + ) + ); $response = PHPUnitUtil::callMethod( $mockClient, @@ -388,7 +397,11 @@ public function testRequestAppSec() 'Not allowed method should throw an exception before sending request' ); - $this->assertEquals('CrowdSec\LapiClient\ClientException', $errorClass, 'Thrown exception should be an instance of CrowdSec\LapiClient\ClientException'); + $this->assertEquals( + 'CrowdSec\LapiClient\ClientException', + $errorClass, + 'Thrown exception should be an instance of CrowdSec\LapiClient\ClientException' + ); } public function testConfigure() @@ -553,7 +566,14 @@ public function testConfigure() $error = ''; try { - new Bouncer(['auth_type' => Constants::AUTH_TLS, 'tls_cert_path' => 'test', 'tls_key_path' => 'test', 'tls_verify_peer' => true]); + new Bouncer( + [ + 'auth_type' => Constants::AUTH_TLS, + 'tls_cert_path' => 'test', + 'tls_key_path' => 'test', + 'tls_verify_peer' => true + ] + ); } catch (\Exception $e) { $error = $e->getMessage(); } diff --git a/tests/Unit/CurlTest.php b/tests/Unit/CurlTest.php index b2b7bd6..c719865 100644 --- a/tests/Unit/CurlTest.php +++ b/tests/Unit/CurlTest.php @@ -20,15 +20,16 @@ use CrowdSec\LapiClient\TimeoutException; /** - * @uses \CrowdSec\LapiClient\Configuration::getConfigTreeBuilder - * @uses \CrowdSec\LapiClient\Bouncer::__construct - * @uses \CrowdSec\LapiClient\Bouncer::configure - * @uses \CrowdSec\LapiClient\Bouncer::formatUserAgent - * @uses \CrowdSec\LapiClient\Configuration::addConnectionNodes - * @uses \CrowdSec\LapiClient\Configuration::validate - * @uses \CrowdSec\LapiClient\Configuration::addAppSecNodes - * @uses \CrowdSec\LapiClient\Bouncer::cleanHeadersForLog - * @uses \CrowdSec\LapiClient\Bouncer::cleanRawBodyForLog() + * @uses \CrowdSec\LapiClient\Configuration::getConfigTreeBuilder + * @uses \CrowdSec\LapiClient\Bouncer::__construct + * @uses \CrowdSec\LapiClient\Bouncer::configure + * @uses \CrowdSec\LapiClient\Bouncer::formatUserAgent + * @uses \CrowdSec\LapiClient\Configuration::addConnectionNodes + * @uses \CrowdSec\LapiClient\Configuration::validate + * @uses \CrowdSec\LapiClient\Configuration::addAppSecNodes + * @uses \CrowdSec\LapiClient\Bouncer::cleanHeadersForLog + * @uses \CrowdSec\LapiClient\Bouncer::cleanRawBodyForLog() + * @uses \CrowdSec\LapiClient\AbstractLapiClient::getConfiguration * * @covers \CrowdSec\LapiClient\Bouncer::getStreamDecisions * @covers \CrowdSec\LapiClient\Bouncer::getFilteredDecisions diff --git a/tests/Unit/FileGetContentsTest.php b/tests/Unit/FileGetContentsTest.php index 27ea5b7..eedf2b5 100644 --- a/tests/Unit/FileGetContentsTest.php +++ b/tests/Unit/FileGetContentsTest.php @@ -22,14 +22,15 @@ use CrowdSec\LapiClient\TimeoutException; /** - * @uses \CrowdSec\LapiClient\Configuration::getConfigTreeBuilder - * @uses \CrowdSec\LapiClient\Bouncer::__construct - * @uses \CrowdSec\LapiClient\Bouncer::configure - * @uses \CrowdSec\LapiClient\Bouncer::formatUserAgent - * @uses \CrowdSec\LapiClient\Bouncer::manageRequest - * @uses \CrowdSec\LapiClient\Configuration::addConnectionNodes - * @uses \CrowdSec\LapiClient\Configuration::validate - * @uses \CrowdSec\LapiClient\Configuration::addAppSecNodes + * @uses \CrowdSec\LapiClient\Configuration::getConfigTreeBuilder + * @uses \CrowdSec\LapiClient\Bouncer::__construct + * @uses \CrowdSec\LapiClient\Bouncer::configure + * @uses \CrowdSec\LapiClient\Bouncer::formatUserAgent + * @uses \CrowdSec\LapiClient\Bouncer::manageRequest + * @uses \CrowdSec\LapiClient\Configuration::addConnectionNodes + * @uses \CrowdSec\LapiClient\Configuration::validate + * @uses \CrowdSec\LapiClient\Configuration::addAppSecNodes + * @uses \CrowdSec\LapiClient\AbstractLapiClient::getConfiguration * * @covers \CrowdSec\LapiClient\Bouncer::getStreamDecisions * @covers \CrowdSec\LapiClient\Bouncer::getFilteredDecisions diff --git a/tests/Unit/Payload/AlertTest.php b/tests/Unit/Payload/AlertTest.php index b6de555..2723b15 100644 --- a/tests/Unit/Payload/AlertTest.php +++ b/tests/Unit/Payload/AlertTest.php @@ -5,6 +5,23 @@ use CrowdSec\LapiClient\Payload\Alert; use PHPUnit\Framework\TestCase; +/** + * @covers \CrowdSec\LapiClient\Payload\Alert::__construct + * @covers \CrowdSec\LapiClient\Payload\Alert::toArray + * @covers \CrowdSec\LapiClient\Payload\Alert::fromArray + * @covers \CrowdSec\LapiClient\Payload\Alert::jsonSerialize + * @covers \CrowdSec\LapiClient\Payload\Alert::configureDecisions + * @covers \CrowdSec\LapiClient\Payload\Alert::configureEvents + * @covers \CrowdSec\LapiClient\Payload\Alert::configureMeta + * @covers \CrowdSec\LapiClient\Payload\Alert::configureProperties + * @covers \CrowdSec\LapiClient\Payload\Alert::configureSource + * @covers \CrowdSec\LapiClient\Payload\Alert::handleList + * @covers \CrowdSec\LapiClient\Configuration\Alert::getConfigTreeBuilder + * @covers \CrowdSec\LapiClient\Configuration\Alert\Decision::getConfigTreeBuilder + * @covers \CrowdSec\LapiClient\Configuration\Alert\Event::getConfigTreeBuilder + * @covers \CrowdSec\LapiClient\Configuration\Alert\Meta::getConfigTreeBuilder + * @covers \CrowdSec\LapiClient\Configuration\Alert\Source::getConfigTreeBuilder + */ class AlertTest extends TestCase { /** @@ -90,4 +107,83 @@ public function dpConstruct(): iterable $minimal, ]; } + + public function testFromArray(): void + { + $data = [ + 'scenario' => 'crowdsecurity/http-probing', + 'scenario_hash' => 'abc123', + 'scenario_version' => '1.0', + 'message' => 'Probing detected', + 'events_count' => 3, + 'start_at' => '2025-01-01T00:00:00Z', + 'stop_at' => '2025-01-01T00:10:00Z', + 'capacity' => 10, + 'leakspeed' => '10/1s', + 'simulated' => false, + 'remediation' => true, + 'source' => [ + 'scope' => 'ip', + 'value' => '1.2.3.4', + ], + 'events' => [ + [ + 'meta' => [ + ['key' => 'path', 'value' => '/admin'], + ], + 'timestamp' => '2025-01-01T00:00:01Z', + ], + ], + 'decisions' => [ + [ + 'origin' => 'lapi', + 'type' => 'ban', + 'scope' => 'ip', + 'value' => '1.2.3.4', + 'duration' => '4h', + 'scenario' => 'crowdsecurity/http-probing', + ], + ], + 'meta' => [ + ['key' => 'service', 'value' => 'nginx'], + ], + 'labels' => ['http'], + ]; + + $alert = Alert::fromArray($data); + + self::assertEquals($data, $alert->toArray()); + } + + public function testJsonSerialize(): void + { + $data = [ + 'scenario' => 'crowdsecurity/http-probing', + 'scenario_hash' => 'abc123', + 'scenario_version' => '1.0', + 'message' => 'Probing detected', + 'events_count' => 3, + 'start_at' => '2025-01-01T00:00:00Z', + 'stop_at' => '2025-01-01T00:10:00Z', + 'capacity' => 10, + 'leakspeed' => '10/1s', + 'simulated' => false, + 'remediation' => true, + 'events' => [], + ]; + + $alert = new Alert( + $data, + null, + [], + [], + [], + [] + ); + + $json = json_encode($alert); + $decoded = json_decode($json, true); + + self::assertEquals($data, $decoded); + } } diff --git a/tests/Unit/Storage/TokenStorageTest.php b/tests/Unit/Storage/TokenStorageTest.php index 57adaad..a848dd2 100644 --- a/tests/Unit/Storage/TokenStorageTest.php +++ b/tests/Unit/Storage/TokenStorageTest.php @@ -8,7 +8,8 @@ use Symfony\Component\Cache\Adapter\ArrayAdapter; /** - * @coversDefaultClass \CrowdSec\LapiClient\Storage\TokenStorage + * @covers \CrowdSec\LapiClient\Storage\TokenStorage::retrieveToken + * @covers \CrowdSec\LapiClient\Storage\TokenStorage::__construct */ final class TokenStorageTest extends TestCase { @@ -31,4 +32,20 @@ public function testLoginSuccess(): void $ci = $cache->getItem('crowdsec_token'); self::assertSame('j.w.t', $ci->get()); } + + public function testLoginFailure(): void + { + $watcher = $this->createMock(WatcherClient::class); + $watcher + ->expects(self::once()) + ->method('login') + ->willReturn([ + 'code' => 401, + 'message' => 'Unauthorized', + ]); + $cache = new ArrayAdapter(); + $storage = new TokenStorage($watcher, $cache); + self::assertNull($storage->retrieveToken()); + self::assertFalse($cache->hasItem('crowdsec_token')); + } } diff --git a/tests/Unit/WatcherClientTest.php b/tests/Unit/WatcherClientTest.php new file mode 100644 index 0000000..7f95fe3 --- /dev/null +++ b/tests/Unit/WatcherClientTest.php @@ -0,0 +1,175 @@ +configs = array_merge($this->configs, [ + 'machine_id' => 'test-machine', + 'password' => 'test-password', + ]); + } + + public function testWatcherClientInit() + { + $client = new WatcherClient($this->configs); + + $this->assertInstanceOf( + WatcherClient::class, + $client, + 'WatcherClient should be instantiated' + ); + + $configuration = PHPUnitUtil::callMethod($client, 'getConfiguration', []); + $this->assertInstanceOf( + Watcher::class, + $configuration, + 'WatcherClient should use Watcher configuration' + ); + } + + public function testLoginParams() + { + $mockClient = $this->getMockBuilder(WatcherClient::class) + ->enableOriginalConstructor() + ->setConstructorArgs(['configs' => $this->configs]) + ->onlyMethods(['request']) + ->getMock(); + + $mockClient->expects($this->exactly(1))->method('request') + ->with( + 'POST', + Constants::WATCHER_LOGIN_ENDPOINT, + [ + 'scenarios' => ['test/scenario'], + 'machine_id' => 'test-machine', + 'password' => 'test-password', + ], + $this->anything() + ); + + $mockClient->login(['test/scenario']); + } + + public function testLoginWithTlsAuth() + { + $tlsConfigs = [ + 'auth_type' => Constants::AUTH_TLS, + 'tls_cert_path' => '/path/to/cert', + 'tls_key_path' => '/path/to/key', + ]; + + $mockClient = $this->getMockBuilder(WatcherClient::class) + ->enableOriginalConstructor() + ->setConstructorArgs(['configs' => $tlsConfigs]) + ->onlyMethods(['request']) + ->getMock(); + + // With TLS auth, machine_id and password should NOT be included + $mockClient->expects($this->exactly(1))->method('request') + ->with( + 'POST', + Constants::WATCHER_LOGIN_ENDPOINT, + ['scenarios' => []], + $this->anything() + ); + + $mockClient->login(); + } + + public function testLoginRequest() + { + $mockCurl = $this->getCurlMock(['handle']); + + $mockClient = $this->getMockBuilder(WatcherClient::class) + ->enableOriginalConstructor() + ->setConstructorArgs([ + 'configs' => $this->configs, + 'requestHandler' => $mockCurl, + ]) + ->onlyMethods(['sendRequest']) + ->getMock(); + + $mockCurl->expects($this->exactly(1))->method('handle')->will( + $this->returnValue( + new Response(MockedData::LOGIN_SUCCESS, MockedData::HTTP_200, []) + ) + ); + + $response = $mockClient->login(['test/scenario']); + + $this->assertEquals( + json_decode(MockedData::LOGIN_SUCCESS, true), + $response, + 'Should return login response' + ); + } + + public function testConfigureValidation() + { + // Test missing machine_id + $error = ''; + try { + new WatcherClient([ + 'api_key' => 'test-key', + 'password' => 'test-password', + ]); + } catch (\Exception $e) { + $error = $e->getMessage(); + } + + PHPUnitUtil::assertRegExp( + $this, + '/machine_id and password are required/', + $error, + 'machine_id should be required for api_key auth' + ); + + // Test missing password + $error = ''; + try { + new WatcherClient([ + 'api_key' => 'test-key', + 'machine_id' => 'test-machine', + ]); + } catch (\Exception $e) { + $error = $e->getMessage(); + } + + PHPUnitUtil::assertRegExp( + $this, + '/machine_id and password are required/', + $error, + 'password should be required for api_key auth' + ); + } +} \ No newline at end of file diff --git a/tools/coding-standards/phpunit/phpunit.xml b/tools/coding-standards/phpunit/phpunit.xml index aa555e8..1eaa5c0 100644 --- a/tools/coding-standards/phpunit/phpunit.xml +++ b/tools/coding-standards/phpunit/phpunit.xml @@ -1,7 +1,7 @@ + From dcac504f14efd37bf47ead241f5328b733c169e7 Mon Sep 17 00:00:00 2001 From: Julien Loizelet Date: Fri, 12 Dec 2025 15:58:14 +0900 Subject: [PATCH 39/43] docs(watcher): Add Wathcer and Alert client doc --- docs/DEVELOPER.md | 20 +++--- docs/USER_GUIDE.md | 155 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 164 insertions(+), 11 deletions(-) diff --git a/docs/DEVELOPER.md b/docs/DEVELOPER.md index 5a6a9c0..a59f53e 100644 --- a/docs/DEVELOPER.md +++ b/docs/DEVELOPER.md @@ -9,17 +9,17 @@ **Table of Contents** - [Local development](#local-development) - - [DDEV setup](#ddev-setup) - - [DDEV installation](#ddev-installation) - - [Prepare DDEV PHP environment](#prepare-ddev-php-environment) - - [DDEV Usage](#ddev-usage) - - [Use composer to update or install the lib](#use-composer-to-update-or-install-the-lib) - - [Unit test](#unit-test) - - [Integration test](#integration-test) - - [Coding standards](#coding-standards) - - [Testing timeout in the CrowdSec container](#testing-timeout-in-the-crowdsec-container) + - [DDEV setup](#ddev-setup) + - [DDEV installation](#ddev-installation) + - [Prepare DDEV PHP environment](#prepare-ddev-php-environment) + - [DDEV Usage](#ddev-usage) + - [Use composer to update or install the lib](#use-composer-to-update-or-install-the-lib) + - [Unit test](#unit-test) + - [Integration test](#integration-test) + - [Coding standards](#coding-standards) + - [Testing timeout in the CrowdSec container](#testing-timeout-in-the-crowdsec-container) - [Commit message](#commit-message) - - [Allowed message `type` values](#allowed-message-type-values) + - [Allowed message `type` values](#allowed-message-type-values) - [Update documentation table of contents](#update-documentation-table-of-contents) - [Release process](#release-process) diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 62f9378..2ff1144 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -14,6 +14,12 @@ - [Installation](#installation) - [Bouncer client instantiation](#bouncer-client-instantiation) - [LAPI calls](#lapi-calls) + - [Watcher client instantiation](#watcher-client-instantiation) + - [Watcher configuration](#watcher-configuration) + - [Login](#login) + - [Alerts client instantiation](#alerts-client-instantiation) + - [Token Storage](#token-storage) + - [LAPI calls](#lapi-calls-1) - [Bouncer client configurations](#bouncer-client-configurations) - [LAPI url](#lapi-url) - [AppSec url](#appsec-url) @@ -59,11 +65,18 @@ This client allows you to interact with the CrowdSec Local API (LAPI). ## Features -- CrowdSec LAPI Bouncer available endpoints +- CrowdSec LAPI Bouncer client - Retrieve decisions stream list - Retrieve decisions for some filter - Retrieve AppSec decision - Push usage metrics +- CrowdSec LAPI Watcher client + - Login to LAPI +- CrowdSec LAPI Alerts client + - Push alerts + - Search alerts + - Delete alerts + - Get alert by ID - Overridable request handler (`curl` by default, `file_get_contents` also available) @@ -168,6 +181,146 @@ The `$usageMetrics` parameter is an array containing the usage metrics to push. We provide a `buildUsageMetrics` method to help you build the `$usageMetrics` array. +### Watcher client instantiation + +The Watcher client is used to authenticate a machine to LAPI. It requires a `machine_id` and `password` when using API key authentication. + +```php +use CrowdSec\LapiClient\WatcherClient; + +$configs = [ + 'auth_type' => 'api_key', + 'api_url' => 'https://your-crowdsec-lapi-url:8080', + 'machine_id' => 'your-machine-id', + 'password' => 'your-machine-password', +]; +$watcherClient = new WatcherClient($configs); +``` + +#### Watcher configuration + +In addition to the [common configurations](#bouncer-client-configurations), the Watcher client requires: + +- `machine_id`: The machine ID registered with CrowdSec (required for `api_key` auth type) +- `password`: The machine password (required for `api_key` auth type) + +#### Login + +To authenticate and retrieve a JWT token: + +```php +$response = $watcherClient->login($scenarios); +// $response contains: ['code' => 200, 'expire' => '...', 'token' => '...'] +``` + +The `$scenarios` parameter is an optional array of scenario names that the watcher is interested in. + + +### Alerts client instantiation + +The Alerts client allows you to push, search, and manage alerts. It requires a token storage implementation to handle JWT authentication. + +#### Token Storage + +The Alerts client needs a `TokenStorageInterface` implementation to store and retrieve authentication tokens. You must provide a PSR-6 compatible cache implementation (e.g., `symfony/cache`): + +```php +use CrowdSec\LapiClient\AlertsClient; +use CrowdSec\LapiClient\WatcherClient; +use CrowdSec\LapiClient\Storage\TokenStorage; +use Symfony\Component\Cache\Adapter\FilesystemAdapter; + +// Create a cache adapter (requires symfony/cache or any PSR-6 implementation) +$cache = new FilesystemAdapter('crowdsec', 0, '/path/to/cache'); + +// Create a watcher client for authentication +$watcherConfigs = [ + 'auth_type' => 'api_key', + 'api_url' => 'https://your-crowdsec-lapi-url:8080', + 'machine_id' => 'your-machine-id', + 'password' => 'your-machine-password', +]; +$watcherClient = new WatcherClient($watcherConfigs); + +// Create token storage +$tokenStorage = new TokenStorage($watcherClient, $cache); + +// Create alerts client +$alertsConfigs = [ + 'api_url' => 'https://your-crowdsec-lapi-url:8080', + 'api_key' => '**************************', +]; +$alertsClient = new AlertsClient($alertsConfigs, $tokenStorage); +``` + +#### LAPI calls + +##### Push alerts + +To push alerts to LAPI: + +```php +$alerts = [ + [ + 'scenario' => 'crowdsecurity/http-probing', + 'scenario_hash' => 'abc123', + 'scenario_version' => '1.0', + 'message' => 'HTTP probing detected', + 'events_count' => 5, + 'start_at' => '2025-01-01T00:00:00Z', + 'stop_at' => '2025-01-01T00:10:00Z', + 'capacity' => 10, + 'leakspeed' => '10/1s', + 'simulated' => false, + 'remediation' => true, + 'source' => [ + 'scope' => 'ip', + 'value' => '1.2.3.4', + ], + 'events' => [], + ], +]; +$alertIds = $alertsClient->push($alerts); +``` + +##### Search alerts + +To search for existing alerts: + +```php +$query = [ + 'scope' => 'ip', + 'value' => '1.2.3.4', + 'limit' => 10, +]; +$alerts = $alertsClient->search($query); +``` + +Available search parameters: `scope`, `value`, `scenario`, `ip`, `range`, `since`, `until`, `simulated`, `has_active_decision`, `decision_type`, `limit`, `origin`. + +##### Delete alerts + +To delete alerts by condition: + +```php +$query = [ + 'scope' => 'ip', + 'value' => '1.2.3.4', +]; +$result = $alertsClient->delete($query); +``` + +##### Get alert by ID + +To retrieve a specific alert: + +```php +$alert = $alertsClient->getById(123); +``` + +Returns `null` if the alert is not found. + + ## Bouncer client configurations The first parameter `$configs` of the Bouncer constructor can be used to pass the following settings: From 7ffba0cb01594307d718e8777cee757756dcc088 Mon Sep 17 00:00:00 2001 From: Julien Loizelet Date: Fri, 12 Dec 2025 17:14:15 +0900 Subject: [PATCH 40/43] feat(watcher): Refactor an use only WatcherClient --- docs/USER_GUIDE.md | 107 ++++++----- src/AbstractLapiClient.php | 4 +- src/AlertsClient.php | 174 ----------------- src/Configuration.php | 43 +++-- src/Configuration/Watcher.php | 10 +- src/Storage/TokenStorage.php | 51 ----- src/Storage/TokenStorageInterface.php | 14 -- src/WatcherClient.php | 227 +++++++++++++++++++++- tests/Integration/AlertsClientTest.php | 33 ++-- tests/Integration/TestWatcherClient.php | 4 +- tests/Integration/WatcherClientTest.php | 7 +- tests/Unit/AbstractClientTest.php | 3 +- tests/Unit/AlertsClientTest.php | 235 ----------------------- tests/Unit/BouncerTest.php | 3 +- tests/Unit/CurlTest.php | 3 +- tests/Unit/FileGetContentsTest.php | 3 +- tests/Unit/Storage/TokenStorageTest.php | 51 ----- tests/Unit/WatcherClientTest.php | 242 +++++++++++++++++++++++- tests/scripts/watcher/login.php | 55 ++++++ tests/scripts/watcher/push-alert.php | 54 ++++++ tools/coding-standards/psalm/psalm.xml | 10 - 21 files changed, 695 insertions(+), 638 deletions(-) delete mode 100644 src/AlertsClient.php delete mode 100644 src/Storage/TokenStorage.php delete mode 100644 src/Storage/TokenStorageInterface.php delete mode 100644 tests/Unit/AlertsClientTest.php delete mode 100644 tests/Unit/Storage/TokenStorageTest.php create mode 100644 tests/scripts/watcher/login.php create mode 100644 tests/scripts/watcher/push-alert.php diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 2ff1144..74ff0df 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -16,9 +16,6 @@ - [LAPI calls](#lapi-calls) - [Watcher client instantiation](#watcher-client-instantiation) - [Watcher configuration](#watcher-configuration) - - [Login](#login) - - [Alerts client instantiation](#alerts-client-instantiation) - - [Token Storage](#token-storage) - [LAPI calls](#lapi-calls-1) - [Bouncer client configurations](#bouncer-client-configurations) - [LAPI url](#lapi-url) @@ -55,6 +52,12 @@ - [Push usage metrics](#push-usage-metrics) - [Command usage](#command-usage-3) - [Example](#example-2) + - [Watcher login](#watcher-login) + - [Command usage](#command-usage-4) + - [Example](#example-3) + - [Push alert](#push-alert) + - [Command usage](#command-usage-5) + - [Example](#example-4) @@ -72,7 +75,6 @@ This client allows you to interact with the CrowdSec Local API (LAPI). - Push usage metrics - CrowdSec LAPI Watcher client - Login to LAPI -- CrowdSec LAPI Alerts client - Push alerts - Search alerts - Delete alerts @@ -183,10 +185,16 @@ We provide a `buildUsageMetrics` method to help you build the `$usageMetrics` ar ### Watcher client instantiation -The Watcher client is used to authenticate a machine to LAPI. It requires a `machine_id` and `password` when using API key authentication. +The Watcher client is used to authenticate a machine to LAPI and manage alerts. It requires: +- A `machine_id` and `password` when using API key authentication +- A PSR-6 compatible cache implementation to store authentication tokens (e.g., `symfony/cache`) ```php use CrowdSec\LapiClient\WatcherClient; +use Symfony\Component\Cache\Adapter\FilesystemAdapter; + +// Create a cache adapter (requires symfony/cache or any PSR-6 implementation) +$cache = new FilesystemAdapter('crowdsec', 0, '/path/to/cache'); $configs = [ 'auth_type' => 'api_key', @@ -194,7 +202,11 @@ $configs = [ 'machine_id' => 'your-machine-id', 'password' => 'your-machine-password', ]; -$watcherClient = new WatcherClient($configs); + +// Optional: scenarios to register on login +$scenarios = ['crowdsecurity/http-probing']; + +$watcherClient = new WatcherClient($configs, $cache, $scenarios); ``` #### Watcher configuration @@ -203,10 +215,13 @@ In addition to the [common configurations](#bouncer-client-configurations), the - `machine_id`: The machine ID registered with CrowdSec (required for `api_key` auth type) - `password`: The machine password (required for `api_key` auth type) +- A PSR-6 cache implementation (mandatory) - used to store the authentication token + +#### LAPI calls -#### Login +##### Login -To authenticate and retrieve a JWT token: +To manually authenticate and retrieve a JWT token: ```php $response = $watcherClient->login($scenarios); @@ -215,45 +230,7 @@ $response = $watcherClient->login($scenarios); The `$scenarios` parameter is an optional array of scenario names that the watcher is interested in. - -### Alerts client instantiation - -The Alerts client allows you to push, search, and manage alerts. It requires a token storage implementation to handle JWT authentication. - -#### Token Storage - -The Alerts client needs a `TokenStorageInterface` implementation to store and retrieve authentication tokens. You must provide a PSR-6 compatible cache implementation (e.g., `symfony/cache`): - -```php -use CrowdSec\LapiClient\AlertsClient; -use CrowdSec\LapiClient\WatcherClient; -use CrowdSec\LapiClient\Storage\TokenStorage; -use Symfony\Component\Cache\Adapter\FilesystemAdapter; - -// Create a cache adapter (requires symfony/cache or any PSR-6 implementation) -$cache = new FilesystemAdapter('crowdsec', 0, '/path/to/cache'); - -// Create a watcher client for authentication -$watcherConfigs = [ - 'auth_type' => 'api_key', - 'api_url' => 'https://your-crowdsec-lapi-url:8080', - 'machine_id' => 'your-machine-id', - 'password' => 'your-machine-password', -]; -$watcherClient = new WatcherClient($watcherConfigs); - -// Create token storage -$tokenStorage = new TokenStorage($watcherClient, $cache); - -// Create alerts client -$alertsConfigs = [ - 'api_url' => 'https://your-crowdsec-lapi-url:8080', - 'api_key' => '**************************', -]; -$alertsClient = new AlertsClient($alertsConfigs, $tokenStorage); -``` - -#### LAPI calls +Note: You don't need to call `login()` manually before using alert methods - authentication is handled automatically. ##### Push alerts @@ -280,7 +257,7 @@ $alerts = [ 'events' => [], ], ]; -$alertIds = $alertsClient->push($alerts); +$alertIds = $watcherClient->pushAlerts($alerts); ``` ##### Search alerts @@ -293,7 +270,7 @@ $query = [ 'value' => '1.2.3.4', 'limit' => 10, ]; -$alerts = $alertsClient->search($query); +$alerts = $watcherClient->searchAlerts($query); ``` Available search parameters: `scope`, `value`, `scenario`, `ip`, `range`, `since`, `until`, `simulated`, `has_active_decision`, `decision_type`, `limit`, `origin`. @@ -307,7 +284,7 @@ $query = [ 'scope' => 'ip', 'value' => '1.2.3.4', ]; -$result = $alertsClient->delete($query); +$result = $watcherClient->deleteAlerts($query); ``` ##### Get alert by ID @@ -315,7 +292,7 @@ $result = $alertsClient->delete($query); To retrieve a specific alert: ```php -$alert = $alertsClient->getById(123); +$alert = $watcherClient->getAlertById(123); ``` Returns `null` if the alert is not found. @@ -704,3 +681,31 @@ php tests/scripts/bouncer/build-and-push-metrics.php [] +``` + +#### Example + +```bash +php tests/scripts/watcher/login.php my-machine-id my-password https://crowdsec:8080 '["crowdsecurity/http-probing"]' +``` + +### Push alert + +#### Command usage + +```bash +php tests/scripts/watcher/push-alert.php +``` + +#### Example + +```bash +php tests/scripts/watcher/push-alert.php '{"scenario":"test/scenario","scenario_hash":"abc123","scenario_version":"1.0","message":"Test alert","events_count":1,"start_at":"2025-01-01T00:00:00Z","stop_at":"2025-01-01T00:00:01Z","capacity":10,"leakspeed":"10/1s","simulated":false,"remediation":true,"source":{"scope":"ip","value":"1.2.3.4"},"events":[]}' my-machine-id my-password https://crowdsec:8080 +``` diff --git a/src/AbstractLapiClient.php b/src/AbstractLapiClient.php index faec236..53f927c 100644 --- a/src/AbstractLapiClient.php +++ b/src/AbstractLapiClient.php @@ -65,8 +65,8 @@ protected function manageRequest( array $parameters = [] ): array { try { - $this->logger->debug('Now processing a bouncer request', [ - 'type' => 'BOUNCER_CLIENT_REQUEST', + $this->logger->debug('Now processing a LAPI client request', [ + 'type' => 'LAPI_CLIENT_REQUEST', 'method' => $method, 'endpoint' => $endpoint, 'parameters' => $parameters, diff --git a/src/AlertsClient.php b/src/AlertsClient.php deleted file mode 100644 index ca3f210..0000000 --- a/src/AlertsClient.php +++ /dev/null @@ -1,174 +0,0 @@ -, - * events: list, - * events_count: int, - * id: int, - * labels: null|array, - * leakspeed: string, - * machine_id: string, - * message: string, - * meta: list, - * scenario: string, - * scenario_hash: string, - * scenario_version: string, - * simulated: bool, - * source: TSource, - * start_at: string, - * stop_at: string, - * uuid: string - * } - */ -class AlertsClient extends AbstractLapiClient -{ - /** - * @var TokenStorageInterface - */ - private $tokenStorage; - - public function __construct( - array $configs, - TokenStorageInterface $tokenStorage, - ?RequestHandlerInterface $requestHandler = null, - ?LoggerInterface $logger = null - ) { - $this->tokenStorage = $tokenStorage; - parent::__construct($configs, $requestHandler, $logger); - } - - /** - * @param list $alerts - * - * @return list - */ - public function push(array $alerts): array - { - $this->login(); - - return $this->manageRequest( - 'POST', - Constants::ALERTS_ENDPOINT, - $alerts - ); - } - - /** - * Search for alerts. - * - * scope - Show alerts for this scope. - * value - Show alerts for this value (used with scope). - * scenario - Show alerts for this scenario. - * ip - IP to search for (shorthand for scope=ip&value=). - * range - Range to search for (shorthand for scope=range&value=). - * since - Search alerts newer than delay (format must be compatible with time.ParseDuration). - * until - Search alerts older than delay (format must be compatible with time.ParseDuration). - * simulated - If set to true, decisions in simulation mode will be returned as well. - * has_active_decision: Only return alerts with decisions not expired yet. - * decision_type: Restrict results to alerts with decisions matching given type. - * limit: Number of alerts to return. - * origin: Restrict results to this origin (ie. lists,CAPI,cscli). - * - * @param TSearchQuery $query - * - * @return list - */ - public function search(array $query): array - { - $this->login(); - - return $this->manageRequest( - 'GET', - Constants::ALERTS_ENDPOINT, - $query - ); - } - - /** - * Delete alerts by condition. Can be used only on the same machine than the local API. - * - * @param TDeleteQuery $query - */ - public function delete(array $query): array - { - $this->login(); - - return $this->manageRequest( - 'DELETE', - Constants::ALERTS_ENDPOINT, - $query - ); - } - - /** - * @param positive-int $id - * - * @return ?TStoredAlert - */ - public function getById(int $id): ?array - { - $this->login(); - $result = $this->manageRequest( - 'GET', - \sprintf('%s/%d', Constants::ALERTS_ENDPOINT, $id) - ); - // workaround for muted 404 status. - if (!isset($result['id'])) { - return null; - } - - /** @var TStoredAlert */ - return $result; - } - - private function login(): void - { - $token = $this->tokenStorage->retrieveToken(); - if (null === $token) { - throw new ClientException('Login fail'); - } - $this->headers['Authorization'] = "Bearer $token"; - } -} diff --git a/src/Configuration.php b/src/Configuration.php index 1583627..3fa0190 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -91,7 +91,8 @@ public function getConfigTreeBuilder(): TreeBuilder ; $this->addConnectionNodes($rootNode); $this->addAppSecNodes($rootNode); - $this->validate($rootNode); + $this->validateTls($rootNode); + $this->validateApiKey($rootNode); return $treeBuilder; } @@ -149,26 +150,16 @@ private function addConnectionNodes($rootNode): void } /** - * Conditional validation. + * Validate TLS authentication settings. * * @param NodeDefinition|ArrayNodeDefinition $rootNode * * @throws \InvalidArgumentException * @throws \RuntimeException */ - private function validate($rootNode): void + protected function validateTls($rootNode): void { $rootNode - ->validate() - ->ifTrue(function (array $v) { - if (Constants::AUTH_KEY === $v['auth_type'] && empty($v['api_key'])) { - return true; - } - - return false; - }) - ->thenInvalid('Api key is required as auth type is api_key') - ->end() ->validate() ->ifTrue(function (array $v) { if (Constants::AUTH_TLS === $v['auth_type']) { @@ -190,4 +181,30 @@ private function validate($rootNode): void ->thenInvalid('CA path is required for tls authentification with verify_peer.') ->end(); } + + /** + * Validate API key authentication settings (for Bouncer). + * + * This can be overridden by subclasses (e.g., Watcher) that have different + * API key auth requirements. + * + * @param NodeDefinition|ArrayNodeDefinition $rootNode + * + * @throws \InvalidArgumentException + * @throws \RuntimeException + */ + protected function validateApiKey($rootNode): void + { + $rootNode + ->validate() + ->ifTrue(function (array $v) { + if (Constants::AUTH_KEY === $v['auth_type'] && empty($v['api_key'])) { + return true; + } + + return false; + }) + ->thenInvalid('Api key is required as auth type is api_key') + ->end(); + } } diff --git a/src/Configuration/Watcher.php b/src/Configuration/Watcher.php index 917fbeb..20e714b 100644 --- a/src/Configuration/Watcher.php +++ b/src/Configuration/Watcher.php @@ -7,6 +7,7 @@ use CrowdSec\LapiClient\Configuration; use CrowdSec\LapiClient\Constants; use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; +use Symfony\Component\Config\Definition\Builder\NodeDefinition; use Symfony\Component\Config\Definition\Builder\TreeBuilder; /** @@ -73,7 +74,6 @@ public function getConfigTreeBuilder(): TreeBuilder $rootNode = $treeBuilder->getRootNode(); $this->addWatcherNodes($rootNode); - $this->validateWatcher($rootNode); return $treeBuilder; } @@ -94,14 +94,16 @@ private function addWatcherNodes(ArrayNodeDefinition $rootNode): void } /** - * Watcher-specific validation. + * Override API key validation for Watcher. * - * @param ArrayNodeDefinition $rootNode + * For Watcher, api_key auth requires machine_id and password instead of api_key. + * + * @param NodeDefinition|ArrayNodeDefinition $rootNode * * @throws \InvalidArgumentException * @throws \RuntimeException */ - private function validateWatcher(ArrayNodeDefinition $rootNode): void + protected function validateApiKey($rootNode): void { $rootNode ->validate() diff --git a/src/Storage/TokenStorage.php b/src/Storage/TokenStorage.php deleted file mode 100644 index 63378cb..0000000 --- a/src/Storage/TokenStorage.php +++ /dev/null @@ -1,51 +0,0 @@ -watcher = $watcher; - $this->cache = $cache; - $this->scenarios = $scenarios; - } - - public function retrieveToken(): ?string - { - $ci = $this->cache->getItem('crowdsec_token'); - if (!$ci->isHit()) { - $tokenInfo = $this->watcher->login($this->scenarios); - if (200 !== $tokenInfo['code']) { - return null; - } - \assert(isset($tokenInfo['token'])); - $ci - ->set($tokenInfo['token']) - ->expiresAt(new DateTime($tokenInfo['expire'])); - $this->cache->save($ci); - } - - return $ci->get(); - } -} diff --git a/src/Storage/TokenStorageInterface.php b/src/Storage/TokenStorageInterface.php deleted file mode 100644 index e16dab7..0000000 --- a/src/Storage/TokenStorageInterface.php +++ /dev/null @@ -1,14 +0,0 @@ -, + * events: list, + * events_count: int, + * id: int, + * labels: null|array, + * leakspeed: string, + * machine_id: string, + * message: string, + * meta: list, + * scenario: string, + * scenario_hash: string, + * scenario_version: string, + * simulated: bool, + * source: TSource, + * start_at: string, + * stop_at: string, + * uuid: string + * } */ class WatcherClient extends AbstractLapiClient { + private const CACHE_KEY = 'crowdsec_watcher_token'; + /** * @var TWatcherConfig */ protected $configs; + + /** + * @var CacheItemPoolInterface + */ + private $cache; + + /** + * @var string[] + */ + private $scenarios; + + public function __construct( + array $configs, + CacheItemPoolInterface $cache, + array $scenarios = [], + ?RequestHandlerInterface $requestHandler = null, + ?LoggerInterface $logger = null + ) { + $this->cache = $cache; + $this->scenarios = $scenarios; + parent::__construct($configs, $requestHandler, $logger); + } + /** * @inheritDoc */ @@ -31,6 +117,10 @@ protected function getConfiguration(): Configuration } /** + * Authenticate with LAPI and retrieve a JWT token. + * + * @param string[] $scenarios Optional list of scenarios to register + * * @return TLoginResponse * * @throws ClientException @@ -38,7 +128,7 @@ protected function getConfiguration(): Configuration public function login(array $scenarios = []): array { $data = [ - 'scenarios' => $scenarios, + 'scenarios' => $scenarios ?: $this->scenarios, ]; if (isset($this->configs['auth_type']) && Constants::AUTH_KEY === $this->configs['auth_type']) { /** @var array{machine_id?: string, password?: string} $configs */ @@ -53,4 +143,139 @@ public function login(array $scenarios = []): array $data ); } + + /** + * Push alerts to LAPI. + * + * @param list $alerts + * + * @return list Alert IDs + * + * @throws ClientException + */ + public function pushAlerts(array $alerts): array + { + $this->ensureAuthenticated(); + + return $this->manageRequest( + 'POST', + Constants::ALERTS_ENDPOINT, + $alerts + ); + } + + /** + * Search for alerts. + * + * @param TSearchQuery $query Search parameters: + * - scope: Show alerts for this scope + * - value: Show alerts for this value (used with scope) + * - scenario: Show alerts for this scenario + * - ip: IP to search for (shorthand for scope=ip&value=) + * - range: Range to search for (shorthand for scope=range&value=) + * - since: Search alerts newer than delay (format must be compatible with time.ParseDuration) + * - until: Search alerts older than delay (format must be compatible with time.ParseDuration) + * - simulated: If set to true, decisions in simulation mode will be returned as well + * - has_active_decision: Only return alerts with decisions not expired yet + * - decision_type: Restrict results to alerts with decisions matching given type + * - limit: Number of alerts to return + * - origin: Restrict results to this origin (ie. lists,CAPI,cscli) + * + * @return list + * + * @throws ClientException + */ + public function searchAlerts(array $query = []): array + { + $this->ensureAuthenticated(); + + return $this->manageRequest( + 'GET', + Constants::ALERTS_ENDPOINT, + $query + ); + } + + /** + * Delete alerts by condition. + * + * Can be used only on the same machine as the local API. + * + * @param TDeleteQuery $query Delete parameters + * + * @throws ClientException + */ + public function deleteAlerts(array $query = []): array + { + $this->ensureAuthenticated(); + + return $this->manageRequest( + 'DELETE', + Constants::ALERTS_ENDPOINT, + $query + ); + } + + /** + * Get a specific alert by ID. + * + * @param positive-int $id Alert ID + * + * @return ?TStoredAlert Returns null if alert not found + * + * @throws ClientException + */ + public function getAlertById(int $id): ?array + { + $this->ensureAuthenticated(); + + $result = $this->manageRequest( + 'GET', + \sprintf('%s/%d', Constants::ALERTS_ENDPOINT, $id) + ); + + // Workaround for muted 404 status + if (!isset($result['id'])) { + return null; + } + + /** @var TStoredAlert */ + return $result; + } + + /** + * Ensure the client is authenticated by retrieving/refreshing the token. + * + * @throws ClientException + */ + private function ensureAuthenticated(): void + { + $token = $this->retrieveToken(); + if (null === $token) { + throw new ClientException('Authentication failed'); + } + $this->headers['Authorization'] = "Bearer $token"; + } + + /** + * Retrieve the authentication token from cache or login to get a new one. + */ + private function retrieveToken(): ?string + { + $cacheItem = $this->cache->getItem(self::CACHE_KEY); + + if (!$cacheItem->isHit()) { + $tokenInfo = $this->login(); + if (200 !== $tokenInfo['code']) { + return null; + } + \assert(isset($tokenInfo['token'])); + $cacheItem + ->set($tokenInfo['token']) + ->expiresAt(new DateTime($tokenInfo['expire'])); + $this->cache->save($cacheItem); + } + + return $cacheItem->get(); + } } diff --git a/tests/Integration/AlertsClientTest.php b/tests/Integration/AlertsClientTest.php index 0171e09..8c1bcf7 100644 --- a/tests/Integration/AlertsClientTest.php +++ b/tests/Integration/AlertsClientTest.php @@ -4,10 +4,8 @@ namespace CrowdSec\LapiClient\Tests\Integration; -use CrowdSec\LapiClient\AlertsClient; use CrowdSec\LapiClient\Constants; use CrowdSec\LapiClient\Payload\Alert; -use CrowdSec\LapiClient\Storage\TokenStorage; use CrowdSec\LapiClient\Tests\Constants as TestConstants; use CrowdSec\LapiClient\WatcherClient; use PHPUnit\Framework\TestCase; @@ -16,7 +14,7 @@ /** * @note You must delete all alerts manually before running this TestCase. Command: `cscli alerts delete --all`. * - * @coversDefaultClass \CrowdSec\LapiClient\AlertsClient + * @coversDefaultClass \CrowdSec\LapiClient\WatcherClient */ final class AlertsClientTest extends TestCase { @@ -32,9 +30,9 @@ final class AlertsClientTest extends TestCase protected $useTls; /** - * @var AlertsClient + * @var WatcherClient */ - protected $alertsClient; + protected $watcherClient; protected function setUp(): void { @@ -51,13 +49,12 @@ protected function setUp(): void $this->configs = $watcherConfigs; - $watcher = new WatcherClient($this->configs); - $tokenStorage = new TokenStorage($watcher, new ArrayAdapter()); - $this->alertsClient = new AlertsClient($this->configs, $tokenStorage); + $cache = new ArrayAdapter(); + $this->watcherClient = new WatcherClient($this->configs, $cache); } /** - * @covers ::push + * @covers ::pushAlerts */ public function testPush(): array { @@ -238,13 +235,13 @@ public function testPush(): array ], ] ); - $result = $this->alertsClient->push([ + $result = $this->watcherClient->pushAlerts([ // with decisions - $alert01, - $alert02, + $alert01->toArray(), + $alert02->toArray(), // without decisions - $alert11, - $alert12, + $alert11->toArray(), + $alert12->toArray(), ]); self::assertIsArray($result); self::assertCount(4, $result); @@ -253,7 +250,7 @@ public function testPush(): array } /** - * @covers ::search + * @covers ::searchAlerts * * @depends testPush * @@ -261,7 +258,7 @@ public function testPush(): array */ public function testSearch(array $query, int $expectedCount): void { - $result = $this->alertsClient->search($query); + $result = $this->watcherClient->searchAlerts($query); self::assertCount($expectedCount, $result); } @@ -378,14 +375,14 @@ public function testGetById(array $idList): void { foreach ($idList as $id) { self::assertIsNumeric($id); - $result = $this->alertsClient->getById(\intval($id)); + $result = $this->watcherClient->getAlertById(\intval($id)); self::assertIsArray($result); } } public function testAlertInfoNotFound(): void { - $result = $this->alertsClient->getById(\PHP_INT_MAX); + $result = $this->watcherClient->getAlertById(\PHP_INT_MAX); self::assertNull($result); } } diff --git a/tests/Integration/TestWatcherClient.php b/tests/Integration/TestWatcherClient.php index c663661..5ab9985 100644 --- a/tests/Integration/TestWatcherClient.php +++ b/tests/Integration/TestWatcherClient.php @@ -8,6 +8,7 @@ use CrowdSec\LapiClient\ClientException; use CrowdSec\LapiClient\Constants; use CrowdSec\LapiClient\WatcherClient; +use Symfony\Component\Cache\Adapter\ArrayAdapter; class TestWatcherClient extends AbstractClient { @@ -36,7 +37,8 @@ public function __construct(array $configs) $this->configs['tls_key_path'] = $agentTlsPath . '/agent-key.pem'; $this->configs['tls_verify_peer'] = false; - $this->watcher = new WatcherClient($this->configs); + $cache = new ArrayAdapter(); + $this->watcher = new WatcherClient($this->configs, $cache); parent::__construct($this->configs); } diff --git a/tests/Integration/WatcherClientTest.php b/tests/Integration/WatcherClientTest.php index 4ce4a39..e9c6271 100644 --- a/tests/Integration/WatcherClientTest.php +++ b/tests/Integration/WatcherClientTest.php @@ -9,6 +9,7 @@ use CrowdSec\LapiClient\Tests\PHPUnitUtil; use CrowdSec\LapiClient\WatcherClient; use PHPUnit\Framework\TestCase; +use Symfony\Component\Cache\Adapter\ArrayAdapter; /** * @coversDefaultClass \CrowdSec\LapiClient\WatcherClient @@ -32,7 +33,8 @@ public function testLoginTls(): void 'tls_verify_peer' => false, ]; - $watcher = new WatcherClient($watcherConfigs); + $cache = new ArrayAdapter(); + $watcher = new WatcherClient($watcherConfigs, $cache); $this->assertLoginResult($watcher->login()); } @@ -51,7 +53,8 @@ public function testLoginApiKey(): void 'password' => $password, ]; - $watcher = new WatcherClient($watcherConfigs); + $cache = new ArrayAdapter(); + $watcher = new WatcherClient($watcherConfigs, $cache); $this->assertLoginResult($watcher->login()); } diff --git a/tests/Unit/AbstractClientTest.php b/tests/Unit/AbstractClientTest.php index 9f9a3fb..de92dd4 100644 --- a/tests/Unit/AbstractClientTest.php +++ b/tests/Unit/AbstractClientTest.php @@ -26,7 +26,8 @@ * @uses \CrowdSec\LapiClient\Configuration::getConfigTreeBuilder * @uses \CrowdSec\LapiClient\Bouncer::formatUserAgent * @uses \CrowdSec\LapiClient\Configuration::addConnectionNodes - * @uses \CrowdSec\LapiClient\Configuration::validate + * @uses \CrowdSec\LapiClient\Configuration::validateTls + * @uses \CrowdSec\LapiClient\Configuration::validateApiKey * @uses \CrowdSec\LapiClient\Configuration::addAppSecNodes * * @covers \CrowdSec\LapiClient\Bouncer::__construct diff --git a/tests/Unit/AlertsClientTest.php b/tests/Unit/AlertsClientTest.php deleted file mode 100644 index b180354..0000000 --- a/tests/Unit/AlertsClientTest.php +++ /dev/null @@ -1,235 +0,0 @@ -createMock(TokenStorageInterface::class); - $tokenStorage->method('retrieveToken')->willReturn($token); - - return $tokenStorage; - } - - private function createFailingTokenStorage(): TokenStorageInterface - { - $tokenStorage = $this->createMock(TokenStorageInterface::class); - $tokenStorage->method('retrieveToken')->willReturn(null); - - return $tokenStorage; - } - - public function testAlertsClientInit() - { - $tokenStorage = $this->createTokenStorage(); - $client = new AlertsClient($this->configs, $tokenStorage); - - $this->assertInstanceOf( - AlertsClient::class, - $client, - 'AlertsClient should be instantiated' - ); - } - - public function testPush() - { - $mockCurl = $this->getCurlMock(['handle']); - $tokenStorage = $this->createTokenStorage(); - - $mockClient = $this->getMockBuilder(AlertsClient::class) - ->enableOriginalConstructor() - ->setConstructorArgs([ - 'configs' => $this->configs, - 'tokenStorage' => $tokenStorage, - 'requestHandler' => $mockCurl, - ]) - ->onlyMethods(['sendRequest']) - ->getMock(); - - $mockCurl->expects($this->exactly(1))->method('handle')->will( - $this->returnValue( - new Response(MockedData::ALERTS_PUSH_SUCCESS, MockedData::HTTP_200, []) - ) - ); - - $alerts = [ - [ - 'scenario' => 'test/scenario', - 'scenario_hash' => 'abc123', - 'scenario_version' => '1.0', - 'message' => 'Test alert', - 'events_count' => 1, - 'start_at' => '2025-01-01T00:00:00Z', - 'stop_at' => '2025-01-01T00:00:01Z', - 'capacity' => 10, - 'leakspeed' => '10/1s', - 'simulated' => false, - 'remediation' => true, - 'events' => [], - ], - ]; - - $response = $mockClient->push($alerts); - - $this->assertEquals( - ['1'], - $response, - 'Should return alert IDs' - ); - } - - public function testSearch() - { - $mockCurl = $this->getCurlMock(['handle']); - $tokenStorage = $this->createTokenStorage(); - - $mockClient = $this->getMockBuilder(AlertsClient::class) - ->enableOriginalConstructor() - ->setConstructorArgs([ - 'configs' => $this->configs, - 'tokenStorage' => $tokenStorage, - 'requestHandler' => $mockCurl, - ]) - ->onlyMethods(['sendRequest']) - ->getMock(); - - $mockCurl->expects($this->exactly(1))->method('handle')->will( - $this->returnValue( - new Response(MockedData::ALERTS_SEARCH_SUCCESS, MockedData::HTTP_200, []) - ) - ); - - $response = $mockClient->search(['scope' => 'ip', 'value' => '1.2.3.4']); - - $this->assertIsArray($response); - } - - public function testDelete() - { - $mockCurl = $this->getCurlMock(['handle']); - $tokenStorage = $this->createTokenStorage(); - - $mockClient = $this->getMockBuilder(AlertsClient::class) - ->enableOriginalConstructor() - ->setConstructorArgs([ - 'configs' => $this->configs, - 'tokenStorage' => $tokenStorage, - 'requestHandler' => $mockCurl, - ]) - ->onlyMethods(['sendRequest']) - ->getMock(); - - $mockCurl->expects($this->exactly(1))->method('handle')->will( - $this->returnValue( - new Response(MockedData::ALERTS_DELETE_SUCCESS, MockedData::HTTP_200, []) - ) - ); - - $response = $mockClient->delete(['scope' => 'ip', 'value' => '1.2.3.4']); - - $this->assertIsArray($response); - } - - public function testGetById() - { - $mockCurl = $this->getCurlMock(['handle']); - $tokenStorage = $this->createTokenStorage(); - - $mockClient = $this->getMockBuilder(AlertsClient::class) - ->enableOriginalConstructor() - ->setConstructorArgs([ - 'configs' => $this->configs, - 'tokenStorage' => $tokenStorage, - 'requestHandler' => $mockCurl, - ]) - ->onlyMethods(['sendRequest']) - ->getMock(); - - $mockCurl->expects($this->exactly(1))->method('handle')->will( - $this->returnValue( - new Response(MockedData::ALERT_BY_ID_SUCCESS, MockedData::HTTP_200, []) - ) - ); - - $response = $mockClient->getById(1); - - $this->assertIsArray($response); - $this->assertEquals(1, $response['id']); - } - - public function testGetByIdNotFound() - { - $mockCurl = $this->getCurlMock(['handle']); - $tokenStorage = $this->createTokenStorage(); - - $mockClient = $this->getMockBuilder(AlertsClient::class) - ->enableOriginalConstructor() - ->setConstructorArgs([ - 'configs' => $this->configs, - 'tokenStorage' => $tokenStorage, - 'requestHandler' => $mockCurl, - ]) - ->onlyMethods(['sendRequest']) - ->getMock(); - - $mockCurl->expects($this->exactly(1))->method('handle')->will( - $this->returnValue( - new Response(MockedData::ALERT_NOT_FOUND, MockedData::HTTP_200, []) - ) - ); - - $response = $mockClient->getById(999); - - $this->assertNull($response); - } - - public function testLoginFailure() - { - $tokenStorage = $this->createFailingTokenStorage(); - - $client = new AlertsClient($this->configs, $tokenStorage); - - $error = ''; - try { - $client->search([]); - } catch (ClientException $e) { - $error = $e->getMessage(); - } - - PHPUnitUtil::assertRegExp( - $this, - '/Login fail/', - $error, - 'Should throw ClientException on login failure' - ); - } -} \ No newline at end of file diff --git a/tests/Unit/BouncerTest.php b/tests/Unit/BouncerTest.php index a83985c..e939f5c 100644 --- a/tests/Unit/BouncerTest.php +++ b/tests/Unit/BouncerTest.php @@ -35,7 +35,8 @@ * @covers \CrowdSec\LapiClient\Configuration::getConfigTreeBuilder * @covers \CrowdSec\LapiClient\Configuration::addConnectionNodes * @covers \CrowdSec\LapiClient\Configuration::addAppSecNodes - * @covers \CrowdSec\LapiClient\Configuration::validate + * @covers \CrowdSec\LapiClient\Configuration::validateTls + * @covers \CrowdSec\LapiClient\Configuration::validateApiKey * @covers \CrowdSec\LapiClient\Bouncer::buildUsageMetrics * @covers \CrowdSec\LapiClient\Bouncer::getOs * @covers \CrowdSec\LapiClient\Configuration\Metrics::getConfigTreeBuilder diff --git a/tests/Unit/CurlTest.php b/tests/Unit/CurlTest.php index c719865..36ffdae 100644 --- a/tests/Unit/CurlTest.php +++ b/tests/Unit/CurlTest.php @@ -25,7 +25,8 @@ * @uses \CrowdSec\LapiClient\Bouncer::configure * @uses \CrowdSec\LapiClient\Bouncer::formatUserAgent * @uses \CrowdSec\LapiClient\Configuration::addConnectionNodes - * @uses \CrowdSec\LapiClient\Configuration::validate + * @uses \CrowdSec\LapiClient\Configuration::validateTls + * @uses \CrowdSec\LapiClient\Configuration::validateApiKey * @uses \CrowdSec\LapiClient\Configuration::addAppSecNodes * @uses \CrowdSec\LapiClient\Bouncer::cleanHeadersForLog * @uses \CrowdSec\LapiClient\Bouncer::cleanRawBodyForLog() diff --git a/tests/Unit/FileGetContentsTest.php b/tests/Unit/FileGetContentsTest.php index eedf2b5..117a069 100644 --- a/tests/Unit/FileGetContentsTest.php +++ b/tests/Unit/FileGetContentsTest.php @@ -28,7 +28,8 @@ * @uses \CrowdSec\LapiClient\Bouncer::formatUserAgent * @uses \CrowdSec\LapiClient\Bouncer::manageRequest * @uses \CrowdSec\LapiClient\Configuration::addConnectionNodes - * @uses \CrowdSec\LapiClient\Configuration::validate + * @uses \CrowdSec\LapiClient\Configuration::validateTls + * @uses \CrowdSec\LapiClient\Configuration::validateApiKey * @uses \CrowdSec\LapiClient\Configuration::addAppSecNodes * @uses \CrowdSec\LapiClient\AbstractLapiClient::getConfiguration * diff --git a/tests/Unit/Storage/TokenStorageTest.php b/tests/Unit/Storage/TokenStorageTest.php deleted file mode 100644 index a848dd2..0000000 --- a/tests/Unit/Storage/TokenStorageTest.php +++ /dev/null @@ -1,51 +0,0 @@ -createMock(WatcherClient::class); - $expire = (new \DateTime('+1 hour'))->format('Y-m-d\TH:i:s\Z'); - $watcher - ->expects(self::once()) - ->method('login') - ->willReturn([ - 'code' => 200, - 'expire' => $expire, - 'token' => 'j.w.t', - ]); - $cache = new ArrayAdapter(); - $storage = new TokenStorage($watcher, $cache); - self::assertSame('j.w.t', $storage->retrieveToken()); - self::assertTrue($cache->hasItem('crowdsec_token')); - $ci = $cache->getItem('crowdsec_token'); - self::assertSame('j.w.t', $ci->get()); - } - - public function testLoginFailure(): void - { - $watcher = $this->createMock(WatcherClient::class); - $watcher - ->expects(self::once()) - ->method('login') - ->willReturn([ - 'code' => 401, - 'message' => 'Unauthorized', - ]); - $cache = new ArrayAdapter(); - $storage = new TokenStorage($watcher, $cache); - self::assertNull($storage->retrieveToken()); - self::assertFalse($cache->hasItem('crowdsec_token')); - } -} diff --git a/tests/Unit/WatcherClientTest.php b/tests/Unit/WatcherClientTest.php index 7f95fe3..1b05802 100644 --- a/tests/Unit/WatcherClientTest.php +++ b/tests/Unit/WatcherClientTest.php @@ -5,18 +5,27 @@ namespace CrowdSec\LapiClient\Tests\Unit; use CrowdSec\Common\Client\HttpMessage\Response; +use CrowdSec\LapiClient\ClientException; use CrowdSec\LapiClient\Configuration\Watcher; use CrowdSec\LapiClient\Constants; use CrowdSec\LapiClient\Tests\MockedData; use CrowdSec\LapiClient\Tests\PHPUnitUtil; use CrowdSec\LapiClient\WatcherClient; +use Symfony\Component\Cache\Adapter\ArrayAdapter; /** + * @covers \CrowdSec\LapiClient\WatcherClient::__construct * @covers \CrowdSec\LapiClient\WatcherClient::getConfiguration * @covers \CrowdSec\LapiClient\WatcherClient::login + * @covers \CrowdSec\LapiClient\WatcherClient::pushAlerts + * @covers \CrowdSec\LapiClient\WatcherClient::searchAlerts + * @covers \CrowdSec\LapiClient\WatcherClient::deleteAlerts + * @covers \CrowdSec\LapiClient\WatcherClient::getAlertById + * @covers \CrowdSec\LapiClient\WatcherClient::ensureAuthenticated + * @covers \CrowdSec\LapiClient\WatcherClient::retrieveToken * @covers \CrowdSec\LapiClient\Configuration\Watcher::getConfigTreeBuilder * @covers \CrowdSec\LapiClient\Configuration\Watcher::addWatcherNodes - * @covers \CrowdSec\LapiClient\Configuration\Watcher::validateWatcher + * @covers \CrowdSec\LapiClient\Configuration\Watcher::validateApiKey * * @uses \CrowdSec\LapiClient\AbstractLapiClient::__construct * @uses \CrowdSec\LapiClient\AbstractLapiClient::configure @@ -26,10 +35,16 @@ * @uses \CrowdSec\LapiClient\Configuration::getConfigTreeBuilder * @uses \CrowdSec\LapiClient\Configuration::addConnectionNodes * @uses \CrowdSec\LapiClient\Configuration::addAppSecNodes - * @uses \CrowdSec\LapiClient\Configuration::validate + * @uses \CrowdSec\LapiClient\Configuration::validateTls + * @uses \CrowdSec\LapiClient\Configuration\Watcher::validateApiKey */ final class WatcherClientTest extends AbstractClient { + /** + * @var ArrayAdapter + */ + protected $cache; + protected function setUp(): void { parent::setUp(); @@ -37,11 +52,12 @@ protected function setUp(): void 'machine_id' => 'test-machine', 'password' => 'test-password', ]); + $this->cache = new ArrayAdapter(); } public function testWatcherClientInit() { - $client = new WatcherClient($this->configs); + $client = new WatcherClient($this->configs, $this->cache); $this->assertInstanceOf( WatcherClient::class, @@ -61,7 +77,10 @@ public function testLoginParams() { $mockClient = $this->getMockBuilder(WatcherClient::class) ->enableOriginalConstructor() - ->setConstructorArgs(['configs' => $this->configs]) + ->setConstructorArgs([ + 'configs' => $this->configs, + 'cache' => $this->cache, + ]) ->onlyMethods(['request']) ->getMock(); @@ -90,7 +109,10 @@ public function testLoginWithTlsAuth() $mockClient = $this->getMockBuilder(WatcherClient::class) ->enableOriginalConstructor() - ->setConstructorArgs(['configs' => $tlsConfigs]) + ->setConstructorArgs([ + 'configs' => $tlsConfigs, + 'cache' => $this->cache, + ]) ->onlyMethods(['request']) ->getMock(); @@ -114,6 +136,8 @@ public function testLoginRequest() ->enableOriginalConstructor() ->setConstructorArgs([ 'configs' => $this->configs, + 'cache' => $this->cache, + 'scenarios' => [], 'requestHandler' => $mockCurl, ]) ->onlyMethods(['sendRequest']) @@ -142,7 +166,7 @@ public function testConfigureValidation() new WatcherClient([ 'api_key' => 'test-key', 'password' => 'test-password', - ]); + ], $this->cache); } catch (\Exception $e) { $error = $e->getMessage(); } @@ -160,7 +184,7 @@ public function testConfigureValidation() new WatcherClient([ 'api_key' => 'test-key', 'machine_id' => 'test-machine', - ]); + ], $this->cache); } catch (\Exception $e) { $error = $e->getMessage(); } @@ -172,4 +196,208 @@ public function testConfigureValidation() 'password should be required for api_key auth' ); } + + public function testPushAlerts() + { + $mockCurl = $this->getCurlMock(['handle']); + + $mockClient = $this->getMockBuilder(WatcherClient::class) + ->enableOriginalConstructor() + ->setConstructorArgs([ + 'configs' => $this->configs, + 'cache' => $this->cache, + 'scenarios' => [], + 'requestHandler' => $mockCurl, + ]) + ->onlyMethods(['sendRequest']) + ->getMock(); + + // First call: login, Second call: push alerts + $mockCurl->expects($this->exactly(2))->method('handle')->willReturnOnConsecutiveCalls( + new Response(MockedData::LOGIN_SUCCESS, MockedData::HTTP_200, []), + new Response(MockedData::ALERTS_PUSH_SUCCESS, MockedData::HTTP_200, []) + ); + + $alerts = [ + [ + 'scenario' => 'test/scenario', + 'scenario_hash' => 'abc123', + 'scenario_version' => '1.0', + 'message' => 'Test alert', + 'events_count' => 1, + 'start_at' => '2025-01-01T00:00:00Z', + 'stop_at' => '2025-01-01T00:00:01Z', + 'capacity' => 10, + 'leakspeed' => '10/1s', + 'simulated' => false, + 'remediation' => true, + 'events' => [], + ], + ]; + + $response = $mockClient->pushAlerts($alerts); + + $this->assertEquals(['1'], $response, 'Should return alert IDs'); + } + + public function testSearchAlerts() + { + $mockCurl = $this->getCurlMock(['handle']); + + $mockClient = $this->getMockBuilder(WatcherClient::class) + ->enableOriginalConstructor() + ->setConstructorArgs([ + 'configs' => $this->configs, + 'cache' => $this->cache, + 'scenarios' => [], + 'requestHandler' => $mockCurl, + ]) + ->onlyMethods(['sendRequest']) + ->getMock(); + + $mockCurl->expects($this->exactly(2))->method('handle')->willReturnOnConsecutiveCalls( + new Response(MockedData::LOGIN_SUCCESS, MockedData::HTTP_200, []), + new Response(MockedData::ALERTS_SEARCH_SUCCESS, MockedData::HTTP_200, []) + ); + + $response = $mockClient->searchAlerts(['scope' => 'ip', 'value' => '1.2.3.4']); + + $this->assertIsArray($response); + } + + public function testDeleteAlerts() + { + $mockCurl = $this->getCurlMock(['handle']); + + $mockClient = $this->getMockBuilder(WatcherClient::class) + ->enableOriginalConstructor() + ->setConstructorArgs([ + 'configs' => $this->configs, + 'cache' => $this->cache, + 'scenarios' => [], + 'requestHandler' => $mockCurl, + ]) + ->onlyMethods(['sendRequest']) + ->getMock(); + + $mockCurl->expects($this->exactly(2))->method('handle')->willReturnOnConsecutiveCalls( + new Response(MockedData::LOGIN_SUCCESS, MockedData::HTTP_200, []), + new Response(MockedData::ALERTS_DELETE_SUCCESS, MockedData::HTTP_200, []) + ); + + $response = $mockClient->deleteAlerts(['scope' => 'ip', 'value' => '1.2.3.4']); + + $this->assertIsArray($response); + } + + public function testGetAlertById() + { + $mockCurl = $this->getCurlMock(['handle']); + + $mockClient = $this->getMockBuilder(WatcherClient::class) + ->enableOriginalConstructor() + ->setConstructorArgs([ + 'configs' => $this->configs, + 'cache' => $this->cache, + 'scenarios' => [], + 'requestHandler' => $mockCurl, + ]) + ->onlyMethods(['sendRequest']) + ->getMock(); + + $mockCurl->expects($this->exactly(2))->method('handle')->willReturnOnConsecutiveCalls( + new Response(MockedData::LOGIN_SUCCESS, MockedData::HTTP_200, []), + new Response(MockedData::ALERT_BY_ID_SUCCESS, MockedData::HTTP_200, []) + ); + + $response = $mockClient->getAlertById(1); + + $this->assertIsArray($response); + $this->assertEquals(1, $response['id']); + } + + public function testGetAlertByIdNotFound() + { + $mockCurl = $this->getCurlMock(['handle']); + + $mockClient = $this->getMockBuilder(WatcherClient::class) + ->enableOriginalConstructor() + ->setConstructorArgs([ + 'configs' => $this->configs, + 'cache' => $this->cache, + 'scenarios' => [], + 'requestHandler' => $mockCurl, + ]) + ->onlyMethods(['sendRequest']) + ->getMock(); + + $mockCurl->expects($this->exactly(2))->method('handle')->willReturnOnConsecutiveCalls( + new Response(MockedData::LOGIN_SUCCESS, MockedData::HTTP_200, []), + new Response(MockedData::ALERT_NOT_FOUND, MockedData::HTTP_200, []) + ); + + $response = $mockClient->getAlertById(999); + + $this->assertNull($response); + } + + public function testAuthenticationFailure() + { + $mockCurl = $this->getCurlMock(['handle']); + + $mockClient = $this->getMockBuilder(WatcherClient::class) + ->enableOriginalConstructor() + ->setConstructorArgs([ + 'configs' => $this->configs, + 'cache' => $this->cache, + 'scenarios' => [], + 'requestHandler' => $mockCurl, + ]) + ->onlyMethods(['sendRequest']) + ->getMock(); + + // Return failed login response + $mockCurl->expects($this->exactly(1))->method('handle')->will( + $this->returnValue( + new Response('{"code":401,"message":"Unauthorized"}', MockedData::HTTP_200, []) + ) + ); + + $this->expectException(ClientException::class); + $this->expectExceptionMessage('Authentication failed'); + + $mockClient->searchAlerts([]); + } + + public function testTokenCaching() + { + $mockCurl = $this->getCurlMock(['handle']); + + // Pre-populate the cache with a valid token + $cacheItem = $this->cache->getItem('crowdsec_watcher_token'); + $cacheItem->set('cached-test-token'); + $cacheItem->expiresAt(new \DateTime('+1 hour')); + $this->cache->save($cacheItem); + + $mockClient = $this->getMockBuilder(WatcherClient::class) + ->enableOriginalConstructor() + ->setConstructorArgs([ + 'configs' => $this->configs, + 'cache' => $this->cache, + 'scenarios' => [], + 'requestHandler' => $mockCurl, + ]) + ->onlyMethods(['sendRequest']) + ->getMock(); + + // No login calls - only two alert calls using cached token + $mockCurl->expects($this->exactly(2))->method('handle')->willReturnOnConsecutiveCalls( + new Response(MockedData::ALERTS_SEARCH_SUCCESS, MockedData::HTTP_200, []), + new Response(MockedData::ALERTS_SEARCH_SUCCESS, MockedData::HTTP_200, []) + ); + + // Both calls should use cached token (no login) + $mockClient->searchAlerts([]); + $mockClient->searchAlerts([]); + } } \ No newline at end of file diff --git a/tests/scripts/watcher/login.php b/tests/scripts/watcher/login.php new file mode 100644 index 0000000..b7a9ebb --- /dev/null +++ b/tests/scripts/watcher/login.php @@ -0,0 +1,55 @@ +, and are required' . \PHP_EOL + . 'Usage: php login.php []' . \PHP_EOL + . 'Example: php login.php my-machine-id my-password https://crowdsec:8080 \'["crowdsecurity/http-probing"]\'' + . \PHP_EOL); +} + +$scenarios = json_decode($scenariosJson, true); +if (is_null($scenarios)) { + exit('Param is not a valid json' . \PHP_EOL + . 'Usage: php login.php []' + . \PHP_EOL); +} + +echo \PHP_EOL . 'Setting up cache ...' . \PHP_EOL; +$cacheDir = sys_get_temp_dir() . '/crowdsec-lapi-client-cache'; +$cache = new FilesystemAdapter('crowdsec', 0, $cacheDir); +echo 'Cache directory: ' . $cacheDir . \PHP_EOL; + +echo \PHP_EOL . 'Instantiate watcher client ...' . \PHP_EOL; +$configs = [ + 'auth_type' => 'api_key', + 'api_url' => $lapiUrl, + 'machine_id' => $machineId, + 'password' => $password, +]; +$logger = new ConsoleLog(); +$client = new WatcherClient($configs, $cache, $scenarios, null, $logger); +echo 'Watcher client instantiated' . \PHP_EOL; + +echo 'Calling ' . $client->getConfig('api_url') . ' for login ...' . \PHP_EOL; +echo 'Scenarios: ' . json_encode($scenarios) . \PHP_EOL; + +try { + $response = $client->login($scenarios); + echo \PHP_EOL . 'Login response is:' . json_encode($response) . \PHP_EOL; + echo \PHP_EOL . 'Token: ' . ($response['token'] ?? 'N/A') . \PHP_EOL; + echo 'Expires: ' . ($response['expire'] ?? 'N/A') . \PHP_EOL; +} catch (\Exception $e) { + echo \PHP_EOL . 'Login failed: ' . $e->getMessage() . \PHP_EOL; + exit(1); +} \ No newline at end of file diff --git a/tests/scripts/watcher/push-alert.php b/tests/scripts/watcher/push-alert.php new file mode 100644 index 0000000..4bb9e8e --- /dev/null +++ b/tests/scripts/watcher/push-alert.php @@ -0,0 +1,54 @@ +, , and are required' . \PHP_EOL + . 'Usage: php push-alert.php ' . \PHP_EOL + . 'Example: php push-alert.php \'{"scenario":"test/scenario","scenario_hash":"abc123","scenario_version":"1.0","message":"Test alert","events_count":1,"start_at":"2025-01-01T00:00:00Z","stop_at":"2025-01-01T00:00:01Z","capacity":10,"leakspeed":"10/1s","simulated":false,"remediation":true,"source":{"scope":"ip","value":"1.2.3.4"},"events":[]}\' my-machine-id my-password https://crowdsec:8080' + . \PHP_EOL); +} + +$alert = json_decode($alertJson, true); +if (is_null($alert)) { + exit('Param is not a valid json' . \PHP_EOL + . 'Usage: php push-alert.php ' + . \PHP_EOL); +} + +$logger = new ConsoleLog(); + +echo \PHP_EOL . 'Setting up cache ...' . \PHP_EOL; +$cacheDir = sys_get_temp_dir() . '/crowdsec-lapi-client-cache'; +$cache = new FilesystemAdapter('crowdsec', 0, $cacheDir); +echo 'Cache directory: ' . $cacheDir . \PHP_EOL; + +echo \PHP_EOL . 'Instantiate watcher client ...' . \PHP_EOL; +$configs = [ + 'auth_type' => 'api_key', + 'api_url' => $lapiUrl, + 'machine_id' => $machineId, + 'password' => $password, +]; +$client = new WatcherClient($configs, $cache, [], null, $logger); +echo 'Watcher client instantiated' . \PHP_EOL; + +echo \PHP_EOL . 'Pushing alert to ' . $client->getConfig('api_url') . ' ...' . \PHP_EOL; +echo 'Alert: ' . json_encode($alert, \JSON_UNESCAPED_SLASHES) . \PHP_EOL; + +try { + $response = $client->pushAlerts([$alert]); + echo \PHP_EOL . 'Push response (alert IDs):' . json_encode($response) . \PHP_EOL; +} catch (\Exception $e) { + echo \PHP_EOL . 'Push failed: ' . $e->getMessage() . \PHP_EOL; + exit(1); +} \ No newline at end of file diff --git a/tools/coding-standards/psalm/psalm.xml b/tools/coding-standards/psalm/psalm.xml index f1e8122..2ab17a7 100644 --- a/tools/coding-standards/psalm/psalm.xml +++ b/tools/coding-standards/psalm/psalm.xml @@ -28,16 +28,6 @@ - - - - - - - - - - From 94f58383c3629df34cf8c04aa4e1e30c13cee603 Mon Sep 17 00:00:00 2001 From: Julien Loizelet Date: Fri, 12 Dec 2025 17:44:45 +0900 Subject: [PATCH 41/43] test(*): Refactor tests --- .../workflows/unit-and-integration-test.yml | 8 +- docs/DEVELOPER.md | 22 ++-- docs/USER_GUIDE.md | 33 +---- src/WatcherClient.php | 2 +- tests/Integration/BouncerTest.php | 2 + tests/Integration/TestWatcherClient.php | 116 +++++++++--------- tests/Integration/WatcherClientTest.php | 83 ------------- .../{AlertsClientTest.php => WatcherTest.php} | 56 +++++++-- tests/Unit/WatcherClientTest.php | 84 ++----------- tests/scripts/watcher/login.php | 55 --------- tools/coding-standards/phpunit/phpunit.xml | 9 +- 11 files changed, 140 insertions(+), 330 deletions(-) delete mode 100644 tests/Integration/WatcherClientTest.php rename tests/Integration/{AlertsClientTest.php => WatcherTest.php} (87%) delete mode 100644 tests/scripts/watcher/login.php diff --git a/.github/workflows/unit-and-integration-test.yml b/.github/workflows/unit-and-integration-test.yml index 7784123..85c9ea4 100644 --- a/.github/workflows/unit-and-integration-test.yml +++ b/.github/workflows/unit-and-integration-test.yml @@ -112,8 +112,8 @@ jobs: github.event_name == 'push' || github.event_name == 'pull_request' run: | - ddev exec -s crowdsec cscli alerts delete --all - ddev exec BOUNCER_KEY=${{ env.BOUNCER_KEY }} AGENT_TLS_PATH=/var/www/html/cfssl APPSEC_URL=http://crowdsec:7422 LAPI_URL=https://crowdsec:8080 /usr/bin/php ./${{env.EXTENSION_PATH}}/vendor/bin/phpunit --testdox --colors --exclude-group timeout,appsec ./${{env.EXTENSION_PATH}}/tests/Integration + ddev exec -s crowdsec cscli alerts delete --all + ddev exec BOUNCER_KEY=${{ env.BOUNCER_KEY }} AGENT_TLS_PATH=/var/www/html/cfssl APPSEC_URL=http://crowdsec:7422 LAPI_URL=https://crowdsec:8080 /usr/bin/php ./${{env.EXTENSION_PATH}}/vendor/bin/phpunit --configuration ./${{env.EXTENSION_PATH}}/tools/coding-standards/phpunit/phpunit.xml --testsuite Integration-Watcher,Integration-Bouncer --testdox --colors --exclude-group timeout,appsec - name: Run Integration tests (with TLS) if: | @@ -121,8 +121,8 @@ jobs: github.event_name == 'push' || github.event_name == 'pull_request' run: | - ddev exec -s crowdsec cscli alerts delete --all - ddev exec BOUNCER_KEY=${{ env.BOUNCER_KEY }} AGENT_TLS_PATH=/var/www/html/cfssl BOUNCER_TLS_PATH=/var/www/html/cfssl APPSEC_URL=http://crowdsec:7422 LAPI_URL=https://crowdsec:8080 /usr/bin/php ./${{env.EXTENSION_PATH}}/vendor/bin/phpunit --testdox --colors --exclude-group timeout,appsec ./${{env.EXTENSION_PATH}}/tests/Integration + ddev exec -s crowdsec cscli alerts delete --all + ddev exec BOUNCER_KEY=${{ env.BOUNCER_KEY }} AGENT_TLS_PATH=/var/www/html/cfssl BOUNCER_TLS_PATH=/var/www/html/cfssl APPSEC_URL=http://crowdsec:7422 LAPI_URL=https://crowdsec:8080 /usr/bin/php ./${{env.EXTENSION_PATH}}/vendor/bin/phpunit --configuration ./${{env.EXTENSION_PATH}}/tools/coding-standards/phpunit/phpunit.xml --testsuite Integration-Watcher,Integration-Bouncer --testdox --colors --exclude-group timeout,appsec - name: Run AppSec tests diff --git a/docs/DEVELOPER.md b/docs/DEVELOPER.md index a59f53e..20259b1 100644 --- a/docs/DEVELOPER.md +++ b/docs/DEVELOPER.md @@ -9,17 +9,17 @@ **Table of Contents** - [Local development](#local-development) - - [DDEV setup](#ddev-setup) - - [DDEV installation](#ddev-installation) - - [Prepare DDEV PHP environment](#prepare-ddev-php-environment) - - [DDEV Usage](#ddev-usage) - - [Use composer to update or install the lib](#use-composer-to-update-or-install-the-lib) - - [Unit test](#unit-test) - - [Integration test](#integration-test) - - [Coding standards](#coding-standards) - - [Testing timeout in the CrowdSec container](#testing-timeout-in-the-crowdsec-container) + - [DDEV setup](#ddev-setup) + - [DDEV installation](#ddev-installation) + - [Prepare DDEV PHP environment](#prepare-ddev-php-environment) + - [DDEV Usage](#ddev-usage) + - [Use composer to update or install the lib](#use-composer-to-update-or-install-the-lib) + - [Unit test](#unit-test) + - [Integration test](#integration-test) + - [Coding standards](#coding-standards) + - [Testing timeout in the CrowdSec container](#testing-timeout-in-the-crowdsec-container) - [Commit message](#commit-message) - - [Allowed message `type` values](#allowed-message-type-values) + - [Allowed message `type` values](#allowed-message-type-values) - [Update documentation table of contents](#update-documentation-table-of-contents) - [Release process](#release-process) @@ -134,7 +134,7 @@ Finally, run In order to launch integration tests, we have to set some environment variables: ```bash -ddev exec BOUNCER_KEY= AGENT_TLS_PATH=/var/www/html/cfssl APPSEC_URL=http://crowdsec:7422 LAPI_URL=https://crowdsec:8080 php ./my-code/lapi-client/vendor/bin/phpunit ./my-code/lapi-client/tests/Integration --testdox --exclude-group timeout +ddev exec BOUNCER_KEY= AGENT_TLS_PATH=/var/www/html/cfssl APPSEC_URL=http://crowdsec:7422 LAPI_URL=https://crowdsec:8080 php ./my-code/lapi-client/vendor/bin/phpunit ./my-code/lapi-client/tests/Integration --configuration ./my-code/lapi-client/tools/coding-standards/phpunit/phpunit.xml --testdox --exclude-group timeout ``` `` should have been created and retrieved before this test by running `ddev create-bouncer`. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 74ff0df..9bdf28b 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -52,12 +52,9 @@ - [Push usage metrics](#push-usage-metrics) - [Command usage](#command-usage-3) - [Example](#example-2) - - [Watcher login](#watcher-login) + - [Push alert](#push-alert) - [Command usage](#command-usage-4) - [Example](#example-3) - - [Push alert](#push-alert) - - [Command usage](#command-usage-5) - - [Example](#example-4) @@ -74,7 +71,6 @@ This client allows you to interact with the CrowdSec Local API (LAPI). - Retrieve AppSec decision - Push usage metrics - CrowdSec LAPI Watcher client - - Login to LAPI - Push alerts - Search alerts - Delete alerts @@ -219,18 +215,7 @@ In addition to the [common configurations](#bouncer-client-configurations), the #### LAPI calls -##### Login - -To manually authenticate and retrieve a JWT token: - -```php -$response = $watcherClient->login($scenarios); -// $response contains: ['code' => 200, 'expire' => '...', 'token' => '...'] -``` - -The `$scenarios` parameter is an optional array of scenario names that the watcher is interested in. - -Note: You don't need to call `login()` manually before using alert methods - authentication is handled automatically. +Once your watcher client is instantiated, you can perform the following calls. Authentication is handled automatically - the client will login and cache the JWT token as needed. ##### Push alerts @@ -682,20 +667,6 @@ php tests/scripts/bouncer/build-and-push-metrics.php [] -``` - -#### Example - -```bash -php tests/scripts/watcher/login.php my-machine-id my-password https://crowdsec:8080 '["crowdsecurity/http-probing"]' -``` - ### Push alert #### Command usage diff --git a/src/WatcherClient.php b/src/WatcherClient.php index f3d5c53..33b8403 100644 --- a/src/WatcherClient.php +++ b/src/WatcherClient.php @@ -125,7 +125,7 @@ protected function getConfiguration(): Configuration * * @throws ClientException */ - public function login(array $scenarios = []): array + private function login(array $scenarios = []): array { $data = [ 'scenarios' => $scenarios ?: $this->scenarios, diff --git a/tests/Integration/BouncerTest.php b/tests/Integration/BouncerTest.php index a2ce743..bb72ca3 100644 --- a/tests/Integration/BouncerTest.php +++ b/tests/Integration/BouncerTest.php @@ -11,6 +11,8 @@ * * @copyright Copyright (c) 2022+ CrowdSec * @license MIT License + * + * @coversNothing */ use CrowdSec\Common\Client\AbstractClient; diff --git a/tests/Integration/TestWatcherClient.php b/tests/Integration/TestWatcherClient.php index 5ab9985..e27c7d9 100644 --- a/tests/Integration/TestWatcherClient.php +++ b/tests/Integration/TestWatcherClient.php @@ -5,68 +5,52 @@ namespace CrowdSec\LapiClient\Tests\Integration; use CrowdSec\Common\Client\AbstractClient; -use CrowdSec\LapiClient\ClientException; use CrowdSec\LapiClient\Constants; use CrowdSec\LapiClient\WatcherClient; use Symfony\Component\Cache\Adapter\ArrayAdapter; +/** + * Test helper for setting up watcher state in integration tests. + * + * Uses WatcherClient to push alerts with decisions for testing bouncer functionality. + * Extends AbstractClient to make raw HTTP requests for deleting decisions. + */ class TestWatcherClient extends AbstractClient { public const HOURS24 = '+24 hours'; + /** @var WatcherClient */ + private $watcher; + /** @var string */ private $token; - /** - * @var array|string[] - */ - protected $headers = []; - /** @var WatcherClient */ - protected $watcher; + /** @var array */ + protected $headers = []; public function __construct(array $configs) { - $this->configs = $configs; - $this->headers = ['User-Agent' => 'LAPI_WATCHER_TEST/' . Constants::VERSION]; $agentTlsPath = getenv('AGENT_TLS_PATH'); if (!$agentTlsPath) { throw new \Exception('Using TLS auth for agent is required. Please set AGENT_TLS_PATH env.'); } - $this->configs['auth_type'] = Constants::AUTH_TLS; - $this->configs['tls_cert_path'] = $agentTlsPath . '/agent.pem'; - $this->configs['tls_key_path'] = $agentTlsPath . '/agent-key.pem'; - $this->configs['tls_verify_peer'] = false; + $configs['auth_type'] = Constants::AUTH_TLS; + $configs['tls_cert_path'] = $agentTlsPath . '/agent.pem'; + $configs['tls_key_path'] = $agentTlsPath . '/agent-key.pem'; + $configs['tls_verify_peer'] = false; $cache = new ArrayAdapter(); - $this->watcher = new WatcherClient($this->configs, $cache); + $this->watcher = new WatcherClient($configs, $cache); - parent::__construct($this->configs); - } + $this->headers = ['User-Agent' => 'LAPI_WATCHER_TEST/' . Constants::VERSION]; - /** - * Make a request. - * - * @throws ClientException - */ - private function manageRequest( - string $method, - string $endpoint, - array $parameters = [] - ): array { - $this->logger->debug('', [ - 'type' => 'WATCHER_CLIENT_REQUEST', - 'method' => $method, - 'endpoint' => $endpoint, - 'parameters' => $parameters, - ]); - - return $this->request($method, $endpoint, $parameters, $this->headers); + parent::__construct($configs); } /** Set the initial watcher state */ public function setInitialState(): void { - $this->deleteAllDecisions(); + $this->deleteAllAlerts(); $now = new \DateTime(); $this->addDecision($now, '12h', '+12 hours', TestHelpers::BAD_IP, 'captcha'); $this->addDecision($now, '24h', self::HOURS24, TestHelpers::BAD_IP . '/' . TestHelpers::IP_RANGE, 'ban'); @@ -76,8 +60,7 @@ public function setInitialState(): void /** Set the second watcher state */ public function setSecondState(): void { - $this->logger->info('', ['message' => 'Set "second" state']); - $this->deleteAllDecisions(); + $this->deleteAllAlerts(); $now = new \DateTime(); $this->addDecision($now, '36h', '+36 hours', TestHelpers::NEWLY_BAD_IP, 'ban'); $this->addDecision( @@ -92,31 +75,54 @@ public function setSecondState(): void $this->addDecision($now, '24h', self::HOURS24, TestHelpers::IP_FRANCE, 'ban'); } - /** - * Ensure we retrieved a JWT to connect the API. - */ - private function ensureLogin(): void + public function deleteAllAlerts(): void { - if (!$this->token) { - $credentials = $this->watcher->login(); - $this->token = $credentials['token']; - $this->headers['Authorization'] = 'Bearer ' . $this->token; - } + $this->watcher->deleteAlerts([]); } + /** + * Delete all decisions. + * + * This uses a raw HTTP request since WatcherClient doesn't have a method for + * deleting decisions (decisions are managed through the bouncer endpoint). + */ public function deleteAllDecisions(): void { - // Delete all existing decisions. $this->ensureLogin(); - $this->manageRequest( + $this->request( 'DELETE', Constants::DECISIONS_FILTER_ENDPOINT, - [] + [], + $this->headers ); } - protected function getFinalScope($scope, $value) + /** + * Ensure we have a valid token by triggering a watcher operation. + */ + private function ensureLogin(): void + { + if (!$this->token) { + // Trigger authentication by searching for alerts (this will login internally) + $this->watcher->searchAlerts(['limit' => 1]); + + // Now we need to get the token - we'll do a login call and get it from there + // Actually, we can't get the token from WatcherClient since login is private. + // We need to do our own login call. + $loginResponse = $this->request( + 'POST', + Constants::WATCHER_LOGIN_ENDPOINT, + ['scenarios' => []], + $this->headers + ); + + $this->token = $loginResponse['token'] ?? ''; + $this->headers['Authorization'] = 'Bearer ' . $this->token; + } + } + + protected function getFinalScope(string $scope, string $value): string { $scope = (Constants::SCOPE_IP === $scope && 2 === count(explode('/', $value))) ? Constants::SCOPE_RANGE : $scope; @@ -137,11 +143,11 @@ public function addDecision( string $value, string $type, string $scope = Constants::SCOPE_IP - ) { + ): void { $stopAt = (clone $now)->modify($dateTimeDurationString)->format('Y-m-d\TH:i:s.000\Z'); $startAt = $now->format('Y-m-d\TH:i:s.000\Z'); - $body = [ + $alert = [ 'capacity' => 0, 'decisions' => [ [ @@ -171,10 +177,6 @@ public function addDecision( 'stop_at' => $stopAt, ]; - $result = $this->manageRequest( - 'POST', - Constants::ALERTS_ENDPOINT, - [$body] - ); + $this->watcher->pushAlerts([$alert]); } } diff --git a/tests/Integration/WatcherClientTest.php b/tests/Integration/WatcherClientTest.php deleted file mode 100644 index e9c6271..0000000 --- a/tests/Integration/WatcherClientTest.php +++ /dev/null @@ -1,83 +0,0 @@ - getenv('LAPI_URL'), - 'appsec_url' => getenv('APPSEC_URL'), - 'user_agent_suffix' => TestConstants::USER_AGENT_SUFFIX, - 'auth_type' => Constants::AUTH_TLS, - 'tls_cert_path' => "{$agentTlsPath}/agent.pem", - 'tls_key_path' => "{$agentTlsPath}/agent-key.pem", - 'tls_verify_peer' => false, - ]; - - $cache = new ArrayAdapter(); - $watcher = new WatcherClient($watcherConfigs, $cache); - $this->assertLoginResult($watcher->login()); - } - - public function testLoginApiKey(): void - { - $machineId = getenv('MACHINE_ID') ?: 'watcherLogin'; - $password = getenv('PASSWORD') ?: 'watcherPassword'; - - $watcherConfigs = [ - 'api_url' => getenv('LAPI_URL'), - 'appsec_url' => getenv('APPSEC_URL'), - 'user_agent_suffix' => TestConstants::USER_AGENT_SUFFIX, - 'auth_type' => Constants::AUTH_KEY, - 'api_key' => getenv('BOUNCER_KEY'), - 'machine_id' => $machineId, - 'password' => $password, - ]; - - $cache = new ArrayAdapter(); - $watcher = new WatcherClient($watcherConfigs, $cache); - $this->assertLoginResult($watcher->login()); - } - - private function assertLoginResult(array $data): void - { - self::assertArrayHasKey('code', $data); - self::assertArrayHasKey('expire', $data); - self::assertArrayHasKey('token', $data); - - self::assertSame(200, $data['code']); - PHPUnitUtil::assertRegExp($this, '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/', $data['expire']); - // JWT - $parts = explode('.', $data['token']); - self::assertCount(3, $parts); - $payloadStr = \base64_decode($parts[1]); - self::assertNotSame(false, $payloadStr); - $payload = \json_decode($payloadStr, true); - self::assertNotEmpty($payload); - self::assertArrayHasKey('exp', $payload); - self::assertTrue(\is_int($payload['exp'])); - self::assertArrayHasKey('id', $payload); - self::assertTrue(\is_string($payload['id'])); - self::assertArrayHasKey('orig_iat', $payload); - self::assertTrue(\is_int($payload['orig_iat'])); - } -} diff --git a/tests/Integration/AlertsClientTest.php b/tests/Integration/WatcherTest.php similarity index 87% rename from tests/Integration/AlertsClientTest.php rename to tests/Integration/WatcherTest.php index 8c1bcf7..e9c6f19 100644 --- a/tests/Integration/AlertsClientTest.php +++ b/tests/Integration/WatcherTest.php @@ -14,9 +14,9 @@ /** * @note You must delete all alerts manually before running this TestCase. Command: `cscli alerts delete --all`. * - * @coversDefaultClass \CrowdSec\LapiClient\WatcherClient + * @coversNothing */ -final class AlertsClientTest extends TestCase +final class WatcherTest extends TestCase { private const DT_FORMAT = 'Y-m-dTH:i:sZ'; @@ -24,10 +24,6 @@ final class AlertsClientTest extends TestCase * @var array */ protected $configs; - /** - * @var string - */ - protected $useTls; /** * @var WatcherClient @@ -37,22 +33,55 @@ final class AlertsClientTest extends TestCase protected function setUp(): void { $watcherConfigs = [ - 'auth_type' => $this->useTls ? Constants::AUTH_TLS : Constants::AUTH_KEY, + 'auth_type' => Constants::AUTH_KEY, 'api_key' => getenv('BOUNCER_KEY'), 'api_url' => getenv('LAPI_URL'), 'appsec_url' => getenv('APPSEC_URL'), 'user_agent_suffix' => TestConstants::USER_AGENT_SUFFIX, + 'machine_id' => getenv('MACHINE_ID') ?: 'watcherLogin', + 'password' => getenv('PASSWORD') ?: 'watcherPassword', ]; - $watcherConfigs['machine_id'] = getenv('MACHINE_ID') ?: 'watcherLogin'; - $watcherConfigs['password'] = getenv('PASSWORD') ?: 'watcherPassword'; - $this->configs = $watcherConfigs; $cache = new ArrayAdapter(); $this->watcherClient = new WatcherClient($this->configs, $cache); } + public function testAuthenticationWithTls(): void + { + $agentTlsPath = getenv('AGENT_TLS_PATH'); + if (!$agentTlsPath) { + throw new \Exception('Using TLS auth for agent is required. Please set AGENT_TLS_PATH env.'); + } + + $watcherConfigs = [ + 'api_url' => getenv('LAPI_URL'), + 'appsec_url' => getenv('APPSEC_URL'), + 'user_agent_suffix' => TestConstants::USER_AGENT_SUFFIX, + 'auth_type' => Constants::AUTH_TLS, + 'tls_cert_path' => "{$agentTlsPath}/agent.pem", + 'tls_key_path' => "{$agentTlsPath}/agent-key.pem", + 'tls_verify_peer' => false, + ]; + + $cache = new ArrayAdapter(); + $watcher = new WatcherClient($watcherConfigs, $cache); + + // Authentication is tested implicitly through searchAlerts + // If auth fails, this will throw an exception + $result = $watcher->searchAlerts([]); + self::assertIsArray($result); + } + + public function testAuthenticationWithApiKey(): void + { + // Authentication is tested implicitly through searchAlerts + // If auth fails, this will throw an exception + $result = $this->watcherClient->searchAlerts([]); + self::assertIsArray($result); + } + /** * @covers ::pushAlerts */ @@ -369,6 +398,8 @@ public static function searchProvider(): iterable } /** + * @covers ::getAlertById + * * @depends testPush */ public function testGetById(array $idList): void @@ -380,9 +411,12 @@ public function testGetById(array $idList): void } } + /** + * @covers ::getAlertById + */ public function testAlertInfoNotFound(): void { $result = $this->watcherClient->getAlertById(\PHP_INT_MAX); self::assertNull($result); } -} +} \ No newline at end of file diff --git a/tests/Unit/WatcherClientTest.php b/tests/Unit/WatcherClientTest.php index 1b05802..feb5cc9 100644 --- a/tests/Unit/WatcherClientTest.php +++ b/tests/Unit/WatcherClientTest.php @@ -73,88 +73,20 @@ public function testWatcherClientInit() ); } - public function testLoginParams() - { - $mockClient = $this->getMockBuilder(WatcherClient::class) - ->enableOriginalConstructor() - ->setConstructorArgs([ - 'configs' => $this->configs, - 'cache' => $this->cache, - ]) - ->onlyMethods(['request']) - ->getMock(); - - $mockClient->expects($this->exactly(1))->method('request') - ->with( - 'POST', - Constants::WATCHER_LOGIN_ENDPOINT, - [ - 'scenarios' => ['test/scenario'], - 'machine_id' => 'test-machine', - 'password' => 'test-password', - ], - $this->anything() - ); - - $mockClient->login(['test/scenario']); - } - - public function testLoginWithTlsAuth() + public function testWatcherClientInitWithTlsAuth() { $tlsConfigs = [ 'auth_type' => Constants::AUTH_TLS, - 'tls_cert_path' => '/path/to/cert', - 'tls_key_path' => '/path/to/key', + 'tls_cert_path' => '/path/to/cert.pem', + 'tls_key_path' => '/path/to/key.pem', ]; - $mockClient = $this->getMockBuilder(WatcherClient::class) - ->enableOriginalConstructor() - ->setConstructorArgs([ - 'configs' => $tlsConfigs, - 'cache' => $this->cache, - ]) - ->onlyMethods(['request']) - ->getMock(); + $client = new WatcherClient($tlsConfigs, $this->cache); - // With TLS auth, machine_id and password should NOT be included - $mockClient->expects($this->exactly(1))->method('request') - ->with( - 'POST', - Constants::WATCHER_LOGIN_ENDPOINT, - ['scenarios' => []], - $this->anything() - ); - - $mockClient->login(); - } - - public function testLoginRequest() - { - $mockCurl = $this->getCurlMock(['handle']); - - $mockClient = $this->getMockBuilder(WatcherClient::class) - ->enableOriginalConstructor() - ->setConstructorArgs([ - 'configs' => $this->configs, - 'cache' => $this->cache, - 'scenarios' => [], - 'requestHandler' => $mockCurl, - ]) - ->onlyMethods(['sendRequest']) - ->getMock(); - - $mockCurl->expects($this->exactly(1))->method('handle')->will( - $this->returnValue( - new Response(MockedData::LOGIN_SUCCESS, MockedData::HTTP_200, []) - ) - ); - - $response = $mockClient->login(['test/scenario']); - - $this->assertEquals( - json_decode(MockedData::LOGIN_SUCCESS, true), - $response, - 'Should return login response' + $this->assertInstanceOf( + WatcherClient::class, + $client, + 'WatcherClient should be instantiated with TLS auth' ); } diff --git a/tests/scripts/watcher/login.php b/tests/scripts/watcher/login.php deleted file mode 100644 index b7a9ebb..0000000 --- a/tests/scripts/watcher/login.php +++ /dev/null @@ -1,55 +0,0 @@ -, and are required' . \PHP_EOL - . 'Usage: php login.php []' . \PHP_EOL - . 'Example: php login.php my-machine-id my-password https://crowdsec:8080 \'["crowdsecurity/http-probing"]\'' - . \PHP_EOL); -} - -$scenarios = json_decode($scenariosJson, true); -if (is_null($scenarios)) { - exit('Param is not a valid json' . \PHP_EOL - . 'Usage: php login.php []' - . \PHP_EOL); -} - -echo \PHP_EOL . 'Setting up cache ...' . \PHP_EOL; -$cacheDir = sys_get_temp_dir() . '/crowdsec-lapi-client-cache'; -$cache = new FilesystemAdapter('crowdsec', 0, $cacheDir); -echo 'Cache directory: ' . $cacheDir . \PHP_EOL; - -echo \PHP_EOL . 'Instantiate watcher client ...' . \PHP_EOL; -$configs = [ - 'auth_type' => 'api_key', - 'api_url' => $lapiUrl, - 'machine_id' => $machineId, - 'password' => $password, -]; -$logger = new ConsoleLog(); -$client = new WatcherClient($configs, $cache, $scenarios, null, $logger); -echo 'Watcher client instantiated' . \PHP_EOL; - -echo 'Calling ' . $client->getConfig('api_url') . ' for login ...' . \PHP_EOL; -echo 'Scenarios: ' . json_encode($scenarios) . \PHP_EOL; - -try { - $response = $client->login($scenarios); - echo \PHP_EOL . 'Login response is:' . json_encode($response) . \PHP_EOL; - echo \PHP_EOL . 'Token: ' . ($response['token'] ?? 'N/A') . \PHP_EOL; - echo 'Expires: ' . ($response['expire'] ?? 'N/A') . \PHP_EOL; -} catch (\Exception $e) { - echo \PHP_EOL . 'Login failed: ' . $e->getMessage() . \PHP_EOL; - exit(1); -} \ No newline at end of file diff --git a/tools/coding-standards/phpunit/phpunit.xml b/tools/coding-standards/phpunit/phpunit.xml index 1eaa5c0..bb978ab 100644 --- a/tools/coding-standards/phpunit/phpunit.xml +++ b/tools/coding-standards/phpunit/phpunit.xml @@ -14,9 +14,16 @@ verbose="true" testdox="true"> - + ../../../tests/Unit + + + ../../../tests/Integration/WatcherTest.php + + + ../../../tests/Integration/BouncerTest.php + Date: Fri, 12 Dec 2025 17:55:12 +0900 Subject: [PATCH 42/43] test(integration): Remove risky tests --- tests/Integration/WatcherTest.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/Integration/WatcherTest.php b/tests/Integration/WatcherTest.php index e9c6f19..31ad218 100644 --- a/tests/Integration/WatcherTest.php +++ b/tests/Integration/WatcherTest.php @@ -370,10 +370,6 @@ public static function searchProvider(): iterable ['until' => '-1h'], 4, ]; - yield 'until 1s' => [ - ['until' => '1s'], - 4, - ]; yield 'until 1h' => [ ['until' => '1h'], 0, From ff63245fac3197cf9193442111b36f0c8dece216 Mon Sep 17 00:00:00 2001 From: Julien Loizelet Date: Fri, 12 Dec 2025 18:52:59 +0900 Subject: [PATCH 43/43] style(*): Rename WatcherClient in Watcher --- docs/DEVELOPER.md | 4 +- docs/USER_GUIDE.md | 12 ++-- src/AbstractLapiClient.php | 2 + src/Configuration/Alert/Decision.php | 2 + src/Configuration/Alert/Event.php | 2 + src/Configuration/Alert/Meta.php | 2 + src/Configuration/Alert/Source.php | 2 + src/Payload/Alert.php | 12 ++-- src/{WatcherClient.php => Watcher.php} | 8 +-- tests/Integration/BouncerTest.php | 24 +++---- ...{TestWatcherClient.php => TestWatcher.php} | 14 ++--- tests/Integration/WatcherTest.php | 20 +++--- ...{WatcherClientTest.php => WatcherTest.php} | 62 +++++++++---------- tests/scripts/watcher/push-alert.php | 8 +-- 14 files changed, 93 insertions(+), 81 deletions(-) rename src/{WatcherClient.php => Watcher.php} (97%) rename tests/Integration/{TestWatcherClient.php => TestWatcher.php} (92%) rename tests/Unit/{WatcherClientTest.php => WatcherTest.php} (85%) diff --git a/docs/DEVELOPER.md b/docs/DEVELOPER.md index 20259b1..abaa85e 100644 --- a/docs/DEVELOPER.md +++ b/docs/DEVELOPER.md @@ -218,7 +218,7 @@ ddev xdebug To generate a html report, you can run: ```bash -ddev php -dxdebug.mode=coverage ./my-code/lapi-client/tools/coding-standards/vendor/bin/phpunit --configuration ./my-code/lapi-client/tools/coding-standards/phpunit/phpunit.xml +ddev php -dxdebug.mode=coverage ./my-code/lapi-client/tools/coding-standards/vendor/bin/phpunit --configuration ./my-code/lapi-client/tools/coding-standards/phpunit/phpunit.xml ./my-code/lapi-client/tests/Unit ``` You should find the main report file `dashboard.html` in `tools/coding-standards/phpunit/code-coverage` folder. @@ -226,7 +226,7 @@ You should find the main report file `dashboard.html` in `tools/coding-standards If you want to generate a text report in the same folder: ```bash -ddev php -dxdebug.mode=coverage ./my-code/lapi-client/tools/coding-standards/vendor/bin/phpunit --configuration ./my-code/lapi-client/tools/coding-standards/phpunit/phpunit.xml --coverage-text=./my-code/lapi-client/tools/coding-standards/phpunit/code-coverage/report.txt +ddev php -dxdebug.mode=coverage ./my-code/lapi-client/tools/coding-standards/vendor/bin/phpunit --configuration ./my-code/lapi-client/tools/coding-standards/phpunit/phpunit.xml ./my-code/lapi-client/tests/Unit --coverage-text=./my-code/lapi-client/tools/coding-standards/phpunit/code-coverage/report.txt ``` #### Testing timeout in the CrowdSec container diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 9bdf28b..bce75c8 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -186,7 +186,7 @@ The Watcher client is used to authenticate a machine to LAPI and manage alerts. - A PSR-6 compatible cache implementation to store authentication tokens (e.g., `symfony/cache`) ```php -use CrowdSec\LapiClient\WatcherClient; +use CrowdSec\LapiClient\Watcher; use Symfony\Component\Cache\Adapter\FilesystemAdapter; // Create a cache adapter (requires symfony/cache or any PSR-6 implementation) @@ -202,7 +202,7 @@ $configs = [ // Optional: scenarios to register on login $scenarios = ['crowdsecurity/http-probing']; -$watcherClient = new WatcherClient($configs, $cache, $scenarios); +$watcher = new Watcher($configs, $cache, $scenarios); ``` #### Watcher configuration @@ -242,7 +242,7 @@ $alerts = [ 'events' => [], ], ]; -$alertIds = $watcherClient->pushAlerts($alerts); +$alertIds = $watcher->pushAlerts($alerts); ``` ##### Search alerts @@ -255,7 +255,7 @@ $query = [ 'value' => '1.2.3.4', 'limit' => 10, ]; -$alerts = $watcherClient->searchAlerts($query); +$alerts = $watcher->searchAlerts($query); ``` Available search parameters: `scope`, `value`, `scenario`, `ip`, `range`, `since`, `until`, `simulated`, `has_active_decision`, `decision_type`, `limit`, `origin`. @@ -269,7 +269,7 @@ $query = [ 'scope' => 'ip', 'value' => '1.2.3.4', ]; -$result = $watcherClient->deleteAlerts($query); +$result = $watcher->deleteAlerts($query); ``` ##### Get alert by ID @@ -277,7 +277,7 @@ $result = $watcherClient->deleteAlerts($query); To retrieve a specific alert: ```php -$alert = $watcherClient->getAlertById(123); +$alert = $watcher->getAlertById(123); ``` Returns `null` if the alert is not found. diff --git a/src/AbstractLapiClient.php b/src/AbstractLapiClient.php index 53f927c..61d7d07 100644 --- a/src/AbstractLapiClient.php +++ b/src/AbstractLapiClient.php @@ -1,5 +1,7 @@ $events + * @param TProps $properties + * @param ?TSource $source + * @param list $events * @param list $decisions - * @param list $meta - * @param list $labels + * @param list $meta + * @param list $labels */ public function __construct( array $properties, diff --git a/src/WatcherClient.php b/src/Watcher.php similarity index 97% rename from src/WatcherClient.php rename to src/Watcher.php index 33b8403..5eaf52f 100644 --- a/src/WatcherClient.php +++ b/src/Watcher.php @@ -5,7 +5,7 @@ namespace CrowdSec\LapiClient; use CrowdSec\Common\Client\RequestHandler\RequestHandlerInterface; -use CrowdSec\LapiClient\Configuration\Watcher; +use CrowdSec\LapiClient\Configuration\Watcher as WatcherConfig; use DateTime; use Psr\Cache\CacheItemPoolInterface; use Psr\Log\LoggerInterface; @@ -18,7 +18,7 @@ * * If you use `auth_type = api_key` you must provide configs `machine_id` and `password`. * - * @psalm-import-type TWatcherConfig from Watcher + * @psalm-import-type TWatcherConfig from WatcherConfig * @psalm-import-type TAlertFull from \CrowdSec\LapiClient\Payload\Alert * @psalm-import-type TDecision from \CrowdSec\LapiClient\Payload\Alert * @psalm-import-type TEvent from \CrowdSec\LapiClient\Payload\Alert @@ -77,7 +77,7 @@ * uuid: string * } */ -class WatcherClient extends AbstractLapiClient +class Watcher extends AbstractLapiClient { private const CACHE_KEY = 'crowdsec_watcher_token'; @@ -113,7 +113,7 @@ public function __construct( */ protected function getConfiguration(): Configuration { - return new Watcher(); + return new WatcherConfig(); } /** diff --git a/tests/Integration/BouncerTest.php b/tests/Integration/BouncerTest.php index bb72ca3..24951a2 100644 --- a/tests/Integration/BouncerTest.php +++ b/tests/Integration/BouncerTest.php @@ -35,9 +35,9 @@ final class BouncerTest extends TestCase */ protected $useTls; /** - * @var TestWatcherClient + * @var TestWatcher */ - protected $watcherClient; + protected $watcher; private function addTlsConfig(&$bouncerConfigs, $tlsPath) { @@ -63,9 +63,9 @@ protected function setUp(): void } $this->configs = $bouncerConfigs; - $this->watcherClient = new TestWatcherClient($this->configs); + $this->watcher = new TestWatcher($this->configs); // Delete all decisions - $this->watcherClient->deleteAllDecisions(); + $this->watcher->deleteAllDecisions(); usleep(200000); // 200ms } @@ -103,15 +103,15 @@ public function testDecisionsStream($requestHandler) // Add decisions $now = new \DateTime(); - $this->watcherClient->addDecision($now, '12h', '+12 hours', TestConstants::BAD_IP, 'captcha'); - $this->watcherClient->addDecision( + $this->watcher->addDecision($now, '12h', '+12 hours', TestConstants::BAD_IP, 'captcha'); + $this->watcher->addDecision( $now, '24h', '+24 hours', TestConstants::BAD_IP . '/' . TestConstants::IP_RANGE, 'ban' ); - $this->watcherClient->addDecision( + $this->watcher->addDecision( $now, '24h', '+24 hours', @@ -150,7 +150,7 @@ public function testDecisionsStream($requestHandler) 'Should be no new if startup has been done. Response: ' . json_encode($response) ); // Delete all decisions - $this->watcherClient->deleteAllDecisions(); + $this->watcher->deleteAllDecisions(); $response = $client->getStreamDecisions( false, [ @@ -243,9 +243,9 @@ public function testFilteredDecisions($requestHandler) $this->assertCount(0, $response, 'No decisions yet'); // Add decisions $now = new \DateTime(); - $this->watcherClient->addDecision($now, '12h', '+12 hours', TestConstants::BAD_IP, 'captcha'); - $this->watcherClient->addDecision($now, '24h', '+24 hours', '1.2.3.0/' . TestConstants::IP_RANGE, 'ban'); - $this->watcherClient->addDecision( + $this->watcher->addDecision($now, '12h', '+12 hours', TestConstants::BAD_IP, 'captcha'); + $this->watcher->addDecision($now, '24h', '+24 hours', '1.2.3.0/' . TestConstants::IP_RANGE, 'ban'); + $this->watcher->addDecision( $now, '24h', '+24 hours', @@ -265,7 +265,7 @@ public function testFilteredDecisions($requestHandler) $response = $client->getFilteredDecisions(['type' => 'captcha']); $this->assertCount(2, $response, '2 decision for specified type. Response: ' . json_encode($response)); // Delete all decisions - $this->watcherClient->deleteAllDecisions(); + $this->watcher->deleteAllDecisions(); $response = $client->getFilteredDecisions(['ip' => TestConstants::BAD_IP]); $this->assertCount( 0, diff --git a/tests/Integration/TestWatcherClient.php b/tests/Integration/TestWatcher.php similarity index 92% rename from tests/Integration/TestWatcherClient.php rename to tests/Integration/TestWatcher.php index e27c7d9..8bb13a3 100644 --- a/tests/Integration/TestWatcherClient.php +++ b/tests/Integration/TestWatcher.php @@ -6,20 +6,20 @@ use CrowdSec\Common\Client\AbstractClient; use CrowdSec\LapiClient\Constants; -use CrowdSec\LapiClient\WatcherClient; +use CrowdSec\LapiClient\Watcher; use Symfony\Component\Cache\Adapter\ArrayAdapter; /** * Test helper for setting up watcher state in integration tests. * - * Uses WatcherClient to push alerts with decisions for testing bouncer functionality. + * Uses Watcher to push alerts with decisions for testing bouncer functionality. * Extends AbstractClient to make raw HTTP requests for deleting decisions. */ -class TestWatcherClient extends AbstractClient +class TestWatcher extends AbstractClient { public const HOURS24 = '+24 hours'; - /** @var WatcherClient */ + /** @var Watcher */ private $watcher; /** @var string */ @@ -40,7 +40,7 @@ public function __construct(array $configs) $configs['tls_verify_peer'] = false; $cache = new ArrayAdapter(); - $this->watcher = new WatcherClient($configs, $cache); + $this->watcher = new Watcher($configs, $cache); $this->headers = ['User-Agent' => 'LAPI_WATCHER_TEST/' . Constants::VERSION]; @@ -83,7 +83,7 @@ public function deleteAllAlerts(): void /** * Delete all decisions. * - * This uses a raw HTTP request since WatcherClient doesn't have a method for + * This uses a raw HTTP request since Watcher doesn't have a method for * deleting decisions (decisions are managed through the bouncer endpoint). */ public function deleteAllDecisions(): void @@ -108,7 +108,7 @@ private function ensureLogin(): void $this->watcher->searchAlerts(['limit' => 1]); // Now we need to get the token - we'll do a login call and get it from there - // Actually, we can't get the token from WatcherClient since login is private. + // Actually, we can't get the token from Watcher since login is private. // We need to do our own login call. $loginResponse = $this->request( 'POST', diff --git a/tests/Integration/WatcherTest.php b/tests/Integration/WatcherTest.php index 31ad218..347bc75 100644 --- a/tests/Integration/WatcherTest.php +++ b/tests/Integration/WatcherTest.php @@ -7,7 +7,7 @@ use CrowdSec\LapiClient\Constants; use CrowdSec\LapiClient\Payload\Alert; use CrowdSec\LapiClient\Tests\Constants as TestConstants; -use CrowdSec\LapiClient\WatcherClient; +use CrowdSec\LapiClient\Watcher; use PHPUnit\Framework\TestCase; use Symfony\Component\Cache\Adapter\ArrayAdapter; @@ -26,9 +26,9 @@ final class WatcherTest extends TestCase protected $configs; /** - * @var WatcherClient + * @var Watcher */ - protected $watcherClient; + protected $watcher; protected function setUp(): void { @@ -45,7 +45,7 @@ protected function setUp(): void $this->configs = $watcherConfigs; $cache = new ArrayAdapter(); - $this->watcherClient = new WatcherClient($this->configs, $cache); + $this->watcher = new Watcher($this->configs, $cache); } public function testAuthenticationWithTls(): void @@ -66,7 +66,7 @@ public function testAuthenticationWithTls(): void ]; $cache = new ArrayAdapter(); - $watcher = new WatcherClient($watcherConfigs, $cache); + $watcher = new Watcher($watcherConfigs, $cache); // Authentication is tested implicitly through searchAlerts // If auth fails, this will throw an exception @@ -78,7 +78,7 @@ public function testAuthenticationWithApiKey(): void { // Authentication is tested implicitly through searchAlerts // If auth fails, this will throw an exception - $result = $this->watcherClient->searchAlerts([]); + $result = $this->watcher->searchAlerts([]); self::assertIsArray($result); } @@ -264,7 +264,7 @@ public function testPush(): array ], ] ); - $result = $this->watcherClient->pushAlerts([ + $result = $this->watcher->pushAlerts([ // with decisions $alert01->toArray(), $alert02->toArray(), @@ -287,7 +287,7 @@ public function testPush(): array */ public function testSearch(array $query, int $expectedCount): void { - $result = $this->watcherClient->searchAlerts($query); + $result = $this->watcher->searchAlerts($query); self::assertCount($expectedCount, $result); } @@ -402,7 +402,7 @@ public function testGetById(array $idList): void { foreach ($idList as $id) { self::assertIsNumeric($id); - $result = $this->watcherClient->getAlertById(\intval($id)); + $result = $this->watcher->getAlertById(\intval($id)); self::assertIsArray($result); } } @@ -412,7 +412,7 @@ public function testGetById(array $idList): void */ public function testAlertInfoNotFound(): void { - $result = $this->watcherClient->getAlertById(\PHP_INT_MAX); + $result = $this->watcher->getAlertById(\PHP_INT_MAX); self::assertNull($result); } } \ No newline at end of file diff --git a/tests/Unit/WatcherClientTest.php b/tests/Unit/WatcherTest.php similarity index 85% rename from tests/Unit/WatcherClientTest.php rename to tests/Unit/WatcherTest.php index feb5cc9..97fb4e2 100644 --- a/tests/Unit/WatcherClientTest.php +++ b/tests/Unit/WatcherTest.php @@ -6,23 +6,23 @@ use CrowdSec\Common\Client\HttpMessage\Response; use CrowdSec\LapiClient\ClientException; -use CrowdSec\LapiClient\Configuration\Watcher; +use CrowdSec\LapiClient\Configuration\Watcher as WatcherConfig; use CrowdSec\LapiClient\Constants; use CrowdSec\LapiClient\Tests\MockedData; use CrowdSec\LapiClient\Tests\PHPUnitUtil; -use CrowdSec\LapiClient\WatcherClient; +use CrowdSec\LapiClient\Watcher; use Symfony\Component\Cache\Adapter\ArrayAdapter; /** - * @covers \CrowdSec\LapiClient\WatcherClient::__construct - * @covers \CrowdSec\LapiClient\WatcherClient::getConfiguration - * @covers \CrowdSec\LapiClient\WatcherClient::login - * @covers \CrowdSec\LapiClient\WatcherClient::pushAlerts - * @covers \CrowdSec\LapiClient\WatcherClient::searchAlerts - * @covers \CrowdSec\LapiClient\WatcherClient::deleteAlerts - * @covers \CrowdSec\LapiClient\WatcherClient::getAlertById - * @covers \CrowdSec\LapiClient\WatcherClient::ensureAuthenticated - * @covers \CrowdSec\LapiClient\WatcherClient::retrieveToken + * @covers \CrowdSec\LapiClient\Watcher::__construct + * @covers \CrowdSec\LapiClient\Watcher::getConfiguration + * @covers \CrowdSec\LapiClient\Watcher::login + * @covers \CrowdSec\LapiClient\Watcher::pushAlerts + * @covers \CrowdSec\LapiClient\Watcher::searchAlerts + * @covers \CrowdSec\LapiClient\Watcher::deleteAlerts + * @covers \CrowdSec\LapiClient\Watcher::getAlertById + * @covers \CrowdSec\LapiClient\Watcher::ensureAuthenticated + * @covers \CrowdSec\LapiClient\Watcher::retrieveToken * @covers \CrowdSec\LapiClient\Configuration\Watcher::getConfigTreeBuilder * @covers \CrowdSec\LapiClient\Configuration\Watcher::addWatcherNodes * @covers \CrowdSec\LapiClient\Configuration\Watcher::validateApiKey @@ -38,7 +38,7 @@ * @uses \CrowdSec\LapiClient\Configuration::validateTls * @uses \CrowdSec\LapiClient\Configuration\Watcher::validateApiKey */ -final class WatcherClientTest extends AbstractClient +final class WatcherTest extends AbstractClient { /** * @var ArrayAdapter @@ -55,25 +55,25 @@ protected function setUp(): void $this->cache = new ArrayAdapter(); } - public function testWatcherClientInit() + public function testWatcherInit() { - $client = new WatcherClient($this->configs, $this->cache); + $client = new Watcher($this->configs, $this->cache); $this->assertInstanceOf( - WatcherClient::class, + Watcher::class, $client, - 'WatcherClient should be instantiated' + 'Watcher should be instantiated' ); $configuration = PHPUnitUtil::callMethod($client, 'getConfiguration', []); $this->assertInstanceOf( - Watcher::class, + WatcherConfig::class, $configuration, - 'WatcherClient should use Watcher configuration' + 'Watcher should use Watcher configuration' ); } - public function testWatcherClientInitWithTlsAuth() + public function testWatcherInitWithTlsAuth() { $tlsConfigs = [ 'auth_type' => Constants::AUTH_TLS, @@ -81,12 +81,12 @@ public function testWatcherClientInitWithTlsAuth() 'tls_key_path' => '/path/to/key.pem', ]; - $client = new WatcherClient($tlsConfigs, $this->cache); + $client = new Watcher($tlsConfigs, $this->cache); $this->assertInstanceOf( - WatcherClient::class, + Watcher::class, $client, - 'WatcherClient should be instantiated with TLS auth' + 'Watcher should be instantiated with TLS auth' ); } @@ -95,7 +95,7 @@ public function testConfigureValidation() // Test missing machine_id $error = ''; try { - new WatcherClient([ + new Watcher([ 'api_key' => 'test-key', 'password' => 'test-password', ], $this->cache); @@ -113,7 +113,7 @@ public function testConfigureValidation() // Test missing password $error = ''; try { - new WatcherClient([ + new Watcher([ 'api_key' => 'test-key', 'machine_id' => 'test-machine', ], $this->cache); @@ -133,7 +133,7 @@ public function testPushAlerts() { $mockCurl = $this->getCurlMock(['handle']); - $mockClient = $this->getMockBuilder(WatcherClient::class) + $mockClient = $this->getMockBuilder(Watcher::class) ->enableOriginalConstructor() ->setConstructorArgs([ 'configs' => $this->configs, @@ -176,7 +176,7 @@ public function testSearchAlerts() { $mockCurl = $this->getCurlMock(['handle']); - $mockClient = $this->getMockBuilder(WatcherClient::class) + $mockClient = $this->getMockBuilder(Watcher::class) ->enableOriginalConstructor() ->setConstructorArgs([ 'configs' => $this->configs, @@ -201,7 +201,7 @@ public function testDeleteAlerts() { $mockCurl = $this->getCurlMock(['handle']); - $mockClient = $this->getMockBuilder(WatcherClient::class) + $mockClient = $this->getMockBuilder(Watcher::class) ->enableOriginalConstructor() ->setConstructorArgs([ 'configs' => $this->configs, @@ -226,7 +226,7 @@ public function testGetAlertById() { $mockCurl = $this->getCurlMock(['handle']); - $mockClient = $this->getMockBuilder(WatcherClient::class) + $mockClient = $this->getMockBuilder(Watcher::class) ->enableOriginalConstructor() ->setConstructorArgs([ 'configs' => $this->configs, @@ -252,7 +252,7 @@ public function testGetAlertByIdNotFound() { $mockCurl = $this->getCurlMock(['handle']); - $mockClient = $this->getMockBuilder(WatcherClient::class) + $mockClient = $this->getMockBuilder(Watcher::class) ->enableOriginalConstructor() ->setConstructorArgs([ 'configs' => $this->configs, @@ -277,7 +277,7 @@ public function testAuthenticationFailure() { $mockCurl = $this->getCurlMock(['handle']); - $mockClient = $this->getMockBuilder(WatcherClient::class) + $mockClient = $this->getMockBuilder(Watcher::class) ->enableOriginalConstructor() ->setConstructorArgs([ 'configs' => $this->configs, @@ -311,7 +311,7 @@ public function testTokenCaching() $cacheItem->expiresAt(new \DateTime('+1 hour')); $this->cache->save($cacheItem); - $mockClient = $this->getMockBuilder(WatcherClient::class) + $mockClient = $this->getMockBuilder(Watcher::class) ->enableOriginalConstructor() ->setConstructorArgs([ 'configs' => $this->configs, diff --git a/tests/scripts/watcher/push-alert.php b/tests/scripts/watcher/push-alert.php index 4bb9e8e..077bb6b 100644 --- a/tests/scripts/watcher/push-alert.php +++ b/tests/scripts/watcher/push-alert.php @@ -3,7 +3,7 @@ require_once __DIR__ . '/../../../vendor/autoload.php'; use CrowdSec\Common\Logger\ConsoleLog; -use CrowdSec\LapiClient\WatcherClient; +use CrowdSec\LapiClient\Watcher; use Symfony\Component\Cache\Adapter\FilesystemAdapter; $alertJson = $argv[1] ?? false; @@ -32,15 +32,15 @@ $cache = new FilesystemAdapter('crowdsec', 0, $cacheDir); echo 'Cache directory: ' . $cacheDir . \PHP_EOL; -echo \PHP_EOL . 'Instantiate watcher client ...' . \PHP_EOL; +echo \PHP_EOL . 'Instantiate watcher ...' . \PHP_EOL; $configs = [ 'auth_type' => 'api_key', 'api_url' => $lapiUrl, 'machine_id' => $machineId, 'password' => $password, ]; -$client = new WatcherClient($configs, $cache, [], null, $logger); -echo 'Watcher client instantiated' . \PHP_EOL; +$client = new Watcher($configs, $cache, [], null, $logger); +echo 'Watcher instantiated' . \PHP_EOL; echo \PHP_EOL . 'Pushing alert to ' . $client->getConfig('api_url') . ' ...' . \PHP_EOL; echo 'Alert: ' . json_encode($alert, \JSON_UNESCAPED_SLASHES) . \PHP_EOL;