From 12bd49408bf9048aae74962229ab6102e227535e Mon Sep 17 00:00:00 2001 From: Julien Loizelet Date: Fri, 20 Dec 2024 15:14:31 +0900 Subject: [PATCH] feat(*): Add methods to build and push usage metrics --- CHANGELOG.md | 15 ++ docs/USER_GUIDE.md | 30 +++ src/Bouncer.php | 99 ++++++++++ .../Bouncer.php} | 41 ++-- src/Configuration/Metrics.php | 65 +++++++ src/Configuration/Metrics/Items.php | 96 ++++++++++ src/Configuration/Metrics/Meta.php | 46 +++++ src/Constants.php | 8 + src/Metrics.php | 89 +++++++++ tests/Integration/BouncerTest.php | 91 +++++++-- tests/Unit/AbstractClientTest.php | 8 +- tests/Unit/BouncerTest.php | 181 +++++++++++++++++- tests/Unit/CurlTest.php | 8 +- tests/Unit/FileGetContentsTest.php | 8 +- .../bouncer/build-and-push-metrics.php | 49 +++++ 15 files changed, 782 insertions(+), 52 deletions(-) rename src/{Configuration.php => Configuration/Bouncer.php} (97%) create mode 100644 src/Configuration/Metrics.php create mode 100644 src/Configuration/Metrics/Items.php create mode 100644 src/Configuration/Metrics/Meta.php create mode 100644 src/Metrics.php create mode 100644 tests/scripts/bouncer/build-and-push-metrics.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a167ff..3f06c03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,21 @@ As far as possible, we try to adhere to [Symfony guidelines](https://symfony.com --- +## [4.0.0](https://github.com/crowdsecurity/php-lapi-client/releases/tag/v4.0.0) - 202?-??-?? +[_Compare with previous release_](https://github.com/crowdsecurity/php-lapi-client/compare/v3.3.2...HEAD) + +**This release is not yet published** + +### Added + +- Add `pushUsageMetrics` method to `Bouncer` class + +### Changed + +- *Breaking change*: Move configuration classes to `CrowdSec\LapiClient\Configuration` namespace + +--- + ## [3.3.2](https://github.com/crowdsecurity/php-lapi-client/releases/tag/v3.3.2) - 2024-10-21 [_Compare with previous release_](https://github.com/crowdsecurity/php-lapi-client/compare/v3.3.1...v3.3.2) diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index f3d52b8..62f9378 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -46,6 +46,9 @@ - [Get AppSec decision](#get-appsec-decision) - [Command usage](#command-usage-2) - [Example](#example-1) + - [Push usage metrics](#push-usage-metrics) + - [Command usage](#command-usage-3) + - [Example](#example-2) @@ -60,6 +63,7 @@ This client allows you to interact with the CrowdSec Local API (LAPI). - Retrieve decisions stream list - Retrieve decisions for some filter - Retrieve AppSec decision + - Push usage metrics - Overridable request handler (`curl` by default, `file_get_contents` also available) @@ -151,6 +155,18 @@ The `$rawBody` parameter is optional and must be used if the forwarded request c Please see the [CrowdSec AppSec documentation](https://docs.crowdsec.net/docs/appsec/intro/) for more details. +##### Push usage metrics + +To push usage metrics, you can do the following call: + +```php +$client->pushUsageMetrics($usageMetrics); +``` + +The `$usageMetrics` parameter is an array containing the usage metrics to push. Please see the [CrowdSec LAPI documentation](https://crowdsecurity.github.io/api_doc/index.html?urls.primaryName=LAPI#/bouncers/postUsageMetrics) for more details. + +We provide a `buildUsageMetrics` method to help you build the `$usageMetrics` array. + ## Bouncer client configurations @@ -521,3 +537,17 @@ php tests/scripts/bouncer/appsec-decision.php +``` + +#### Example + +```bash +php tests/scripts/bouncer/build-and-push-metrics.php '{"name":"TEST BOUNCER","type":"crowdsec-test-php-bouncer","version":"v0.0.0","items":[{"name":"dropped","value":12,"unit":"request","labels":{"origin":"CAPI"}}],"meta":{"window_size_seconds":900,"utc_now_timestamp":12}}' 92d3de1dde6d354b771d63035cf5ef83 https://crowdsec:8080 +``` diff --git a/src/Bouncer.php b/src/Bouncer.php index df79c88..4b5996a 100644 --- a/src/Bouncer.php +++ b/src/Bouncer.php @@ -8,6 +8,7 @@ use CrowdSec\Common\Client\ClientException as CommonClientException; use CrowdSec\Common\Client\RequestHandler\RequestHandlerInterface; use CrowdSec\Common\Client\TimeoutException as CommonTimeoutException; +use CrowdSec\LapiClient\Configuration\Bouncer as Configuration; use Psr\Log\LoggerInterface; use Symfony\Component\Config\Definition\Processor; @@ -45,6 +46,79 @@ public function __construct( parent::__construct($this->configs, $requestHandler, $logger); } + /** + * Helper to create well formatted metrics array. + * + * @param array $properties + * Array containing metrics properties + * $properties = [ + * 'name' => (string) Bouncer name + * 'type' => (string) Bouncer type (crowdsec-php-bouncer) + * 'last_pull' => (integer) last pull timestamp, + * '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 array $meta + * Array containing meta data + * $meta = [ + * 'window_size_seconds' => (integer) Window size in seconds + * 'utc_now_timestamp' => (integer) Current timestamp + * ]; + * @param array[] $items + * Array of items. Each item is an array too. + * $items = [ + * [ + * 'name' => (string) Name of the metric + * 'value' => (integer) Value of the metric + * 'type' => (string) Type of the metric + * 'labels' => (array) Labels of the metric + * 'labels' = [ + * 'key' => (string) Tag key + * 'value' => (string) Tag value + * ], + * ], + * ... + * ] + * + * @throws ClientException + */ + public function buildUsageMetrics(array $properties, array $meta, array $items = [[]]): array + { + $finalProperties = [ + 'name' => $properties['name'] ?? '', + 'type' => $properties['type'] ?? Constants::METRICS_TYPE, + 'version' => $properties['version'] ?? '', + 'feature_flags' => $properties['feature_flags'] ?? [], + 'utc_startup_timestamp' => $properties['utc_startup_timestamp'] ?? 0, + ]; + $lastPull = $properties['last_pull'] ?? 0; + $os = $properties['os'] ?? $this->getOs(); + if ($lastPull) { + $finalProperties['last_pull'] = $lastPull; + } + if (!empty($os['name']) && !empty($os['version'])) { + $finalProperties['os'] = $os; + } + $meta = [ + 'window_size_seconds' => $meta['window_size_seconds'] ?? 0, + 'utc_now_timestamp' => $meta['utc_now_timestamp'] ?? time(), + ]; + + try { + $metrics = new Metrics($finalProperties, $meta, $items); + } catch (\Exception $e) { + throw new ClientException('Something went wrong while creating metrics: ' . $e->getMessage()); + } + + return $metrics->toArray(); + } + /** * Process a call to AppSec component. * @@ -99,6 +173,23 @@ public function getStreamDecisions( ); } + /** + * Push usage metrics to LAPI. + * + * @see https://crowdsecurity.github.io/api_doc/index.html?urls.primaryName=LAPI#/Remediation%20component/usage-metrics + * + * @throws ClientException + * @codeCoverageIgnore + */ + public function pushUsageMetrics(array $usageMetrics): array + { + return $this->manageRequest( + 'POST', + Constants::METRICS_ENDPOINT, + $usageMetrics + ); + } + private function cleanHeadersForLog(array $headers): array { $cleanedHeaders = $headers; @@ -136,6 +227,14 @@ private function formatUserAgent(array $configs = []): string return Constants::USER_AGENT_PREFIX . $userAgentSuffix . '/' . $userAgentVersion; } + private function getOs(): array + { + return [ + 'name' => php_uname('s'), + 'version' => php_uname('v'), + ]; + } + /** * Make a request to the AppSec component of LAPI. * diff --git a/src/Configuration.php b/src/Configuration/Bouncer.php similarity index 97% rename from src/Configuration.php rename to src/Configuration/Bouncer.php index d8bc5de..48a6b06 100644 --- a/src/Configuration.php +++ b/src/Configuration/Bouncer.php @@ -2,9 +2,10 @@ declare(strict_types=1); -namespace CrowdSec\LapiClient; +namespace CrowdSec\LapiClient\Configuration; use CrowdSec\Common\Configuration\AbstractConfiguration; +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; @@ -19,7 +20,7 @@ * @copyright Copyright (c) 2022+ CrowdSec * @license MIT License */ -class Configuration extends AbstractConfiguration +class Bouncer extends AbstractConfiguration { /** @var array The list of each configuration tree key */ protected $keys = [ @@ -79,6 +80,24 @@ public function getConfigTreeBuilder(): TreeBuilder return $treeBuilder; } + /** + * AppSec settings. + * + * @param NodeDefinition|ArrayNodeDefinition $rootNode + * + * @return void + * + * @throws \InvalidArgumentException + */ + private function addAppSecNodes($rootNode) + { + $rootNode->children() + ->scalarNode('appsec_url')->cannotBeEmpty()->defaultValue(Constants::DEFAULT_APPSEC_URL)->end() + ->integerNode('appsec_timeout_ms')->defaultValue(Constants::APPSEC_TIMEOUT_MS)->end() + ->integerNode('appsec_connect_timeout_ms')->defaultValue(Constants::APPSEC_CONNECT_TIMEOUT_MS)->end() + ->end(); + } + /** * LAPI connection settings. * @@ -117,24 +136,6 @@ private function addConnectionNodes($rootNode) ->end(); } - /** - * AppSec settings. - * - * @param NodeDefinition|ArrayNodeDefinition $rootNode - * - * @return void - * - * @throws \InvalidArgumentException - */ - private function addAppSecNodes($rootNode) - { - $rootNode->children() - ->scalarNode('appsec_url')->cannotBeEmpty()->defaultValue(Constants::DEFAULT_APPSEC_URL)->end() - ->integerNode('appsec_timeout_ms')->defaultValue(Constants::APPSEC_TIMEOUT_MS)->end() - ->integerNode('appsec_connect_timeout_ms')->defaultValue(Constants::APPSEC_CONNECT_TIMEOUT_MS)->end() - ->end(); - } - /** * Conditional validation. * diff --git a/src/Configuration/Metrics.php b/src/Configuration/Metrics.php new file mode 100644 index 0000000..5c2ea36 --- /dev/null +++ b/src/Configuration/Metrics.php @@ -0,0 +1,65 @@ + The list of each configuration tree key */ + protected $keys = [ + 'name', + 'type', + 'last_pull', + 'version', + 'os', + 'feature_flags', + 'utc_startup_timestamp', + ]; + + /** + * @throws \InvalidArgumentException + * @throws \RuntimeException + */ + public function getConfigTreeBuilder(): TreeBuilder + { + $treeBuilder = new TreeBuilder('metricsConfig'); + /** @var ArrayNodeDefinition $rootNode */ + $rootNode = $treeBuilder->getRootNode(); + $rootNode->children() + ->scalarNode('name')->isRequired()->cannotBeEmpty()->end() + ->scalarNode('type')->isRequired()->defaultValue(Constants::METRICS_TYPE)->end() + ->integerNode('last_pull')->end() + ->scalarNode('version')->isRequired()->cannotBeEmpty()->end() + ->integerNode('utc_startup_timestamp')->isRequired()->min(0)->end() + ->arrayNode('os') + ->children() + ->scalarNode('name')->isRequired()->cannotBeEmpty()->end() + ->scalarNode('version')->isRequired()->cannotBeEmpty()->end() + ->end() + ->end() + ->arrayNode('feature_flags') + ->scalarPrototype()->end() + ->defaultValue([]) + ->end() + ->end() + ; + + return $treeBuilder; + } +} diff --git a/src/Configuration/Metrics/Items.php b/src/Configuration/Metrics/Items.php new file mode 100644 index 0000000..15ab05e --- /dev/null +++ b/src/Configuration/Metrics/Items.php @@ -0,0 +1,96 @@ + The list of each configuration tree key */ + protected $keys = [ + 'name', + 'value', + 'unit', + 'labels', + ]; + + /** + * Keep only necessary configs + * Override because $configs is an array of array (metrics item) and we want to clean each item. + */ + public function cleanConfigs(array $configs): array + { + $result = []; + foreach ($configs as $config) { + $result[] = array_intersect_key($config, array_flip($this->keys)); + } + + return $result; + } + + /** + * @throws \InvalidArgumentException + * @throws \RuntimeException + */ + public function getConfigTreeBuilder(): TreeBuilder + { + $treeBuilder = new TreeBuilder('metricsItemsConfig'); + /** @var ArrayNodeDefinition $rootNode */ + $rootNode = $treeBuilder->getRootNode(); + $rootNode->arrayPrototype() + ->children() + ->scalarNode('name') + ->isRequired()->cannotBeEmpty() + ->end() + ->integerNode('value')->isRequired() + ->min(0) + ->end() + ->scalarNode('unit')->isRequired()->cannotBeEmpty()->end() + ->variableNode('labels') + // Remove empty labels totally + ->beforeNormalization() + ->ifTrue(function ($value) { + return empty($value); + }) + ->thenUnset() + ->end() + ->validate() + ->ifTrue(function ($value) { + // Ensure all values in the array are strings + if (!is_array($value)) { + return true; + } + foreach ($value as $val) { + if (!is_string($val)) { + return true; + } + } + + return false; + }) + ->thenInvalid('Labels must be an array of key-value pairs with string values.') + ->end() + ->info('Optional labels as key-value pairs.') + ->end() + ->end() + ->end() + ->end() + ; + + return $treeBuilder; + } +} diff --git a/src/Configuration/Metrics/Meta.php b/src/Configuration/Metrics/Meta.php new file mode 100644 index 0000000..ee2e480 --- /dev/null +++ b/src/Configuration/Metrics/Meta.php @@ -0,0 +1,46 @@ + The list of each configuration tree key */ + protected $keys = [ + 'window_size_seconds', + 'utc_now_timestamp', + ]; + + /** + * @throws \InvalidArgumentException + * @throws \RuntimeException + */ + public function getConfigTreeBuilder(): TreeBuilder + { + $treeBuilder = new TreeBuilder('metricsMetaConfig'); + /** @var ArrayNodeDefinition $rootNode */ + $rootNode = $treeBuilder->getRootNode(); + $rootNode->children() + ->integerNode('window_size_seconds')->isRequired()->min(0)->end() + ->integerNode('utc_now_timestamp')->isRequired()->min(0)->end() + ->end() + ; + + return $treeBuilder; + } +} diff --git a/src/Constants.php b/src/Constants.php index 6771686..71c7246 100644 --- a/src/Constants.php +++ b/src/Constants.php @@ -34,6 +34,10 @@ class Constants extends CommonConstants * @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 user agent prefix used to send request to LAPI */ @@ -42,4 +46,8 @@ class Constants extends CommonConstants * @var string The current version of this library */ public const VERSION = 'v3.3.2'; + /** + * @var string The metrics type + */ + public const METRICS_TYPE = 'crowdsec-php-bouncer'; } diff --git a/src/Metrics.php b/src/Metrics.php new file mode 100644 index 0000000..0a56030 --- /dev/null +++ b/src/Metrics.php @@ -0,0 +1,89 @@ +configureProperties($properties); + $this->configureMeta($meta); + $this->configureItems($items); + } + + public function toArray(): array + { + return [ + 'remediation_components' => [ + $this->properties + + [ + 'metrics' => [ + [ + 'meta' => $this->meta, + 'items' => $this->items, + ], + ], + ], + ], + ]; + } + + private function configureItems(array $items): void + { + $configuration = new ItemsConfig(); + $processor = new Processor(); + $this->items = $processor->processConfiguration($configuration, [$configuration->cleanConfigs($items)]); + } + + private function configureMeta(array $meta): void + { + $configuration = new MetaConfig(); + $processor = new Processor(); + $this->meta = $processor->processConfiguration($configuration, [$configuration->cleanConfigs($meta)]); + } + + private function configureProperties(array $properties): void + { + $configuration = new PropertiesConfig(); + $processor = new Processor(); + $this->properties = $processor->processConfiguration( + $configuration, + [$configuration->cleanConfigs($properties)] + ); + } +} diff --git a/tests/Integration/BouncerTest.php b/tests/Integration/BouncerTest.php index f14c69d..82969d8 100644 --- a/tests/Integration/BouncerTest.php +++ b/tests/Integration/BouncerTest.php @@ -65,7 +65,7 @@ protected function setUp(): void $this->configs = $bouncerConfigs; $this->watcherClient = new WatcherClient($this->configs); - // Delete all decisions + // Delete all decisions $this->watcherClient->deleteAllDecisions(); usleep(200000); // 200ms } @@ -109,7 +109,7 @@ public function testDecisionsStream($requestHandler) $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 +117,7 @@ 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 +125,7 @@ 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( @@ -134,8 +134,66 @@ public function testDecisionsStream($requestHandler) 'scopes' => Constants::SCOPE_IP . ',' . Constants::SCOPE_RANGE . ',' . Constants::SCOPE_COUNTRY, ] ); - $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->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)); + } + + /** + * @dataProvider requestHandlerProvider + */ + public function testPushUsageMetrics($requestHandler) + { + if ('FileGetContents' === $requestHandler) { + $client = new Bouncer($this->configs, new FileGetContents($this->configs)); + } else { + // Curl by default + $client = new Bouncer($this->configs); + } + if ($this->useTls) { + $this->assertEquals(Constants::AUTH_TLS, $this->configs['auth_type']); + } else { + $this->assertEquals(Constants::AUTH_KEY, $this->configs['auth_type']); + } + $this->checkRequestHandler($client, $requestHandler); + // test 1 : success + $properties = [ + 'name' => 'test', + 'version' => '1.0.0', + 'type' => 'test', + 'utc_startup_timestamp' => 1234567890, + + ]; + $meta = [ + 'window_size_seconds' => 60, + ]; + $items = [ + [ + 'name' => 'dropped', + 'value' => 1, + 'unit' => 'test', + 'labels' => [ + 'origin' => 'CAPI', + ], + ], + ]; + + $metrics = $client->buildUsageMetrics($properties, $meta, $items); + + $response = $client->pushUsageMetrics($metrics); + + $this->assertEquals([], $response, 'Should be empty response'); + + // test 2 : failure + + unset($metrics['remediation_components'][0]['version']); + $error = null; + try { + $client->pushUsageMetrics($metrics); + } catch (\CrowdSec\LapiClient\ClientException $e) { + $error = $e->getMessage(); + } + + PHPUnitUtil::assertRegExp($this, '/Unexpected response status code: 422/', $error, 'Payload should be invalid'); } /** @@ -164,23 +222,24 @@ public function testFilteredDecisions($requestHandler) $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); $response = $client->getFilteredDecisions(['ip' => TestConstants::BAD_IP]); - $this->assertCount(2, $response, '2 decisions for specified IP. Response: '. json_encode($response)); + $this->assertCount(2, $response, '2 decisions for specified IP. Response: ' . json_encode($response)); $response = $client->getFilteredDecisions(['scope' => Constants::SCOPE_COUNTRY, 'value' => TestConstants::JAPAN]); - $this->assertCount(1, $response, '1 decision for specified country. Response: '. json_encode($response)); + $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)); + $this->assertCount(1, $response, '1 decision for specified range. Response: ' . json_encode($response)); $response = $client->getFilteredDecisions(['ip' => '2.3.4.5']); - $this->assertCount(0, $response, '0 decision for specified IP. Response: '. json_encode($response)); + $this->assertCount(0, $response, '0 decision for specified IP. Response: ' . json_encode($response)); $response = $client->getFilteredDecisions(['type' => 'captcha']); - $this->assertCount(2, $response, '2 decision for specified type. Response: '. json_encode($response)); + $this->assertCount(2, $response, '2 decision for specified type. Response: ' . json_encode($response)); // 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)); } /** * @dataProvider requestHandlerProvider + * * @group appsec */ public function testAppSecDecision($requestHandler) @@ -213,16 +272,16 @@ 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) @@ -232,7 +291,7 @@ 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)); } /** diff --git a/tests/Unit/AbstractClientTest.php b/tests/Unit/AbstractClientTest.php index 432d1ae..2cede3e 100644 --- a/tests/Unit/AbstractClientTest.php +++ b/tests/Unit/AbstractClientTest.php @@ -23,11 +23,11 @@ use CrowdSec\LapiClient\Tests\PHPUnitUtil; /** - * @uses \CrowdSec\LapiClient\Configuration::getConfigTreeBuilder + * @uses \CrowdSec\LapiClient\Configuration\Bouncer::getConfigTreeBuilder * @uses \CrowdSec\LapiClient\Bouncer::formatUserAgent - * @uses \CrowdSec\LapiClient\Configuration::addConnectionNodes - * @uses \CrowdSec\LapiClient\Configuration::validate - * @uses \CrowdSec\LapiClient\Configuration::addAppSecNodes + * @uses \CrowdSec\LapiClient\Configuration\Bouncer::addConnectionNodes + * @uses \CrowdSec\LapiClient\Configuration\Bouncer::validate + * @uses \CrowdSec\LapiClient\Configuration\Bouncer::addAppSecNodes * * @covers \CrowdSec\LapiClient\Bouncer::__construct * @covers \CrowdSec\LapiClient\Bouncer::configure diff --git a/tests/Unit/BouncerTest.php b/tests/Unit/BouncerTest.php index 7261f1c..d6bc958 100644 --- a/tests/Unit/BouncerTest.php +++ b/tests/Unit/BouncerTest.php @@ -18,6 +18,7 @@ use CrowdSec\Common\Client\ClientException; use CrowdSec\Common\Client\HttpMessage\Response; use CrowdSec\LapiClient\Bouncer; +use CrowdSec\LapiClient\Metrics; use CrowdSec\LapiClient\Constants; use CrowdSec\LapiClient\Tests\Constants as TestConstants; use CrowdSec\LapiClient\Tests\MockedData; @@ -32,10 +33,22 @@ * @covers \CrowdSec\LapiClient\Bouncer::getAppSecDecision * @covers \CrowdSec\LapiClient\Bouncer::manageAppSecRequest * @covers \CrowdSec\LapiClient\Bouncer::formatUserAgent - * @covers \CrowdSec\LapiClient\Configuration::getConfigTreeBuilder - * @covers \CrowdSec\LapiClient\Configuration::addConnectionNodes - * @covers \CrowdSec\LapiClient\Configuration::addAppSecNodes - * @covers \CrowdSec\LapiClient\Configuration::validate + * @covers \CrowdSec\LapiClient\Configuration\Bouncer::getConfigTreeBuilder + * @covers \CrowdSec\LapiClient\Configuration\Bouncer::addConnectionNodes + * @covers \CrowdSec\LapiClient\Configuration\Bouncer::addAppSecNodes + * @covers \CrowdSec\LapiClient\Configuration\Bouncer::validate + * + * @covers \CrowdSec\LapiClient\Bouncer::buildUsageMetrics + * @covers \CrowdSec\LapiClient\Bouncer::getOs + * @covers \CrowdSec\LapiClient\Configuration\Metrics::getConfigTreeBuilder + * @covers \CrowdSec\LapiClient\Configuration\Metrics\Items::cleanConfigs + * @covers \CrowdSec\LapiClient\Configuration\Metrics\Items::getConfigTreeBuilder + * @covers \CrowdSec\LapiClient\Configuration\Metrics\Meta::getConfigTreeBuilder + * @covers \CrowdSec\LapiClient\Metrics::__construct + * @covers \CrowdSec\LapiClient\Metrics::configureItems + * @covers \CrowdSec\LapiClient\Metrics::configureMeta + * @covers \CrowdSec\LapiClient\Metrics::configureProperties + * @covers \CrowdSec\LapiClient\Metrics::toArray * * @uses \CrowdSec\LapiClient\Bouncer::cleanHeadersForLog * @uses \CrowdSec\LapiClient\Bouncer::cleanRawBodyForLog() @@ -90,6 +103,166 @@ public function testFilteredDecisionsParams() $mockClient->getFilteredDecisions(['ip' => '1.2.3.4']); } + public function testBuildUsageMetrics() + { + $osName = php_uname('s'); + $osVersion = php_uname('v'); + + $client = new Bouncer($this->configs); + // Test 1: basic + $properties = [ + 'name' => 'test', + 'version' => '1.0.0', + 'type' => 'test', + 'utc_startup_timestamp' => 1234567890, + + ]; + $meta = [ + 'window_size_seconds' => 60, + ]; + $items = [ + [ + 'name' => 'dropped', + 'value' => 1, + 'unit' => 'test', + 'labels' => [ + 'origin' => 'CAPI', + ], + ], + ]; + + $metrics = $client->buildUsageMetrics($properties, $meta, $items); + + $this->assertEquals( + [ + 'remediation_components' => [ + [ + 'name' => 'test', + 'version' => '1.0.0', + 'type' => 'test', + 'feature_flags' => [], + 'utc_startup_timestamp' => 1234567890, + 'os' => [ + 'name' => $osName, + 'version' => $osVersion, + ], + ] + [ + 'metrics' => [ + [ + 'meta' => [ + 'window_size_seconds' => 60, + 'utc_now_timestamp' => time(), + ], + 'items' => $items, + ], + ], + ], + ], + ], + $metrics, + 'Should format metrics as expected' + ); + + // Test 2: with last pull + + $properties = [ + 'name' => 'test', + 'version' => '1.0.0', + 'type' => 'test', + 'utc_startup_timestamp' => 1234567890, + 'last_pull' => 123456747, + ]; + + + $metrics = $client->buildUsageMetrics($properties, $meta, $items); + + $this->assertEquals( + [ + 'remediation_components' => [ + [ + 'name' => 'test', + 'version' => '1.0.0', + 'type' => 'test', + 'feature_flags' => [], + 'utc_startup_timestamp' => 1234567890, + 'os' => [ + 'name' => $osName, + 'version' => $osVersion, + ], + 'last_pull' => 123456747, + ] + [ + 'metrics' => [ + [ + 'meta' => [ + 'window_size_seconds' => 60, + 'utc_now_timestamp' => time(), + ], + 'items' => $items, + ], + ], + ], + ], + ], + $metrics, + 'Should format metrics as expected' + ); + + + // Test 3 : labels exception + + $items = [ + [ + 'name' => 'dropped', + 'value' => 1, + 'unit' => 'test', + 'labels' => [ + 'origin' => 22, + 'test' => 'test', + ], + ], + ]; + + $error = ''; + try { + $client->buildUsageMetrics($properties, $meta, $items); + } catch (\Exception $e) { + $error = $e->getMessage(); + } + + PHPUnitUtil::assertRegExp( + $this, + '/Labels must be an array of key-value pairs with string values/', + $error, + 'Labels must be strings' + ); + + // Test 4 : labels exception 2 + + $items = [ + [ + 'name' => 'dropped', + 'value' => 1, + 'unit' => 'test', + 'labels' => "origin", + ], + ]; + + $error = ''; + try { + $client->buildUsageMetrics($properties, $meta, $items); + } catch (\Exception $e) { + $error = $e->getMessage(); + } + + PHPUnitUtil::assertRegExp( + $this, + '/Labels must be an array of key-value pairs with string values/', + $error, + 'Labels must be an array' + ); + + } + public function testAppSecDecisionParams() { $mockClient = $this->getMockBuilder('CrowdSec\LapiClient\Bouncer') diff --git a/tests/Unit/CurlTest.php b/tests/Unit/CurlTest.php index b2b7bd6..4676623 100644 --- a/tests/Unit/CurlTest.php +++ b/tests/Unit/CurlTest.php @@ -20,13 +20,13 @@ use CrowdSec\LapiClient\TimeoutException; /** - * @uses \CrowdSec\LapiClient\Configuration::getConfigTreeBuilder + * @uses \CrowdSec\LapiClient\Configuration\Bouncer::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\Configuration\Bouncer::addConnectionNodes + * @uses \CrowdSec\LapiClient\Configuration\Bouncer::validate + * @uses \CrowdSec\LapiClient\Configuration\Bouncer::addAppSecNodes * @uses \CrowdSec\LapiClient\Bouncer::cleanHeadersForLog * @uses \CrowdSec\LapiClient\Bouncer::cleanRawBodyForLog() * diff --git a/tests/Unit/FileGetContentsTest.php b/tests/Unit/FileGetContentsTest.php index b034b88..55fba67 100644 --- a/tests/Unit/FileGetContentsTest.php +++ b/tests/Unit/FileGetContentsTest.php @@ -23,14 +23,14 @@ use CrowdSec\LapiClient\TimeoutException; /** - * @uses \CrowdSec\LapiClient\Configuration::getConfigTreeBuilder + * @uses \CrowdSec\LapiClient\Configuration\Bouncer::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\Bouncer::addConnectionNodes + * @uses \CrowdSec\LapiClient\Configuration\Bouncer::validate + * @uses \CrowdSec\LapiClient\Configuration\Bouncer::addAppSecNodes * * @covers \CrowdSec\LapiClient\Bouncer::getStreamDecisions * @covers \CrowdSec\LapiClient\Bouncer::getFilteredDecisions diff --git a/tests/scripts/bouncer/build-and-push-metrics.php b/tests/scripts/bouncer/build-and-push-metrics.php new file mode 100644 index 0000000..b5b418b --- /dev/null +++ b/tests/scripts/bouncer/build-and-push-metrics.php @@ -0,0 +1,49 @@ + and are required' . \PHP_EOL + . 'Usage: php build-and-push-metrics.php ' . \PHP_EOL + . 'Example: php build-and-push-metrics.php \'{"name":"TEST BOUNCER","type":"crowdsec-test-php-bouncer","version":"v0.0.0","items":[{"name":"dropped","value":12,"unit":"request","labels":{"origin":"CAPI"}}],"meta":{"window_size_seconds":900,"utc_now_timestamp":12}}\' my-bouncer-key https://crowdsec:8080 ' + . \PHP_EOL); +} + +if (is_null($metrics)) { + exit('Param is not a valid json' . \PHP_EOL + . 'Usage: php build-and-push-metrics.php ' + . \PHP_EOL); +} + +echo \PHP_EOL . 'Instantiate bouncer ...' . \PHP_EOL; +// Config to use an Api Key for connection +$apiKeyConfigs = [ + 'auth_type' => 'api_key', + 'api_url' => $lapiUrl, + 'api_key' => $bouncerKey, +]; +$logger = new ConsoleLog(); +$client = new Bouncer($apiKeyConfigs, null, $logger); +echo 'Bouncer instantiated' . \PHP_EOL; + +$properties = $metrics; +unset($properties['meta'], $properties['items']); +$meta = $metrics['meta'] ?? []; +$items = $metrics['items'] ?? []; + +echo 'Creating usage metrics ...' . \PHP_EOL; +$response = $client->buildUsageMetrics($properties, $meta, $items); +echo 'Build metrics is:' . json_encode($response, \JSON_UNESCAPED_SLASHES) . \PHP_EOL; +$usageMetrics = $response; + +echo 'Pushing usage metrics to ' . $client->getConfig('api_url') . ' ...' . \PHP_EOL; +echo 'Metrics: '; +print_r(json_encode($usageMetrics) . \PHP_EOL); +$response = $client->pushUsageMetrics($usageMetrics); +echo \PHP_EOL . 'Usage metrics response is:' . json_encode($response) . \PHP_EOL;