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..a84f95e --- /dev/null +++ b/src/AbstractLapiClient.php @@ -0,0 +1,128 @@ +configure($configs); + $this->headers = [Constants::HEADER_LAPI_USER_AGENT => $this->formatUserAgent($this->configs)]; + if (isset($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..72d4505 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,11 +34,13 @@ * 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 { - /** @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', @@ -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..c210036 --- /dev/null +++ b/src/Configuration/Alert.php @@ -0,0 +1,54 @@ +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..9618b6d --- /dev/null +++ b/src/Configuration/Alert/Decision.php @@ -0,0 +1,42 @@ +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..09db054 --- /dev/null +++ b/src/Configuration/Alert/Event.php @@ -0,0 +1,39 @@ +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..ea14e05 --- /dev/null +++ b/src/Configuration/Alert/Meta.php @@ -0,0 +1,31 @@ +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..70542e1 --- /dev/null +++ b/src/Configuration/Alert/Source.php @@ -0,0 +1,46 @@ +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/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..e8a67a5 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', @@ -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/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..f52622a --- /dev/null +++ b/src/Payload/Alert.php @@ -0,0 +1,232 @@ +, + * 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->configureMeta($processor, $meta); + $this->labels = $labels; + } + + /** + * @param TAlertFull $data + */ + 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; + $result['source'] = $this->source; + $result['events'] = $this->events; + if ([] !== $this->decisions) { + $result['decisions'] = $this->decisions; + } + 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 configureMeta(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(): mixed + { + return $this->toArray(); + } +} diff --git a/src/Storage/TokenStorage.php b/src/Storage/TokenStorage.php new file mode 100644 index 0000000..3f9d9d9 --- /dev/null +++ b/src/Storage/TokenStorage.php @@ -0,0 +1,50 @@ +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 new file mode 100644 index 0000000..e16dab7 --- /dev/null +++ b/src/Storage/TokenStorageInterface.php @@ -0,0 +1,14 @@ + $scenarios, + ]; + 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'] ?? ''; + } + + 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()); + } +} diff --git a/tools/coding-standards/psalm/psalm.xml b/tools/coding-standards/psalm/psalm.xml index a7780fa..c89cb61 100644 --- a/tools/coding-standards/psalm/psalm.xml +++ b/tools/coding-standards/psalm/psalm.xml @@ -18,12 +18,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +