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 3f9e9ed..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 @@ -99,23 +99,25 @@ 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 - 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/sdk-chain-tests.yml b/.github/workflows/sdk-chain-tests.yml index a62c50d..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 }}"]' @@ -26,7 +25,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 +34,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 +45,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"]' diff --git a/.github/workflows/unit-and-integration-test.yml b/.github/workflows/unit-and-integration-test.yml index 07c7f4d..85c9ea4 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: @@ -35,7 +33,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 @@ -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: | @@ -85,8 +83,11 @@ 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 + uses: actions/checkout@v5 with: path: ${{env.EXTENSION_PATH}} @@ -101,32 +102,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 --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: | 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 --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 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/.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..abaa85e 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 ``` @@ -82,8 +80,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: @@ -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 @@ -132,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`. @@ -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,24 +209,24 @@ 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 +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. - 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 @@ -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/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 62f9378..bce75c8 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -14,6 +14,9 @@ - [Installation](#installation) - [Bouncer client instantiation](#bouncer-client-instantiation) - [LAPI calls](#lapi-calls) + - [Watcher client instantiation](#watcher-client-instantiation) + - [Watcher configuration](#watcher-configuration) + - [LAPI calls](#lapi-calls-1) - [Bouncer client configurations](#bouncer-client-configurations) - [LAPI url](#lapi-url) - [AppSec url](#appsec-url) @@ -49,6 +52,9 @@ - [Push usage metrics](#push-usage-metrics) - [Command usage](#command-usage-3) - [Example](#example-2) + - [Push alert](#push-alert) + - [Command usage](#command-usage-4) + - [Example](#example-3) @@ -59,11 +65,16 @@ 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 + - Push alerts + - Search alerts + - Delete alerts + - Get alert by ID - Overridable request handler (`curl` by default, `file_get_contents` also available) @@ -168,6 +179,110 @@ 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 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\Watcher; +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', + 'api_url' => 'https://your-crowdsec-lapi-url:8080', + 'machine_id' => 'your-machine-id', + 'password' => 'your-machine-password', +]; + +// Optional: scenarios to register on login +$scenarios = ['crowdsecurity/http-probing']; + +$watcher = new Watcher($configs, $cache, $scenarios); +``` + +#### 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) +- A PSR-6 cache implementation (mandatory) - used to store the authentication token + +#### LAPI calls + +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 + +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 = $watcher->pushAlerts($alerts); +``` + +##### Search alerts + +To search for existing alerts: + +```php +$query = [ + 'scope' => 'ip', + 'value' => '1.2.3.4', + 'limit' => 10, +]; +$alerts = $watcher->searchAlerts($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 = $watcher->deleteAlerts($query); +``` + +##### Get alert by ID + +To retrieve a specific alert: + +```php +$alert = $watcher->getAlertById(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: @@ -551,3 +666,17 @@ php tests/scripts/bouncer/build-and-push-metrics.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 new file mode 100644 index 0000000..61d7d07 --- /dev/null +++ b/src/AbstractLapiClient.php @@ -0,0 +1,138 @@ +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. + */ + protected function configure(array $configs): void + { + $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. + * + * @throws ClientException + */ + protected function manageRequest( + string $method, + string $endpoint, + array $parameters = [] + ): array { + try { + $this->logger->debug('Now processing a LAPI client request', [ + 'type' => 'LAPI_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/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..3fa0190 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, @@ -38,7 +38,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', @@ -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; } @@ -101,11 +102,9 @@ public function getConfigTreeBuilder(): TreeBuilder * * @param NodeDefinition|ArrayNodeDefinition $rootNode * - * @return void - * * @throws \InvalidArgumentException */ - private function addAppSecNodes($rootNode) + private function addAppSecNodes($rootNode): void { $rootNode->children() ->scalarNode('appsec_url')->cannotBeEmpty()->defaultValue(Constants::DEFAULT_APPSEC_URL)->end() @@ -119,11 +118,9 @@ private function addAppSecNodes($rootNode) * * @param NodeDefinition|ArrayNodeDefinition $rootNode * - * @return void - * * @throws \InvalidArgumentException */ - private function addConnectionNodes($rootNode) + private function addConnectionNodes($rootNode): void { $rootNode->children() ->scalarNode('api_url')->cannotBeEmpty()->defaultValue(Constants::DEFAULT_LAPI_URL)->end() @@ -153,28 +150,16 @@ private function addConnectionNodes($rootNode) } /** - * Conditional validation. + * Validate TLS authentication settings. * * @param NodeDefinition|ArrayNodeDefinition $rootNode * - * @return void - * * @throws \InvalidArgumentException * @throws \RuntimeException */ - private function validate($rootNode) + 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']) { @@ -196,4 +181,30 @@ private function validate($rootNode) ->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/Alert.php b/src/Configuration/Alert.php new file mode 100644 index 0000000..dc1cac2 --- /dev/null +++ b/src/Configuration/Alert.php @@ -0,0 +1,53 @@ +getRootNode(); + // @formatter:off + $rootNode + ->children() + ->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() + ->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..994d3a9 --- /dev/null +++ b/src/Configuration/Alert/Decision.php @@ -0,0 +1,44 @@ +getRootNode(); + + // @formatter:off + $rootNode + ->children() + ->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 + + return $treeBuilder; + } +} diff --git a/src/Configuration/Alert/Event.php b/src/Configuration/Alert/Event.php new file mode 100644 index 0000000..293f42a --- /dev/null +++ b/src/Configuration/Alert/Event.php @@ -0,0 +1,41 @@ +getRootNode(); + + // @formatter:off + $rootNode + ->children() + ->arrayNode('meta')->isRequired() + ->arrayPrototype() + ->children() + ->scalarNode('key')->isRequired()->cannotBeEmpty()->end() + ->scalarNode('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..3a82f78 --- /dev/null +++ b/src/Configuration/Alert/Meta.php @@ -0,0 +1,33 @@ +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..80d2e61 --- /dev/null +++ b/src/Configuration/Alert/Source.php @@ -0,0 +1,48 @@ +getRootNode(); + + // @formatter:off + $rootNode + ->children() + ->scalarNode('scope')->isRequired()->cannotBeEmpty()->end() + ->scalarNode('value')->isRequired()->cannotBeEmpty()->end() + ->scalarNode('ip')->cannotBeEmpty()->end() + ->scalarNode('range')->cannotBeEmpty()->end() + ->scalarNode('as_number')->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() + ; + // @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..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', diff --git a/src/Configuration/Watcher.php b/src/Configuration/Watcher.php new file mode 100644 index 0000000..20e714b --- /dev/null +++ b/src/Configuration/Watcher.php @@ -0,0 +1,120 @@ +getRootNode(); + + $this->addWatcherNodes($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(); + } + + /** + * Override API key validation for Watcher. + * + * For Watcher, api_key auth requires machine_id and password instead of api_key. + * + * @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']) { + 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/Constants.php b/src/Constants.php index 6a233eb..be11f0f 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_ENDPOINT = '/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..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 new file mode 100644 index 0000000..dffc579 --- /dev/null +++ b/src/Payload/Alert.php @@ -0,0 +1,249 @@ +, + * 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 TProps + */ + 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( + [ + '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'] ?? [], + $data['labels'] ?? [] + ); + } + + /** + * @return TAlertFull + */ + public function toArray(): array + { + $result = $this->properties; + if (null !== $this->source) { + $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; + } + + /** + * @return array + */ + #[\ReturnTypeWillChange] + public function jsonSerialize() + { + return $this->toArray(); + } +} diff --git a/src/Watcher.php b/src/Watcher.php new file mode 100644 index 0000000..5eaf52f --- /dev/null +++ b/src/Watcher.php @@ -0,0 +1,281 @@ +, + * 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 Watcher 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 + */ + protected function getConfiguration(): Configuration + { + return new WatcherConfig(); + } + + /** + * Authenticate with LAPI and retrieve a JWT token. + * + * @param string[] $scenarios Optional list of scenarios to register + * + * @return TLoginResponse + * + * @throws ClientException + */ + private function login(array $scenarios = []): array + { + $data = [ + '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 */ + $configs = $this->configs; + $data['machine_id'] = $configs['machine_id'] ?? ''; + $data['password'] = $configs['password'] ?? ''; + } + + return $this->manageRequest( + 'POST', + Constants::WATCHER_LOGIN_ENDPOINT, + $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/BouncerTest.php b/tests/Integration/BouncerTest.php index 175d5e1..24951a2 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; @@ -22,9 +24,6 @@ use CrowdSec\LapiClient\TimeoutException; use PHPUnit\Framework\TestCase; -/** - * @coversNothing - */ final class BouncerTest extends TestCase { /** @@ -36,9 +35,9 @@ final class BouncerTest extends TestCase */ protected $useTls; /** - * @var WatcherClient + * @var TestWatcher */ - protected $watcherClient; + protected $watcher; private function addTlsConfig(&$bouncerConfigs, $tlsPath) { @@ -50,7 +49,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, @@ -64,9 +63,9 @@ protected function setUp(): void } $this->configs = $bouncerConfigs; - $this->watcherClient = new WatcherClient($this->configs); + $this->watcher = new TestWatcher($this->configs); // Delete all decisions - $this->watcherClient->deleteAllDecisions(); + $this->watcher->deleteAllDecisions(); usleep(200000); // 200ms } @@ -104,12 +103,29 @@ 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->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->watcher->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 +133,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,9 +145,12 @@ 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(); + $this->watcher->deleteAllDecisions(); $response = $client->getStreamDecisions( false, [ @@ -135,7 +158,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) + ); } /** @@ -217,12 +243,20 @@ 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($now, '24h', '+24 hours', TestConstants::JAPAN, 'captcha', Constants::SCOPE_COUNTRY); + $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', + 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)); @@ -231,9 +265,13 @@ 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, $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 +309,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 +334,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 +374,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/Integration/WatcherClient.php b/tests/Integration/TestWatcher.php similarity index 64% rename from tests/Integration/WatcherClient.php rename to tests/Integration/TestWatcher.php index e36323b..8bb13a3 100644 --- a/tests/Integration/WatcherClient.php +++ b/tests/Integration/TestWatcher.php @@ -5,66 +5,52 @@ namespace CrowdSec\LapiClient\Tests\Integration; use CrowdSec\Common\Client\AbstractClient; -use CrowdSec\LapiClient\ClientException; use CrowdSec\LapiClient\Constants; - -class WatcherClient extends AbstractClient +use CrowdSec\LapiClient\Watcher; +use Symfony\Component\Cache\Adapter\ArrayAdapter; + +/** + * Test helper for setting up watcher state in integration tests. + * + * Uses Watcher to push alerts with decisions for testing bouncer functionality. + * Extends AbstractClient to make raw HTTP requests for deleting decisions. + */ +class TestWatcher extends AbstractClient { - public const WATCHER_LOGIN_ENDPOINT = '/v1/watchers/login'; - - public const WATCHER_DECISIONS_ENDPOINT = '/v1/decisions'; - - public const WATCHER_ALERT_ENDPOINT = '/v1/alerts'; - public const HOURS24 = '+24 hours'; + /** @var Watcher */ + private $watcher; + /** @var string */ private $token; - /** - * @var array|string[] - */ + + /** @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; - parent::__construct($this->configs); - } + $cache = new ArrayAdapter(); + $this->watcher = new Watcher($configs, $cache); - /** - * 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); + $this->headers = ['User-Agent' => 'LAPI_WATCHER_TEST/' . Constants::VERSION]; + + 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'); @@ -74,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( @@ -90,39 +75,54 @@ public function setSecondState(): void $this->addDecision($now, '24h', self::HOURS24, TestHelpers::IP_FRANCE, 'ban'); } + public function deleteAllAlerts(): void + { + $this->watcher->deleteAlerts([]); + } + + /** + * Delete all decisions. + * + * 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 + { + $this->ensureLogin(); + + $this->request( + 'DELETE', + Constants::DECISIONS_FILTER_ENDPOINT, + [], + $this->headers + ); + } + /** - * Ensure we retrieved a JWT to connect the API. + * Ensure we have a valid token by triggering a watcher operation. */ private function ensureLogin(): void { if (!$this->token) { - $data = [ - 'scenarios' => [], - ]; - $credentials = $this->manageRequest( + // 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 Watcher since login is private. + // We need to do our own login call. + $loginResponse = $this->request( 'POST', - self::WATCHER_LOGIN_ENDPOINT, - $data + Constants::WATCHER_LOGIN_ENDPOINT, + ['scenarios' => []], + $this->headers ); - $this->token = $credentials['token']; + $this->token = $loginResponse['token'] ?? ''; $this->headers['Authorization'] = 'Bearer ' . $this->token; } } - public function deleteAllDecisions(): void - { - // Delete all existing decisions. - $this->ensureLogin(); - - $this->manageRequest( - 'DELETE', - self::WATCHER_DECISIONS_ENDPOINT, - [] - ); - } - - protected function getFinalScope($scope, $value) + protected function getFinalScope(string $scope, string $value): string { $scope = (Constants::SCOPE_IP === $scope && 2 === count(explode('/', $value))) ? Constants::SCOPE_RANGE : $scope; @@ -143,25 +143,24 @@ 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' => [ [ '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, ], ], - 'events' => [ - ], + 'events' => [], 'events_count' => 1, 'labels' => null, 'leakspeed' => '0', @@ -178,10 +177,6 @@ public function addDecision( 'stop_at' => $stopAt, ]; - $result = $this->manageRequest( - 'POST', - self::WATCHER_ALERT_ENDPOINT, - [$body] - ); + $this->watcher->pushAlerts([$alert]); } } diff --git a/tests/Integration/WatcherTest.php b/tests/Integration/WatcherTest.php new file mode 100644 index 0000000..347bc75 --- /dev/null +++ b/tests/Integration/WatcherTest.php @@ -0,0 +1,418 @@ + 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', + ]; + + $this->configs = $watcherConfigs; + + $cache = new ArrayAdapter(); + $this->watcher = new Watcher($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 Watcher($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->watcher->searchAlerts([]); + self::assertIsArray($result); + } + + /** + * @covers ::pushAlerts + */ + 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->watcher->pushAlerts([ + // with decisions + $alert01->toArray(), + $alert02->toArray(), + // without decisions + $alert11->toArray(), + $alert12->toArray(), + ]); + self::assertIsArray($result); + self::assertCount(4, $result); + + return $result; + } + + /** + * @covers ::searchAlerts + * + * @depends testPush + * + * @dataProvider searchProvider + */ + public function testSearch(array $query, int $expectedCount): void + { + $result = $this->watcher->searchAlerts($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, + ]; + + // has_active_decision is a FILTER: true = only with decisions, false = only without + yield 'has_active_decision=true' => [ + ['has_active_decision' => 'true'], + 3, // alert01, alert02 have decisions; alert02 simulated also counted + ]; + + yield 'has_active_decision=false' => [ + ['has_active_decision' => 'false'], + 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) + ]; + yield 'simulated=false' => [ + ['simulated' => 'false'], + 2, // Only non-simulated: alert01, alert11 + ]; + yield 'since -1h' => [ + [ + 'since' => '-1h', + ], + 0, + ]; + yield 'since 1m' => [ + ['since' => '1m'], + 4, // All alerts were just created + ]; + yield 'since 1h' => [ + ['since' => '10h'], + 4, + ]; + + yield 'until -1h' => [ + ['until' => '-1h'], + 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, + ]; + } + + /** + * @covers ::getAlertById + * + * @depends testPush + */ + public function testGetById(array $idList): void + { + foreach ($idList as $id) { + self::assertIsNumeric($id); + $result = $this->watcher->getAlertById(\intval($id)); + self::assertIsArray($result); + } + } + + /** + * @covers ::getAlertById + */ + public function testAlertInfoNotFound(): void + { + $result = $this->watcher->getAlertById(\PHP_INT_MAX); + self::assertNull($result); + } +} \ No newline at end of file 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 = << 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 +95,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 +269,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 +298,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 +337,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 +359,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 +398,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 +567,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..36ffdae 100644 --- a/tests/Unit/CurlTest.php +++ b/tests/Unit/CurlTest.php @@ -20,15 +20,17 @@ 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::validateTls + * @uses \CrowdSec\LapiClient\Configuration::validateApiKey + * @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 b034b88..117a069 100644 --- a/tests/Unit/FileGetContentsTest.php +++ b/tests/Unit/FileGetContentsTest.php @@ -17,20 +17,21 @@ * @license MIT License */ -use CrowdSec\Common\Client\HttpMessage\Request; use CrowdSec\LapiClient\Bouncer; use CrowdSec\LapiClient\Tests\MockedData; 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::validateTls + * @uses \CrowdSec\LapiClient\Configuration::validateApiKey + * @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 new file mode 100644 index 0000000..2723b15 --- /dev/null +++ b/tests/Unit/Payload/AlertTest.php @@ -0,0 +1,189 @@ +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, + ]; + } + + 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/WatcherTest.php b/tests/Unit/WatcherTest.php new file mode 100644 index 0000000..97fb4e2 --- /dev/null +++ b/tests/Unit/WatcherTest.php @@ -0,0 +1,335 @@ +configs = array_merge($this->configs, [ + 'machine_id' => 'test-machine', + 'password' => 'test-password', + ]); + $this->cache = new ArrayAdapter(); + } + + public function testWatcherInit() + { + $client = new Watcher($this->configs, $this->cache); + + $this->assertInstanceOf( + Watcher::class, + $client, + 'Watcher should be instantiated' + ); + + $configuration = PHPUnitUtil::callMethod($client, 'getConfiguration', []); + $this->assertInstanceOf( + WatcherConfig::class, + $configuration, + 'Watcher should use Watcher configuration' + ); + } + + public function testWatcherInitWithTlsAuth() + { + $tlsConfigs = [ + 'auth_type' => Constants::AUTH_TLS, + 'tls_cert_path' => '/path/to/cert.pem', + 'tls_key_path' => '/path/to/key.pem', + ]; + + $client = new Watcher($tlsConfigs, $this->cache); + + $this->assertInstanceOf( + Watcher::class, + $client, + 'Watcher should be instantiated with TLS auth' + ); + } + + public function testConfigureValidation() + { + // Test missing machine_id + $error = ''; + try { + new Watcher([ + 'api_key' => 'test-key', + 'password' => 'test-password', + ], $this->cache); + } 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 Watcher([ + 'api_key' => 'test-key', + 'machine_id' => 'test-machine', + ], $this->cache); + } catch (\Exception $e) { + $error = $e->getMessage(); + } + + PHPUnitUtil::assertRegExp( + $this, + '/machine_id and password are required/', + $error, + 'password should be required for api_key auth' + ); + } + + public function testPushAlerts() + { + $mockCurl = $this->getCurlMock(['handle']); + + $mockClient = $this->getMockBuilder(Watcher::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(Watcher::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(Watcher::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(Watcher::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(Watcher::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(Watcher::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(Watcher::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/push-alert.php b/tests/scripts/watcher/push-alert.php new file mode 100644 index 0000000..077bb6b --- /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 ...' . \PHP_EOL; +$configs = [ + 'auth_type' => 'api_key', + 'api_url' => $lapiUrl, + 'machine_id' => $machineId, + 'password' => $password, +]; +$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; + +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/phpunit/phpunit.xml b/tools/coding-standards/phpunit/phpunit.xml index aa555e8..bb978ab 100644 --- a/tools/coding-standards/phpunit/phpunit.xml +++ b/tools/coding-standards/phpunit/phpunit.xml @@ -1,7 +1,7 @@ - + ../../../tests/Unit + + + ../../../tests/Integration/WatcherTest.php + + + ../../../tests/Integration/BouncerTest.php + - + - - + + @@ -23,7 +23,25 @@ + + + + + + + + + + + + + + + + + +