diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..9838a2174 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @thib3113 diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 0d4e85a67..00c8ee1a1 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,12 +1,2 @@ -# These are supported funding model platforms - -github: thib3113 # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] -#patreon: # Replace with a single Patreon username -#open_collective: # Replace with a single Open Collective username -#ko_fi: # Replace with a single Ko-fi username -#tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel -#community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry -#liberapay: # Replace with a single Liberapay username -#issuehunt: # Replace with a single IssueHunt username -#otechie: # Replace with a single Otechie username -custom: paypal.me/thib3113 +github: thib3113 +custom: https://paypal.me/thib3113 diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 000000000..fabd7aa22 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,66 @@ +name: Bug report +description: Report a reproducible bug +title: "[Bug]: " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for the report. Do **not** use this form for security + vulnerabilities: see [SECURITY.md](https://github.com/thib3113/node-crowdsec/blob/main/SECURITY.md). + - type: textarea + id: description + attributes: + label: Description + description: What happened, and what did you expect instead? + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Steps to reproduce + placeholder: | + 1. Install ... + 2. Configure ... + 3. Run ... + validations: + required: true + - type: input + id: package + attributes: + label: Package + placeholder: crowdsec-client / crowdsec-http-middleware / crowdsec-client-scenarios / crowdsec-mcp + validations: + required: true + - type: input + id: package-version + attributes: + label: Package version + validations: + required: true + - type: input + id: node-version + attributes: + label: Node.js version + placeholder: "24" + validations: + required: true + - type: dropdown + id: os + attributes: + label: Operating system + options: + - Linux + - macOS + - Windows + - Other + validations: + required: true + - type: textarea + id: logs + attributes: + label: Relevant logs + description: Remove secrets and personal data before pasting. + render: shell + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..8ea924c41 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: Security vulnerability + url: https://github.com/thib3113/node-crowdsec/security/advisories/new + about: Please report security issues privately, not in a public issue. + - name: Guides and usage documentation + url: https://github.com/thib3113/node-crowdsec/wiki + about: Read the guides (authentication, bouncer/watcher setup, performance). + - name: API reference + url: https://thib3113.github.io/node-crowdsec/ + about: Read the generated technical documentation. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 000000000..18fa81952 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,36 @@ +name: Feature request +description: Suggest an improvement +title: "[Feature]: " +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What are you trying to do, and what blocks you today? + validations: + required: true + - type: textarea + id: solution + attributes: + label: Proposed solution + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + validations: + required: false + - type: dropdown + id: package + attributes: + label: Package + options: + - crowdsec-client + - crowdsec-http-middleware + - crowdsec-client-scenarios + - crowdsec-mcp + - documentation + validations: + required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..30573cd34 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,23 @@ +## What changed + + + +## Affected packages + +- [ ] `crowdsec-client` +- [ ] `crowdsec-http-middleware` +- [ ] `crowdsec-client-scenarios` +- [ ] `crowdsec-mcp` +- [ ] docs / examples / CI + +## Checklist + +- [ ] Tests added or updated (`pnpm -F test`) +- [ ] Lint passes (`pnpm -F lint`) +- [ ] README / docs updated if behavior or configuration changed +- [ ] `package.json` metadata updated if the public API changed +- [ ] No secrets or credentials committed + +## Related issues + +Closes # diff --git a/.github/workflows/build_and_test/action.yml b/.github/actions/build_and_test/action.yml similarity index 84% rename from .github/workflows/build_and_test/action.yml rename to .github/actions/build_and_test/action.yml index c2127d214..0bb24cedf 100644 --- a/.github/workflows/build_and_test/action.yml +++ b/.github/actions/build_and_test/action.yml @@ -25,23 +25,23 @@ runs: using: "composite" steps: - name: Install Node.js - uses: actions/setup-node@v7 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 with: node-version: ${{ inputs.node-version }} - - uses: pnpm/action-setup@v6 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 name: Install pnpm id: pnpm-install with: - run_install: true + run_install: false - name: Get pnpm store directory id: pnpm-cache shell: bash run: | - echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + echo "STORE_PATH=$(pnpm store path)" >> "$GITHUB_OUTPUT" - - uses: actions/cache@v6 + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 name: Setup pnpm cache with: path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} @@ -50,13 +50,12 @@ runs: ${{ runner.os }}-pnpm-store- - name: Install dependencies - run: pnpm install + run: pnpm install --frozen-lockfile shell: bash - name: run eslint run: npm run ci:eslint working-directory: ${{ inputs.package_path }} - continue-on-error: true shell: bash env: CI: true @@ -87,7 +86,7 @@ runs: run: | echo "safe=$(echo '${{ inputs.node-version }}' | tr -c 'A-Za-z0-9._-' '_')" >> "$GITHUB_OUTPUT" - - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: "coverage-${{ inputs.package_name }}-${{ steps.norm-node.outputs.safe }}" path: | @@ -98,7 +97,7 @@ runs: if: ${{ inputs.check == 'true' }} - - uses: codecov/codecov-action@v7 + - uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7 with: token: ${{ inputs.CODECOV_TOKEN }} flags: ${{ inputs.package_name }} diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 000000000..b033a349f --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,24 @@ +changelog: + exclude: + labels: + - ignore-for-release + categories: + - title: Breaking changes + labels: + - breaking + - title: Features + labels: + - enhancement + - feature + - title: Bug fixes + labels: + - bug + - title: Dependencies + labels: + - dependencies + - title: Documentation + labels: + - documentation + - title: Other changes + labels: + - "*" diff --git a/.github/scripts/mcp-http-e2e.sh b/.github/scripts/mcp-http-e2e.sh new file mode 100644 index 000000000..505709a27 --- /dev/null +++ b/.github/scripts/mcp-http-e2e.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +COMPOSE_FILE="$REPO_ROOT/packages/crowdsec-mcp/tests/e2e/http/compose.yaml" +PROJECT="crowdsec-mcp-e2e" + +export REPO_ROOT +export E2E_LAPI_PORT="${E2E_LAPI_PORT:-28080}" +export E2E_MCP_PORT="${E2E_MCP_PORT:-23939}" +export E2E_MCP_OPEN_PORT="${E2E_MCP_OPEN_PORT:-23940}" +export E2E_MCP_TOKEN="${E2E_MCP_TOKEN:-$(openssl rand -hex 24)}" + +compose() { + docker compose -p "$PROJECT" -f "$COMPOSE_FILE" "$@" +} + +cleanup() { + local status=$? + trap - EXIT + + if [[ "$status" -ne 0 ]]; then + compose ps || true + compose logs --no-color --tail=200 || true + fi + + if [[ "${E2E_KEEP:-0}" != "1" ]]; then + compose down -v --remove-orphans >/dev/null 2>&1 || true + else + echo "E2E_KEEP=1: stack left running" + echo "LAPI: http://127.0.0.1:${E2E_LAPI_PORT}" + echo "MCP: http://127.0.0.1:${E2E_MCP_PORT}/mcp" + echo "MCP open: http://127.0.0.1:${E2E_MCP_OPEN_PORT}/mcp" + fi + + exit "$status" +} +trap cleanup EXIT + +if [[ "${E2E_SKIP_BUILD:-0}" != "1" ]]; then + (cd "$REPO_ROOT" && pnpm --filter crowdsec-client build && pnpm --filter crowdsec-mcp build) +fi + +compose down -v --remove-orphans >/dev/null 2>&1 || true +compose up -d crowdsec nginx-logs + +wait_for_lapi() { + local i + for i in $(seq 1 60); do + if compose exec -T crowdsec cscli lapi status >/dev/null 2>&1; then + return 0 + fi + sleep 2 + done + return 1 +} + +if ! wait_for_lapi; then + echo "CrowdSec LAPI did not become ready" >&2 + exit 1 +fi + +if ! compose exec -T crowdsec cscli collections install crowdsecurity/nginx; then + echo "Failed to install crowdsecurity/nginx" >&2 + exit 1 +fi + +compose restart crowdsec + +if ! wait_for_lapi; then + echo "CrowdSec LAPI did not become ready after restart" >&2 + exit 1 +fi + +compose up -d mcp mcp-open + +wait_for_mcp() { + local url=$1 + local i + for i in $(seq 1 60); do + local code + code=$(curl -sS -o /dev/null -w '%{http_code}' -X POST "$url" \ + -H 'content-type: application/json' -d '{}' 2>/dev/null || true) + if [[ "$code" = "401" ]]; then + return 0 + fi + sleep 1 + done + return 1 +} + +MCP_URL="http://127.0.0.1:${E2E_MCP_PORT}/mcp" +MCP_OPEN_URL="http://127.0.0.1:${E2E_MCP_OPEN_PORT}/mcp" + +if ! wait_for_mcp "$MCP_URL" || ! wait_for_mcp "$MCP_OPEN_URL"; then + echo "MCP HTTP endpoints did not become ready" >&2 + exit 1 +fi + +BOUNCER_KEY=$(compose exec -T crowdsec cscli bouncers add e2e-bouncer -o raw 2>&1 | tr -d '\r' | tail -n1 | tr -d '\n') +MACHINE_ID="e2e-watcher" +MACHINE_PASSWORD="$(openssl rand -hex 16)" + +if ! compose exec -T crowdsec cscli machines add "$MACHINE_ID" --password "$MACHINE_PASSWORD" -f - >/dev/null 2>&1; then + echo "Failed to create E2E watcher machine" >&2 + exit 1 +fi + +if [[ -z "$BOUNCER_KEY" ]]; then + echo "Failed to create E2E bouncer" >&2 + exit 1 +fi + +LOGIN_JSON=$(curl -sS -X POST "http://127.0.0.1:${E2E_LAPI_PORT}/v1/watchers/login" \ + -H 'content-type: application/json' \ + --data "{\"machine_id\":\"$MACHINE_ID\",\"password\":\"$MACHINE_PASSWORD\"}" 2>/dev/null || true) +WATCHER_TOKEN=$(printf '%s' "$LOGIN_JSON" | jq -r '.token // empty') + +if [[ -z "$WATCHER_TOKEN" ]]; then + echo "E2E watcher login failed (machine: $MACHINE_ID)" >&2 + compose exec -T crowdsec cscli machines list || true + exit 1 +fi + +export CROWDSEC_MCP_HTTP_E2E=1 +export CROWDSEC_MCP_HTTP_URL="$MCP_URL" +export CROWDSEC_MCP_HTTP_OPEN_URL="$MCP_OPEN_URL" +export CROWDSEC_URL="http://127.0.0.1:${E2E_LAPI_PORT}" +export CROWDSEC_MCP_HTTP_TOKEN="$E2E_MCP_TOKEN" +export CROWDSEC_BOUNCER_KEY="$BOUNCER_KEY" +export CROWDSEC_WATCHER_TOKEN="$WATCHER_TOKEN" + +cd "$REPO_ROOT" +pnpm --filter crowdsec-mcp exec vitest run tests/e2e/http/live.test.ts + +# Seed the fixtures used by the live integration test, then run it against the +# same LAPI. It is kept separate so the HTTP E2E cannot delete its alerts first. +if ! compose exec -T crowdsec cscli allowlists create test-allowlist -d "mcp integration" >/dev/null; then + echo "Failed to create integration allowlist" >&2 + exit 1 +fi +if ! compose exec -T crowdsec cscli allowlists add test-allowlist 192.0.2.10 -d "mcp test" >/dev/null; then + echo "Failed to seed integration allowlist" >&2 + exit 1 +fi +if ! compose exec -T crowdsec cscli decisions add --ip 203.0.113.10 --duration 24h --reason "mcp integration" >/dev/null; then + echo "Failed to seed integration decision" >&2 + exit 1 +fi + +export CROWDSEC_INTEGRATION=1 +export CROWDSEC_INTEGRATION_WRITE=1 +export CROWDSEC_MACHINE_ID="$MACHINE_ID" +export CROWDSEC_PASSWORD="$MACHINE_PASSWORD" +export CROWDSEC_API_KEY="$BOUNCER_KEY" + +pnpm --filter crowdsec-mcp exec vitest run tests/integration/live.test.ts diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index e61f3fb4d..0cd643b19 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -23,26 +23,26 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Install Node.js - uses: actions/setup-node@v7 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 with: node-version: lts/* - - uses: pnpm/action-setup@v6 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 name: Install pnpm id: pnpm-install with: - run_install: true + run_install: false - name: Get pnpm store directory id: pnpm-cache shell: bash run: | - echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + echo "STORE_PATH=$(pnpm store path)" >> "$GITHUB_OUTPUT" - - uses: actions/cache@v6 + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 name: Setup pnpm cache with: path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} @@ -51,29 +51,66 @@ jobs: ${{ runner.os }}-pnpm-store- - name: Install dependencies - run: pnpm install + run: pnpm install --frozen-lockfile list-packages: runs-on: ubuntu-latest outputs: matrix: ${{ steps.set-matrix.outputs.matrix }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Install Node.js - uses: actions/setup-node@v7 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 with: node-version: lts/* - - uses: pnpm/action-setup@v6 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 name: Install pnpm id: pnpm-install with: - run_install: true + run_install: false - id: set-matrix run: | - export MATRIX=$(pnpm -s m ls --depth -1 -F "./packages/**" --json | jq -c) - echo "matrix=${MATRIX}" | tee -a $GITHUB_OUTPUT + MATRIX=$(pnpm -s m ls --depth -1 -F "./packages/**" --json | jq -c) + export MATRIX + echo "matrix=${MATRIX}" | tee -a "$GITHUB_OUTPUT" + quality: + needs: [prepare] + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Install Node.js + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 + with: + node-version: lts/* + + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + name: Install pnpm + id: pnpm-install + with: + run_install: false + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build packages + run: pnpm -r build + + - name: Check formatting + run: pnpm run format:check + + - name: Typecheck + run: pnpm run typecheck + + - name: Validate package metadata + run: pnpm run lint:packages + + - name: Validate package types + run: pnpm run lint:types + tests: needs: [prepare, list-packages] strategy: @@ -91,8 +128,8 @@ jobs: # Steps represent a sequence of tasks that will be executed as part of the job steps: - name: Checkout - uses: actions/checkout@v7 - - uses: ./.github/workflows/build_and_test + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: ./.github/actions/build_and_test with: node-version: ${{ matrix.node-version }} package_path: ${{ matrix.package.path }} @@ -117,10 +154,10 @@ jobs: # Steps represent a sequence of tasks that will be executed as part of the job steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - - uses: ./.github/workflows/build_and_test + - uses: ./.github/actions/build_and_test with: node-version: ${{ matrix.node-version }} package_path: ${{ matrix.package.path }} @@ -136,26 +173,26 @@ jobs: security-events: write runs-on: ubuntu-latest steps: - - uses: actions/checkout@master + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Install Node.js - uses: actions/setup-node@v7 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 with: node-version: lts/* - - uses: pnpm/action-setup@v6 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 name: Install pnpm id: pnpm-install with: - run_install: true + run_install: false - name: Get pnpm store directory id: pnpm-cache shell: bash run: | - echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + echo "STORE_PATH=$(pnpm store path)" >> "$GITHUB_OUTPUT" - - uses: actions/cache@v6 + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 name: Setup pnpm cache with: path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} @@ -164,14 +201,40 @@ jobs: ${{ runner.os }}-pnpm-store- - name: Install dependencies - run: pnpm install + run: pnpm install --frozen-lockfile - name: Run Snyk to check for vulnerabilities - uses: snyk/actions/node@master + uses: snyk/actions/node@11b673cfb4b0dc0d860a1323caa199d54950dcce # master env: SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} with: args: --sarif-file-output=snyk.sarif --all-projects --exclude=examples,dev - name: Upload result to GitHub Code Scanning - uses: github/codeql-action/upload-sarif@v4 + uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4 with: sarif_file: snyk.sarif + + analyze: + name: Analyze (javascript) + if: github.event_name == 'push' || github.event_name == 'pull_request' + needs: security + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Initialize CodeQL + uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4 + with: + languages: javascript + + - name: Autobuild + uses: github/codeql-action/autobuild@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4 + with: + category: "/language:javascript" diff --git a/.github/workflows/actions-security.yml b/.github/workflows/actions-security.yml new file mode 100644 index 000000000..6fbb92e35 --- /dev/null +++ b/.github/workflows/actions-security.yml @@ -0,0 +1,32 @@ +name: Actions security + +on: + push: + branches: [ main ] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + actionlint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Lint workflows + uses: raven-actions/actionlint@3d39aea434753780c3b3d4a1a31c854b4dbf49d7 # v2.2.0 + + zizmor: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Install uv + uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 + + - name: Audit workflows + run: uvx --no-build zizmor==1.31.1 --min-severity high --format=github .github/workflows diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index f3b061428..5d56c0c8d 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -9,14 +9,11 @@ # the `language` matrix defined below to confirm you have the correct set of # supported CodeQL languages. # +# Push/PR analysis runs in CI.yml after the Snyk SARIF upload. +# Keep this workflow for the weekly scheduled scan only. name: "CodeQL" on: - push: - branches: [ "main" ] - pull_request: - # The branches below must be a subset of the branches above - branches: [ "main" ] schedule: - cron: '43 8 * * 5' @@ -40,11 +37,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v4 + uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -58,7 +55,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v4 + uses: github/codeql-action/autobuild@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4 # โ„น๏ธ Command-line programs to run using the OS shell. # ๐Ÿ“š See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -71,6 +68,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4 + uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/docker-mcp.yml b/.github/workflows/docker-mcp.yml new file mode 100644 index 000000000..08bc8bfcf --- /dev/null +++ b/.github/workflows/docker-mcp.yml @@ -0,0 +1,97 @@ +name: Docker (crowdsec-mcp) + +on: + push: + branches: [main] + tags: + - 'v*' + - 'crowdsec-mcp/v*' + paths: + - 'packages/crowdsec-mcp/**' + - 'packages/crowdsec-client/**' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'package.json' + - 'tsconfig.json' + - '.npmrc' + - '.github/workflows/docker-mcp.yml' + pull_request: + paths: + - 'packages/crowdsec-mcp/**' + - 'packages/crowdsec-client/**' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'package.json' + - 'tsconfig.json' + - '.npmrc' + - '.github/workflows/docker-mcp.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: docker-crowdsec-mcp-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build and publish + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write + attestations: write + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Set up QEMU + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + + - name: Log in to GitHub Container Registry + if: github.event_name != 'pull_request' + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 + with: + images: ghcr.io/${{ github.repository_owner }}/crowdsec-mcp + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=sha,format=short,prefix=sha- + type=match,pattern=^crowdsec-mcp/v(.*)$,group=1 + type=match,pattern=^v(.*)$,group=1 + + - name: Build and push + id: build + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 + with: + context: . + file: packages/crowdsec-mcp/Dockerfile + platforms: ${{ github.event_name == 'pull_request' && 'linux/amd64' || 'linux/amd64,linux/arm64' }} + push: ${{ github.event_name != 'pull_request' }} + load: ${{ github.event_name == 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + pull: true + provenance: ${{ github.event_name != 'pull_request' }} + sbom: ${{ github.event_name != 'pull_request' }} + cache-from: type=gha,scope=crowdsec-mcp + cache-to: type=gha,mode=max,scope=crowdsec-mcp + + - name: Smoke test + if: github.event_name == 'pull_request' + env: + IMAGE: ${{ fromJSON(steps.meta.outputs.json).tags[0] }} + run: docker run --rm "$IMAGE" --version diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index 0d252b653..9d393fb00 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -18,26 +18,26 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Install Node.js - uses: actions/setup-node@v7 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 with: node-version: lts/* - - uses: pnpm/action-setup@v6 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 name: Install pnpm id: pnpm-install with: - run_install: true + run_install: false - name: Get pnpm store directory id: pnpm-cache shell: bash run: | - echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + echo "STORE_PATH=$(pnpm store path)" >> "$GITHUB_OUTPUT" - - uses: actions/cache@v6 + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 name: Setup pnpm cache with: path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} @@ -47,16 +47,19 @@ jobs: - name: Install dependencies run: | - pnpm install - pnpm recursive run build + pnpm install --frozen-lockfile + pnpm -r run build - name: build documentation run: | pnpm run generate:documentation env: CI: true + + - name: Copy llms.txt + run: cp llms.txt docs-typedoc/llms.txt - name: Deploy - uses: peaceiris/actions-gh-pages@v4 + uses: peaceiris/actions-gh-pages@84c30a85c19949d7eee79c4ff27748b70285e453 # v4 with: github_token: ${{ secrets.GITHUB_TOKEN }} publish_dir: ./docs-typedoc diff --git a/.github/workflows/mcp-http-e2e.yml b/.github/workflows/mcp-http-e2e.yml new file mode 100644 index 000000000..86079837d --- /dev/null +++ b/.github/workflows/mcp-http-e2e.yml @@ -0,0 +1,48 @@ +name: MCP HTTP E2E + +on: + pull_request: + paths: + - "packages/crowdsec-mcp/**" + - "packages/crowdsec-client/**" + - ".github/scripts/**" + - ".github/workflows/mcp-http-e2e.yml" + push: + branches: [main] + paths: + - "packages/crowdsec-mcp/**" + - "packages/crowdsec-client/**" + - ".github/scripts/**" + - ".github/workflows/mcp-http-e2e.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + http-e2e: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Install Node.js + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 + with: + node-version: lts/* + + - name: Install pnpm + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + with: + run_install: false + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run HTTP MCP E2E + run: bash .github/scripts/mcp-http-e2e.sh + + - name: Cleanup + if: always() + run: docker compose -p crowdsec-mcp-e2e -f packages/crowdsec-mcp/tests/e2e/http/compose.yaml down -v --remove-orphans || true diff --git a/.github/workflows/mcp-scan.yml b/.github/workflows/mcp-scan.yml new file mode 100644 index 000000000..3e3bf90a4 --- /dev/null +++ b/.github/workflows/mcp-scan.yml @@ -0,0 +1,57 @@ +name: MCP security scan + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + scan: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Install Node.js + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 + with: + node-version: lts/* + + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + name: Install pnpm + with: + run_install: false + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build MCP server + run: pnpm -F crowdsec-mcp build + + - name: Create MCP config + run: | + cat > mcp.json <<'JSON' + { + "mcpServers": { + "crowdsec": { + "command": "node", + "args": ["packages/crowdsec-mcp/dist/index.js"], + "env": { + "CROWDSEC_URL": "http://127.0.0.1:8080", + "CROWDSEC_AUTH": "bouncer", + "CROWDSEC_API_KEY": "dummy" + } + } + } + } + JSON + + - name: Install uv + uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 + + - name: Scan MCP server + run: uvx --no-build snyk-agent-scan==0.6.9 scan mcp.json --ci --dangerously-run-mcp-servers --no-skills + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 97cd9d2e8..ca4952ed5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,55 +20,59 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Install Node.js - uses: actions/setup-node@v7 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 with: node-version: lts/* + package-manager-cache: false - - uses: pnpm/action-setup@v6 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 name: Install pnpm id: pnpm-install with: - run_install: true + run_install: false - name: Get pnpm store directory id: pnpm-cache shell: bash run: | - echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + echo "STORE_PATH=$(pnpm store path)" >> "$GITHUB_OUTPUT" - - uses: actions/cache@v6 + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 name: Setup pnpm cache with: path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-pnpm-store- + lookup-only: true - name: Install dependencies - run: pnpm install + run: pnpm install --frozen-lockfile list-packages: runs-on: ubuntu-latest outputs: matrix: ${{ steps.set-matrix.outputs.matrix }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Install Node.js - uses: actions/setup-node@v7 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 with: node-version: lts/* - - uses: pnpm/action-setup@v6 + package-manager-cache: false + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 name: Install pnpm id: pnpm-install with: - run_install: true + run_install: false - id: set-matrix run: | - export MATRIX=$(pnpm -s m ls --depth -1 -F "./packages/**" --json | jq -c) - echo "matrix=${MATRIX}" | tee -a $GITHUB_OUTPUT + MATRIX=$(pnpm -s m ls --depth -1 -F "./packages/**" --json | jq -c) + export MATRIX + echo "matrix=${MATRIX}" | tee -a "$GITHUB_OUTPUT" # This workflow contains a single job called "build" build: @@ -87,40 +91,42 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Install Node.js - uses: actions/setup-node@v7 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 with: node-version: lts/* + package-manager-cache: false registry-url: 'https://registry.npmjs.org' - - uses: pnpm/action-setup@v6 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 name: Install pnpm id: pnpm-install with: - run_install: true + run_install: false - name: Get pnpm store directory id: pnpm-cache shell: bash run: | - echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + echo "STORE_PATH=$(pnpm store path)" >> "$GITHUB_OUTPUT" - - uses: actions/cache@v6 + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 name: Setup pnpm cache with: path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-pnpm-store- + lookup-only: true - name: Install dependencies - run: pnpm install + run: pnpm install --frozen-lockfile shell: bash - name: Compare npm with local (versions) id: package_version - uses: actions/github-script@v9 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 with: script: | const fs = require('fs'); diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 000000000..178fce1ca --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,44 @@ +name: Scorecard supply-chain security + +on: + branch_protection_rule: + schedule: + - cron: '30 1 * * 6' + push: + branches: [ main ] + +permissions: + contents: read + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-latest + permissions: + security-events: write + id-token: write + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Run OpenSSF Scorecard + uses: ossf/scorecard-action@fa7117db83c52d787ae5a89f350999701505d78e # v2.5.0 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + + - name: Upload SARIF + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: scorecard-results + path: results.sarif + retention-days: 5 + + - name: Upload to code scanning + uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4 + with: + sarif_file: results.sarif diff --git a/.gitignore b/.gitignore index ee0917f70..b2f7a52bf 100644 --- a/.gitignore +++ b/.gitignore @@ -196,6 +196,9 @@ packages/node-crowdsec-client/.env # vuepress build output .vuepress/dist +# monorepo package build output +/packages/crowdsec-mcp/dist + # Serverless directories .serverless diff --git a/.sonarcloud.properties b/.sonarcloud.properties new file mode 100644 index 000000000..0cb5a64d2 --- /dev/null +++ b/.sonarcloud.properties @@ -0,0 +1 @@ +sonar.python.version=3.10 diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 000000000..3cd16a23d --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,18 @@ +cff-version: 1.2.0 +message: "If you use this software, please cite it as below." +title: "node-crowdsec" +abstract: "Node.js CrowdSec client, HTTP middleware, detection scenarios and MCP server." +type: software +authors: + - family-names: "Severac" + given-names: "Thibaut" + email: "thib3113@gmail.com" +repository-code: "https://github.com/thib3113/node-crowdsec" +url: "https://thib3113.github.io/node-crowdsec/" +license: MIT +keywords: + - crowdsec + - security + - bouncer + - watcher + - lapi diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 6aff8707f..8e471f337 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -60,7 +60,7 @@ representative at an online or offline event. Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at -thib3113@gmail.com. +. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the @@ -116,7 +116,7 @@ the community. This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at -https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. +. Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). @@ -124,5 +124,5 @@ enforcement ladder](https://github.com/mozilla/diversity). [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see the FAQ at -https://www.contributor-covenant.org/faq. Translations are available at -https://www.contributor-covenant.org/translations. +. Translations are available at +. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..28215de97 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,67 @@ +# Contributing + +Thanks for contributing to node-crowdsec. + +## Requirements + +- Node.js >= 24 +- pnpm >= 12 (the version is pinned in `packageManager`) + +## Setup + +```bash +git clone https://github.com/thib3113/node-crowdsec.git +cd node-crowdsec +pnpm install +pnpm -r build +``` + +## Repository layout + +| Path | Purpose | +| --- | --- | +| `packages/crowdsec-client` | CrowdSec LAPI client (bouncer + watcher) | +| `packages/crowdsec-http-middleware` | HTTP bouncer/watcher middleware | +| `packages/crowdsec-client-scenarios` | Detection scenarios for the middleware | +| `packages/crowdsec-mcp` | Model Context Protocol server | +| `examples/` | Runnable examples | +| `docs/` | Guides and architecture decision records | + +## Common commands + +```bash +# build one package +pnpm -F crowdsec-client build + +# test one package +pnpm -F crowdsec-client test + +# coverage +pnpm -F crowdsec-client test:ci:coverage + +# lint +pnpm -F crowdsec-client lint + +# run an example +pnpm --filter example-express-bouncer debug +``` + +Run the tests and lint for every package you touch before opening a pull request. + +## Pull requests + +- Keep changes focused; one topic per pull request when possible. +- Add or update tests for behavior changes. +- Update the package README and `package.json` metadata when the public API or + configuration changes. +- Do not commit secrets, tokens or `.env` files. + +## Security and conduct + +- Report vulnerabilities privately: see [SECURITY.md](./SECURITY.md). +- This project follows the [Contributor Covenant](./CODE_OF_CONDUCT.md). + +## License + +By contributing, you agree that your contributions are licensed under the +[MIT License](./LICENSE). diff --git a/SECURITY.md b/SECURITY.md index f084a133e..4afbcc16b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,16 +2,26 @@ ## Supported Versions -As node-crowdsec is a community-driven open source project, and our resources are limited -we accept security fixes only on the latest released version and also on the sources published -in the main branch of this GitHub project. +Security fixes are provided for the latest released version and for the `main` +branch. Older versions are not supported. ## Reporting a Vulnerability -If you found a security-related vulnerability, please feel free to report this security problem -using our valuable issue tracker on GitHub. Furthermore, please acknowledge that we will try -to respond to your security reports as fast as possible and also please make sure to provide proper -examples (e.g. example source code, step-by-step explanations) on how to reproduce the security -issue so that we can reproduce and fix this issue as fas as possible. +**Please do not open a public issue for a security vulnerability.** -Please add the label "security" and the label of the project concerned +Use GitHub's private vulnerability reporting: + + + +If private reporting is unavailable, contact the maintainer at +[thib3113@gmail.com](mailto:thib3113@gmail.com). + +Please include: + +- the affected package and version; +- steps to reproduce, with a minimal example; +- the impact you expect; +- a possible fix, if you have one. + +You will get an initial response as soon as possible. Please allow time for a +fix before public disclosure. diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 000000000..6fb34c459 --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,31 @@ +# Support + +## Documentation + +- Guides and usage documentation (wiki): +- API reference (Typedoc): +- Package READMEs: [`crowdsec-client`](./packages/crowdsec-client), + [`crowdsec-http-middleware`](./packages/crowdsec-http-middleware), + [`crowdsec-client-scenarios`](./packages/crowdsec-client-scenarios), + [`crowdsec-mcp`](./packages/crowdsec-mcp) +- Runnable examples: [`examples/`](./examples) +- AI agent setup guide: [`docs/agent-setup-guide.md`](./docs/agent-setup-guide.md) + +## Questions and bug reports + +Open an issue on GitHub: + + + +Use the bug report or feature request template. Please include the package and +version, your Node.js version, and the steps to reproduce. + +## Security + +Do not open a public issue for a vulnerability. See [SECURITY.md](./SECURITY.md) +for private reporting. + +## Commercial support + +No commercial support is offered. The project is maintained on a best-effort +basis. diff --git a/dev/raw/package.json b/dev/raw/package.json index 80e96510b..2866343fc 100644 --- a/dev/raw/package.json +++ b/dev/raw/package.json @@ -1,16 +1,23 @@ { "name": "dev", "version": "1.0.0", - "description": "", + "private": true, + "description": "Local development playground for node-crowdsec packages", "main": "build/index.js", "type": "module", "scripts": { "debug": "npm run build:deps && tsc && node -r source-map-support/register ./build/index.js", "build:deps": "pnpm -F dev... build" }, - "keywords": [], - "author": "", - "license": "ISC", + "keywords": [ + "crowdsec", + "development" + ], + "author": { + "email": "thib3113@gmail.com", + "name": "Thibaut Severac" + }, + "license": "MIT", "dependencies": { "@types/node": "26.6.5", "crowdsec-client": "workspace:^", diff --git a/docs/adr/0002-bouncer-decision-store.md b/docs/adr/0002-bouncer-decision-store.md index 4280358f0..5544da020 100644 --- a/docs/adr/0002-bouncer-decision-store.md +++ b/docs/adr/0002-bouncer-decision-store.md @@ -1,4 +1,4 @@ -# ADR-0002 : Bouncer decision store โ€” numeric prefix index with walk-up lookup +# ADR-0002 : Bouncer decision store - numeric prefix index with walk-up lookup - **Status** : Accepted - **Date** : 2026-08-16 @@ -17,7 +17,7 @@ The initial implementation iterated over every decision per lookup: ); ``` -With 50k decisions this cost ~90โ€“185 ms per lookup (measured) โ€” unacceptable. +With 50k decisions this cost ~90-185 ms per lookup (measured) - unacceptable. ### Distribution of real-world decision sets @@ -27,17 +27,17 @@ Before optimizing, we reasoned about the *actual* data: compromised hosts). This is the dominant case. - Larger blocks follow the hierarchy of who owns them: - **/32** : individual attacker / single compromised machine. - - **/24 โ€“ /16** : an ISP, a hosting/cloud provider block (e.g. AWS, OVH, + - **/24 - /16** : an ISP, a hosting/cloud provider block (e.g. AWS, OVH, a country-wide telecom). A provider block can be flagged when it hosts abusive customers, but this is far rarer than single IPs. - - **/16 โ€“ /8** : large national/regional allocations. In practice almost never + - **/16 - /8** : large national/regional allocations. In practice almost never malicious; would only make sense for an entire hostile country (state - adversary) โ€” an extreme edge case. + adversary) - an extreme edge case. - **Cross-boundary ranges** (a single CIDR whose prefix does not align to the top octet, e.g. `0.0.0.0/7` covering `0.x` and `1.x`) are effectively fiction: no realistic actor has a malicious block that straddles the top-level boundary. The previous bucketing (`parsedAddress[0]`) silently failed for such ranges, - which is acceptable in practice โ€” but our new design handles them correctly for + which is acceptable in practice - but our new design handles them correctly for free, so the limitation disappears. ### IPv6 note @@ -49,7 +49,7 @@ come from IPv4. IPv6 support is kept for correctness (and for IPv4-mapped sockets, see the `getIpObject` normalization), but it is not an optimization target. -This is unlikely to change soon: a successor protocol was announced โ€” **IPv8** +This is unlikely to change soon: a successor protocol was announced - **IPv8** (IETF Internet-Draft `draft-thain-ipv8`, April 2026) proposes 64-bit addresses with full IPv4 backward compatibility and no forced migration. IPv8 may well arrive before malicious IPv6 ever becomes a significant workload, so @@ -82,7 +82,7 @@ walk-up index is simpler and faster for this distribution: of decisions for that exact network. - A lookup iterates the **active** mask lengths, most specific first (typically `[/32, /24, /16, /8]`), and stops at the first hit. -- A /32 hit therefore costs a **single Map lookup** (~30โ€“60 ns measured, +- A /32 hit therefore costs a **single Map lookup** (~30-60 ns measured, ~0.9 ยตs per full 50k-lookup when including cache reads). ```ts @@ -90,7 +90,7 @@ walk-up index is simpler and faster for this distribution: const key = ip >>> (32 - mask); // ipv4 ; or ip >> BigInt(128 - mask) for ipv6 masks.get(mask).get(key).push(decision); -// lookup โ€” walk up, most specific first +// lookup - walk up, most specific first for (const mask of activeMasks) { const hit = masks.get(mask)?.get(ip >>> (32 - mask)); if (hit) return hit[0]; @@ -104,15 +104,15 @@ limit matching to masks `>= N` (e.g. ignore country-sized `/8` decisions to avoi over-blocking). This is exposed as the `subnetLevel` option on the bouncer middleware, with three presets: -- `resident` โ†’ only `/32` (an individual attacker); -- `company` โ†’ up to `/24` (an ISP / hosting block); **default**; -- `country` โ†’ up to `/16` (a national block). +- `resident` -> only `/32` (an individual attacker); +- `company` -> up to `/24` (an ISP / hosting block); **default**; +- `country` -> up to `/16` (a national block). The filter is applied **at insert time** in `addDecision`: decisions whose mask is smaller than the configured level are never stored. Because the middleware configuration is immutable after construction (no setters, like every other option: `pollingInterval`, `maxIpCache`...), changing `subnetLevel` requires -**restarting the middleware** โ€” this is not a new constraint, it is the existing +**restarting the middleware** - this is not a new constraint, it is the existing behavior for all options. Insert-time filtering keeps the index small and `decisionsCount` accurate. @@ -125,12 +125,12 @@ optional **live mode** (`live.enabled`) checks unknown IPs against the LAPI - On a local miss, the current request **always passes** (the check runs in the background, never blocking the request path). - If the LAPI says the IP is banned, the decision is injected through the **same - `addDecision` path** as the stream (deduplicated by `value:type`) โ†’ the *next* + `addDecision` path** as the stream (deduplicated by `value:type`) -> the *next* request from that IP is blocked. A malicious unknown IP passes once, never twice. - If the IP is clean, the verdict is cached (`cleanCache`, LRU + TTL, default 60s) so the LAPI is not re-queried for returning visitors. - **Thundering herd** : pending live checks are deduplicated per IP and capped - globally (`live.maxConcurrentChecks`, default 100) โ€” 10k concurrent requests + globally (`live.maxConcurrentChecks`, default 100) - 10k concurrent requests for the same unknown IP trigger a single LAPI call. - **Failure behavior** is configurable : `failOpen` (default) caches the failure for a short backoff (`live.errorBackoffTtl`, 10s) so a down LAPI is not hammered ; @@ -161,7 +161,7 @@ backward compatibility with the pure stream mode. ### Limits -- Insertion is slower than lookup (~50k inserts โ‰ˆ 0.8โ€“1.2 s on first load). This +- Insertion is slower than lookup (~50k inserts ~ 0.8-1.2 s on first load). This happens once, when the stream arrives, and is not on the request path. - `subnetLevel` is fixed at construction : changing it requires restarting the middleware (consistent with all other options). @@ -169,7 +169,7 @@ backward compatibility with the pure stream mode. ## Alternatives considered -- **Full linear scan with `isInSubnet`** (previous implementation): 90โ€“185 ms +- **Full linear scan with `isInSubnet`** (previous implementation): 90-185 ms per lookup. Rejected. - **Binary prefix trie**: correct and fast (~240 ns), but more complex (node allocation, WeakMap chunk cache) for the same result as the walk-up index. diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 000000000..84a24dc02 --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,5 @@ +# Architecture Decision Records + +| ADR | Title | Status | +| --- | --- | --- | +| [0002](./0002-bouncer-decision-store.md) | Bouncer decision store - numeric prefix index with walk-up lookup | Accepted | diff --git a/docs/agent-setup-guide.md b/docs/agent-setup-guide.md index 9aac4dac8..6f79d1c57 100644 --- a/docs/agent-setup-guide.md +++ b/docs/agent-setup-guide.md @@ -1,4 +1,4 @@ -# AI Agent Setup Guide โ€” CrowdSec HTTP Middleware +# AI Agent Setup Guide - CrowdSec HTTP Middleware Give this guide to your AI agent when you want to configure the `crowdsec-http-middleware` bouncer. It first helps the user decide whether this @@ -13,22 +13,22 @@ simpler alternative instead of configuring anyway. --- -## Step 0 โ€” Do you even need this library ? +## Step 0 - Do you even need this library ? Before configuring anything, help the user decide whether `node-crowdsec` is the right tool. Ask **one** of these questions first : ### 0.1 What are you actually trying to do ? -- **"Block a few bad IPs / a small static blocklist"** โ†’ you probably do **not** +- **"Block a few bad IPs / a small static blocklist"** -> you probably do **not** need a CrowdSec bouncer at all. A firewall rule (fail2ban, iptables), a WAF (Cloudflare, ModSecurity) or a small hardcoded list is simpler. - **"Protect my web server against scanners / brute-force / malicious traffic, - with community-sourced signals"** โ†’ CrowdSec is a good fit. Continue. + with community-sourced signals"** -> CrowdSec is a good fit. Continue. - **"I already run a CrowdSec LAPI and want my Node.js app to use its - decisions"** โ†’ this library (or the official + decisions"** -> this library (or the official `@crowdsec/nodejs-bouncer`) is the right tool. Continue. -- **"Block at the network level (firewall, fail2ban)"** โ†’ CrowdSec's own +- **"Block at the network level (firewall, fail2ban)"** -> CrowdSec's own bouncers (cs-firewall-bouncer, etc.) are more appropriate than a Node.js in-app bouncer. @@ -40,7 +40,7 @@ whether that fits : - **Good fit** : Node.js server, you want fine-grained control, or you cannot install a system-level bouncer. -- **Key advantage โ€” you stay in control of the response.** A network-level +- **Key advantage - you stay in control of the response.** A network-level bouncer (firewall / nginx / CDN) cuts the request before it reaches your app : the visitor gets a generic 403 and that's it. With this library the request still reaches your code and `req.decision` tells you *why* (`ban`, `captcha`, @@ -53,22 +53,22 @@ whether that fits : - different status codes per decision type. This is impossible (or very awkward) with a firewall / reverse-proxy bouncer. - **Poor fit** : you have a reverse proxy / CDN in front (nginx, Traefik, - Cloudflare) that could block earlier at the network layer โ€” blocking there is + Cloudflare) that could block earlier at the network layer - blocking there is more efficient and keeps the load off your app. Use CrowdSec's nginx/caddy/cloudflare bouncers instead, **unless** you need the app-level - control above (captcha, custom page) โ€” in which case keep this library. + control above (captcha, custom page) - in which case keep this library. ### 0.3 This library vs the official `@crowdsec/nodejs-bouncer` ? Both are Node.js bouncers. Rough comparison to present to the user : | | `@crowdsec/nodejs-bouncer` (official) | `crowdsec-http-middleware` (this repo) | -|---|---|---| +| --- | --- | --- | | Default mode | live (one LAPI call per new IP, then cache) | stream (local index, ~ยตs/request) | | Freshness | immediate on cache miss | bounded by `pollingInterval` | | LAPI load | grows with unique IPs | constant (one poll per interval) | -| Live mode option | โ€” | yes, on top of the stream | -| `subnetLevel` | โ€” | yes (avoid banning whole countries) | +| Live mode option | - | yes, on top of the stream | +| `subnetLevel` | - | yes (avoid banning whole countries) | | Watcher / scenarios | no | yes (detect & report) | | Typed events / fine config | basic | extensive | @@ -107,7 +107,7 @@ The important behavioral levers are : ### 2. How do you want to detect malicious IPs ? > **Recommended** : stream mode (default). It is fast, cheap on the LAPI, and -> the freshness gap (โ‰ค polling interval) is acceptable for almost everyone. +> the freshness gap (<= polling interval) is acceptable for almost everyone. - **Stream mode** (recommended) : the bouncer pulls decisions every `pollingInterval` (default 10 s) and matches locally. Zero LAPI load per @@ -119,7 +119,7 @@ The important behavioral levers are : and the LAPI can absorb the load. Ask : is it acceptable that a brand-new ban takes up to `pollingInterval` -(10 s default) to apply ? If no โ†’ enable live mode. +(10 s default) to apply ? If no -> enable live mode. ### 3. What is the largest block you consider malicious ? @@ -127,7 +127,7 @@ Ask : is it acceptable that a brand-new ban takes up to `pollingInterval` Real-world malicious traffic is almost always single IPs (`/32`). Larger blocks follow who owns them : an abusive datacenter rack is a `/24`, an entire national -block is `/8` โ€” banning a whole country is almost never intended. +block is `/8` - banning a whole country is almost never intended. - `resident` : only single IPs (`/32`). Most restrictive on false positives. - `company` (recommended) : up to `/24` (an ISP / hosting provider block). @@ -162,15 +162,17 @@ which header holds the real client IP ? freshness over protecting the LAPI. Ask (only if live mode is on) : if the LAPI is temporarily unreachable, is it -acceptable that requests pass silently ? If yes โ†’ `failOpen`, if no โ†’ +acceptable that requests pass silently ? If yes -> `failOpen`, if no -> `failFast` and a shorter backoff. ### 7. Watcher (optional) - Do you want to *detect and report* malicious requests to the LAPI (not just block) ? If yes, a watcher with scenarios is needed. -- Which default scenarios ? (`crowdsec-client-scenarios` provides user-agent - based ones). Or custom scenarios ? +- Which scenarios ? If `crowdsec-client-scenarios` is installed, omitting the + `scenarios` option loads the defaults (allowlist, X-Forwarded-For, HTTP + enrichment). `scenarios: []` disables them. Custom constructors can be passed + explicitly. Ask : do you need the application to *report* suspicious requests (watcher), or only to *block* flagged IPs (bouncer) ? @@ -218,7 +220,8 @@ const middlewareOptions: ICrowdSecHTTPMiddlewareOptions = { machineID: process.env.CROWDSEC_WATCHER_MACHINE_ID || '', password: process.env.CROWDSEC_WATCHER_PASSWORD || '', heartbeat: true, - scenarios: [] // default scenarios + // omit `scenarios` to load the default scenarios; + // `scenarios: []` disables them } }; ``` @@ -226,7 +229,7 @@ const middlewareOptions: ICrowdSecHTTPMiddlewareOptions = { ### Defaults summary (when the user has no opinion) | Option | Default | Why | -|---|---|---| +| --- | --- | --- | | `pollingInterval` | 10 s | reasonable freshness for stream mode | | `subnetLevel` | `company` (/24) | real attackers are single IPs, not countries | | `live.enabled` | `false` | stream mode is enough for most deployments | @@ -236,6 +239,7 @@ const middlewareOptions: ICrowdSecHTTPMiddlewareOptions = { | `live.maxConcurrentChecks` | 100 | avoids thundering herd on the LAPI | | `live.errorBackoffTtl` | 10 s | fail-open backoff | | `live.watchdog` | `true` | safety net if the LAPI stays down | +| `watcher.scenarios` | omitted | loads the default scenarios when the package is installed | --- @@ -247,5 +251,5 @@ const middlewareOptions: ICrowdSecHTTPMiddlewareOptions = { - `subnetLevel` is fixed at construction time : changing it requires restarting. - Live mode is **not** synchronous : the current request always passes, the ban applies from the *next* request. Do not promise an immediate 403 to the user. -- Do not enable live mode if the LAPI cannot absorb `new-IPs-per-second` ร— 1 +- Do not enable live mode if the LAPI cannot absorb `new-IPs-per-second` x 1 request. diff --git a/examples/express-bouncer-cjs/package.json b/examples/express-bouncer-cjs/package.json index 73f47cea9..a56e88008 100644 --- a/examples/express-bouncer-cjs/package.json +++ b/examples/express-bouncer-cjs/package.json @@ -1,14 +1,24 @@ { "name": "example-express-bouncer-cjs", "version": "1.0.0", - "description": "", + "private": true, + "description": "Example CrowdSec bouncer protecting an Express server (CommonJS)", "main": "src/index.js", "scripts": { "start": "node -r source-map-support/register ./src/index.js" }, - "keywords": [], - "author": "", - "license": "ISC", + "keywords": [ + "crowdsec", + "bouncer", + "express", + "commonjs", + "example" + ], + "author": { + "email": "thib3113@gmail.com", + "name": "Thibaut Severac" + }, + "license": "MIT", "dependencies": { "crowdsec-client": "workspace:^", "express": "^5.3.0" diff --git a/examples/express-bouncer-cjs/readme.md b/examples/express-bouncer-cjs/readme.md index 7c234e939..ac107c86f 100644 --- a/examples/express-bouncer-cjs/readme.md +++ b/examples/express-bouncer-cjs/readme.md @@ -1 +1,42 @@ -Same as the express-bouncer example . But using CommonJS (nodejs, without the type module) +# express-bouncer-cjs + +The same as [express-bouncer](../express-bouncer), written in CommonJS instead of +ESM. It shows a `crowdsec-client` bouncer protecting an Express server. + +## Requirements + +- Node.js >= 24 +- A CrowdSec LAPI with a bouncer API key + +## Setup + +From the repository root: + +```bash +pnpm install +pnpm --filter example-express-bouncer-cjs start +``` + +Or from this directory: + +```bash +pnpm start +``` + +## Environment + +Create a `.env` file (or export the variables) with: + +```dotenv +CROWDSEC_URL=http://localhost:8080 +CROWDSEC_API_KEY=your-bouncer-key +``` + +The example also injects a fake `127.0.0.1` ban (`ENABLE_FAKE_DECISION` in +`src/index.js`) so you can test without a real decision. The server listens on +`http://127.0.0.1:3000`. + +## What it shows + +- the same bouncer flow as `express-bouncer`, without `"type": "module"` +- `require('crowdsec-client')` instead of ESM imports diff --git a/examples/express-bouncer/package.json b/examples/express-bouncer/package.json index 81fd7bfb8..2fd7670ea 100644 --- a/examples/express-bouncer/package.json +++ b/examples/express-bouncer/package.json @@ -1,15 +1,24 @@ { "name": "example-express-bouncer", "version": "1.0.0", - "description": "", + "private": true, + "description": "Example CrowdSec bouncer protecting an Express server (TypeScript, ESM)", "main": "build/index.js", "type": "module", "scripts": { "debug": "tsc && node -r source-map-support/register ./build/index.js" }, - "keywords": [], - "author": "", - "license": "ISC", + "keywords": [ + "crowdsec", + "bouncer", + "express", + "example" + ], + "author": { + "email": "thib3113@gmail.com", + "name": "Thibaut Severac" + }, + "license": "MIT", "dependencies": { "crowdsec-client": "workspace:^", "express": "^5.3.0" diff --git a/examples/express-bouncer/readme.md b/examples/express-bouncer/readme.md index 9f37647a2..ac93ac052 100644 --- a/examples/express-bouncer/readme.md +++ b/examples/express-bouncer/readme.md @@ -1,3 +1,43 @@ -This example will create a basic http express server, and filter ips from crowdsec ban list . +# express-bouncer -This is a very light example, you can try to use other scopes, checking ip range banned ... +A minimal Express server protected by the `crowdsec-client` bouncer. It streams +decisions from the CrowdSec LAPI and rejects banned IPs. + +## Requirements + +- Node.js >= 24 +- A CrowdSec LAPI with a bouncer API key + +## Setup + +From the repository root: + +```bash +pnpm install +pnpm --filter example-express-bouncer debug +``` + +Or from this directory: + +```bash +pnpm debug +``` + +## Environment + +Create a `.env` file (or export the variables) with: + +```dotenv +CROWDSEC_URL=http://localhost:8080 +CROWDSEC_API_KEY=your-bouncer-key +``` + +The example also injects a fake `127.0.0.1` ban (`ENABLE_FAKE_DECISION` in +`src/index.ts`) so you can test without a real decision. The server listens on +`http://127.0.0.1:3000`. + +## What it shows + +- creating and logging in a `BouncerClient` +- consuming `client.Decisions.getStream()` +- rejecting a request when its IP has a decision diff --git a/examples/express-web-parser/package.json b/examples/express-web-parser/package.json index 6ac8367c7..e6d7f2856 100644 --- a/examples/express-web-parser/package.json +++ b/examples/express-web-parser/package.json @@ -1,15 +1,24 @@ { - "name": "example-express-parser", + "name": "example-express-web-parser", "version": "1.0.0", - "description": "", + "private": true, + "description": "Example CrowdSec watcher that reports malicious user agents", "main": "build/index.js", "type": "module", "scripts": { "debug": "tsc && node -r source-map-support/register ./build/index.js" }, - "keywords": [], - "author": "", - "license": "ISC", + "keywords": [ + "crowdsec", + "watcher", + "express", + "example" + ], + "author": { + "email": "thib3113@gmail.com", + "name": "Thibaut Severac" + }, + "license": "MIT", "dependencies": { "crowdsec-client": "workspace:^", "express": "^5.3.0" diff --git a/examples/express-web-parser/readme.md b/examples/express-web-parser/readme.md index 7090fa1fe..1c1b8ae97 100644 --- a/examples/express-web-parser/readme.md +++ b/examples/express-web-parser/readme.md @@ -1,3 +1,42 @@ -This example will create a basic http express server, and automatically ban a user using a bad user-agent . +# express-web-parser -This is a very light example, you can try to use other scopes, checking ip range banned ... +A watcher example: an Express server that reports an alert to CrowdSec when it +sees a known bad user-agent. + +## Requirements + +- Node.js >= 24 +- A CrowdSec LAPI with a watcher machine ID and password + +## Setup + +From the repository root: + +```bash +pnpm install +pnpm --filter example-express-web-parser debug +``` + +Or from this directory: + +```bash +pnpm debug +``` + +## Environment + +The example reads the `.env` file at the repository root (`../../.env`). Set: + +```dotenv +CROWDSEC_URL=http://localhost:8080 +CROWDSEC_MACHINE_ID=your-machine-id +CROWDSEC_PASSWORD=your-password +``` + +The server listens on `http://127.0.0.1:3000`. + +## What it shows + +- creating and logging in a `WatcherClient` +- pushing an alert with `client.Alerts.pushAlerts()` +- matching a request user-agent against `src/badUserAgents.ts` diff --git a/llms.txt b/llms.txt new file mode 100644 index 000000000..bfb6779dd --- /dev/null +++ b/llms.txt @@ -0,0 +1,43 @@ +# node-crowdsec + +> Node.js client for the CrowdSec Local API (LAPI), with an HTTP bouncer/watcher middleware, detection scenarios and a Model Context Protocol (MCP) server. + +## Packages + +- [crowdsec-client](https://github.com/thib3113/node-crowdsec/blob/main/packages/crowdsec-client/README.md): CrowdSec LAPI client for bouncers and watchers (decisions, alerts, allowlists, streams, TLS/mTLS). +- [crowdsec-http-middleware](https://github.com/thib3113/node-crowdsec/blob/main/packages/crowdsec-http-middleware/readme.md): HTTP middleware that checks each request's IP against CrowdSec decisions and exposes `req.decision`. +- [crowdsec-client-scenarios](https://github.com/thib3113/node-crowdsec/blob/main/packages/crowdsec-client-scenarios/readme.md): optional detection scenarios (X-Forwarded-For, allowlist, HTTP enrichment, MaxMind). +- [crowdsec-mcp](https://github.com/thib3113/node-crowdsec/blob/main/packages/crowdsec-mcp/README.md): Model Context Protocol server exposing decisions, alerts and allowlists to an LLM. + +## Documentation + +- [Guides and usage documentation](https://github.com/thib3113/node-crowdsec/wiki): authentication, bouncer/watcher setup, performance notes. +- [API reference](https://thib3113.github.io/node-crowdsec/): generated Typedoc API documentation. +- [AI agent setup guide](https://github.com/thib3113/node-crowdsec/blob/main/docs/agent-setup-guide.md): configure the HTTP middleware with an AI agent. +- [Architecture decisions](https://github.com/thib3113/node-crowdsec/blob/main/docs/adr/README.md): ADRs, including the bouncer decision store. +- [Security policy](https://github.com/thib3113/node-crowdsec/blob/main/SECURITY.md): private vulnerability reporting. + +## Examples + +- [express-bouncer](https://github.com/thib3113/node-crowdsec/tree/main/examples/express-bouncer): Express + bouncer (TypeScript, ESM). +- [express-bouncer-cjs](https://github.com/thib3113/node-crowdsec/tree/main/examples/express-bouncer-cjs): the same example in CommonJS. +- [express-web-parser](https://github.com/thib3113/node-crowdsec/tree/main/examples/express-web-parser): watcher that reports bad user-agents. + +## Repository + +- [README](https://github.com/thib3113/node-crowdsec/blob/main/readme.md): project overview and features. +- [Contributing](https://github.com/thib3113/node-crowdsec/blob/main/CONTRIBUTING.md) +- [Support](https://github.com/thib3113/node-crowdsec/blob/main/SUPPORT.md) +- [License (MIT)](https://github.com/thib3113/node-crowdsec/blob/main/LICENSE) + +## Source layout + +- [packages/](https://github.com/thib3113/node-crowdsec/tree/main/packages): npm packages. +- [examples/](https://github.com/thib3113/node-crowdsec/tree/main/examples): runnable examples. +- [docs/](https://github.com/thib3113/node-crowdsec/tree/main/docs): guides and ADRs. + +## Optional + +- [npm: crowdsec-client](https://www.npmjs.com/package/crowdsec-client) +- [npm: crowdsec-http-middleware](https://www.npmjs.com/package/crowdsec-http-middleware) +- [npm: crowdsec-client-scenarios](https://www.npmjs.com/package/crowdsec-client-scenarios) diff --git a/package.json b/package.json index 15ebb4e43..b85af6d24 100644 --- a/package.json +++ b/package.json @@ -1,23 +1,42 @@ { - "name": "node-crowdsec-client-root", + "name": "node-crowdsec", + "private": true, + "description": "Monorepo for the Node.js CrowdSec client, HTTP middleware and scenarios", + "repository": { + "type": "git", + "url": "https://github.com/thib3113/node-crowdsec.git" + }, + "homepage": "https://github.com/thib3113/node-crowdsec#readme", + "bugs": { + "url": "https://github.com/thib3113/node-crowdsec/issues" + }, + "author": { + "email": "thib3113@gmail.com", + "name": "Thibaut Severac" + }, "engines": { "node": ">=24", "pnpm": ">=9" }, "license": "UNLICENSED", "scripts": { - "generate:documentation": "typedoc" + "format:check": "prettier --check \"packages/*/src/**/*.ts\" \"examples/*/src/**/*.ts\" \"dev/**/*.ts\"", + "generate:documentation": "typedoc", + "lint:packages": "publint packages/crowdsec-client && publint packages/crowdsec-http-middleware && publint packages/crowdsec-client-scenarios && publint packages/crowdsec-mcp", + "lint:types": "attw --pack packages/crowdsec-client && attw --pack packages/crowdsec-http-middleware && attw --pack packages/crowdsec-client-scenarios && attw --pack packages/crowdsec-mcp", + "typecheck": "pnpm -F \"./packages/**\" exec tsc --noEmit" }, "devDependencies": { + "@arethetypeswrong/cli": "0.18.5", "@eslint/eslintrc": "3.3.7", "@eslint/js": "10.0.1", - "@tsconfig/node-lts": "24.0.1", "@types/node": "26.6.5", "@typescript-eslint/parser": "8.72.0", "eslint": "10.13.0", "eslint-config-prettier": "10.1.8", "eslint-plugin-prettier": "5.5.7", "prettier": "3.9.10", + "publint": "0.3.25", "ts-node": "10.9.2", "typedoc": "0.28.20", "typescript": "6.0.3" diff --git a/packages/crowdsec-client-scenarios/.npmignore b/packages/crowdsec-client-scenarios/.npmignore deleted file mode 100644 index 804d10f2b..000000000 --- a/packages/crowdsec-client-scenarios/.npmignore +++ /dev/null @@ -1,11 +0,0 @@ -src -_OATemplates -coverage -tests -*.tgz -tsconfig.json -.env -generator.ts -esbuild.js -jest.config.js -typedoc.json diff --git a/packages/crowdsec-client-scenarios/LICENSE b/packages/crowdsec-client-scenarios/LICENSE new file mode 100644 index 000000000..21c073ef2 --- /dev/null +++ b/packages/crowdsec-client-scenarios/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Thibaut SEVERAC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/crowdsec-client-scenarios/package.json b/packages/crowdsec-client-scenarios/package.json index 21b9790a0..e6c20c8cd 100644 --- a/packages/crowdsec-client-scenarios/package.json +++ b/packages/crowdsec-client-scenarios/package.json @@ -1,11 +1,15 @@ { "name": "crowdsec-client-scenarios", "version": "0.0.14", - "description": "tmp", + "description": "CrowdSec detection scenarios for crowdsec-http-middleware and compatible middlewares", "main": "./lib/index.cjs", "module": "./lib/index.mjs", "types": "./lib/index.d.ts", "type": "module", + "sideEffects": false, + "files": [ + "lib" + ], "homepage": "https://github.com/thib3113/node-crowdsec/tree/main/packages/crowdsec-client-scenarios#readme", "scripts": { "prepack": "npm run build", @@ -41,18 +45,23 @@ }, "author": { "email": "thib3113@gmail.com", - "name": "Thibaut severac" + "name": "Thibaut Severac" }, "repository": { "type": "git", - "url": "https://github.com/thib3113/node-crowdsec.git" + "url": "git+https://github.com/thib3113/node-crowdsec.git", + "directory": "packages/crowdsec-client-scenarios" }, "keywords": [ "crowdsec", + "scenarios", + "bouncer", + "watcher", "firewall", - "security" + "security", + "typescript" ], - "license": "ISC", + "license": "MIT", "dependencies": { "debug": "^4.4.3", "ip-address": "^10.7.5", diff --git a/packages/crowdsec-client-scenarios/readme.md b/packages/crowdsec-client-scenarios/readme.md index 053f22be5..bce1bf37c 100644 --- a/packages/crowdsec-client-scenarios/readme.md +++ b/packages/crowdsec-client-scenarios/readme.md @@ -1,4 +1,5 @@ # crowdsec-client-scenarios + [![NPM version](https://img.shields.io/npm/v/crowdsec-client-scenarios.svg)](https://www.npmjs.com/package/crowdsec-client-scenarios) [![CI](https://github.com/thib3113/node-crowdsec/actions/workflows/CI.yml/badge.svg)](https://github.com/thib3113/node-crowdsec/actions/workflows/CI.yml) [![codecov](https://codecov.io/gh/thib3113/node-crowdsec/branch/main/graph/badge.svg?token=bQdlb31gcU)](https://codecov.io/gh/thib3113/node-crowdsec) @@ -11,25 +12,25 @@ [![GitHub stars](https://img.shields.io/github/stars/thib3113/node-crowdsec.svg?style=social&label=Star)](https://github.com/thib3113/node-crowdsec/stargazers/) [![Package Quality](https://packagequality.com/shield/crowdsec-client-scenarios.svg)](https://packagequality.com/#?package=crowdsec-client-scenarios) -[![Bugs](https://sonarcloud.io/api/project_badges/measure?project=thib3113_crowdsec-client-scenarios&metric=bugs)](https://sonarcloud.io/dashboard?id=thib3113_crowdsec-client-scenarios) -[![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=thib3113_crowdsec-client-scenarios&metric=code_smells)](https://sonarcloud.io/dashboard?id=thib3113_crowdsec-client-scenarios) -[![Duplicated Lines (%)](https://sonarcloud.io/api/project_badges/measure?project=thib3113_crowdsec-client-scenarios&metric=duplicated_lines_density)](https://sonarcloud.io/dashboard?id=thib3113_crowdsec-client-scenarios) -[![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=thib3113_crowdsec-client-scenarios&metric=ncloc)](https://sonarcloud.io/dashboard?id=thib3113_crowdsec-client-scenarios) -[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=thib3113_crowdsec-client-scenarios&metric=sqale_rating)](https://sonarcloud.io/dashboard?id=thib3113_crowdsec-client-scenarios) -[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=thib3113_crowdsec-client-scenarios&metric=alert_status)](https://sonarcloud.io/dashboard?id=thib3113_crowdsec-client-scenarios) -[![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=thib3113_crowdsec-client-scenarios&metric=reliability_rating)](https://sonarcloud.io/dashboard?id=thib3113_crowdsec-client-scenarios) -[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=thib3113_crowdsec-client-scenarios&metric=security_rating)](https://sonarcloud.io/dashboard?id=thib3113_crowdsec-client-scenarios) -[![Technical Debt](https://sonarcloud.io/api/project_badges/measure?project=thib3113_crowdsec-client-scenarios&metric=sqale_index)](https://sonarcloud.io/dashboard?id=thib3113_crowdsec-client-scenarios) -[![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=thib3113_crowdsec-client-scenarios&metric=vulnerabilities)](https://sonarcloud.io/dashboard?id=thib3113_crowdsec-client-scenarios) +[![Bugs](https://sonarcloud.io/api/project_badges/measure?project=thib3113_node-crowdsec&metric=bugs)](https://sonarcloud.io/dashboard?id=thib3113_node-crowdsec) +[![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=thib3113_node-crowdsec&metric=code_smells)](https://sonarcloud.io/dashboard?id=thib3113_node-crowdsec) +[![Duplicated Lines (%)](https://sonarcloud.io/api/project_badges/measure?project=thib3113_node-crowdsec&metric=duplicated_lines_density)](https://sonarcloud.io/dashboard?id=thib3113_node-crowdsec) +[![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=thib3113_node-crowdsec&metric=ncloc)](https://sonarcloud.io/dashboard?id=thib3113_node-crowdsec) +[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=thib3113_node-crowdsec&metric=sqale_rating)](https://sonarcloud.io/dashboard?id=thib3113_node-crowdsec) +[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=thib3113_node-crowdsec&metric=alert_status)](https://sonarcloud.io/dashboard?id=thib3113_node-crowdsec) +[![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=thib3113_node-crowdsec&metric=reliability_rating)](https://sonarcloud.io/dashboard?id=thib3113_node-crowdsec) +[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=thib3113_node-crowdsec&metric=security_rating)](https://sonarcloud.io/dashboard?id=thib3113_node-crowdsec) +[![Technical Debt](https://sonarcloud.io/api/project_badges/measure?project=thib3113_node-crowdsec&metric=sqale_index)](https://sonarcloud.io/dashboard?id=thib3113_node-crowdsec) +[![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=thib3113_node-crowdsec&metric=vulnerabilities)](https://sonarcloud.io/dashboard?id=thib3113_node-crowdsec) ![Dependencies update - renovate](https://img.shields.io/badge/renovate-enabled-green?style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjUgNSAzNzAgMzcwIj48Y2lyY2xlIGN4PSIxODkiIGN5PSIxOTAiIHI9IjE4NCIgZmlsbD0iI2ZlMiIvPjxwYXRoIGZpbGw9IiM4YmIiIGQ9Ik0yNTEgMjU2bC0zOC0zOGExNyAxNyAwIDAxMC0yNGw1Ni01NmMyLTIgMi02IDAtN2wtMjAtMjFhNSA1IDAgMDAtNyAwbC0xMyAxMi05LTggMTMtMTNhMTcgMTcgMCAwMTI0IDBsMjEgMjFjNyA3IDcgMTcgMCAyNGwtNTYgNTdhNSA1IDAgMDAwIDdsMzggMzh6Ii8+PHBhdGggZmlsbD0iI2Q1MSIgZD0iTTMwMCAyODhsLTggOGMtNCA0LTExIDQtMTYgMGwtNDYtNDZjLTUtNS01LTEyIDAtMTZsOC04YzQtNCAxMS00IDE1IDBsNDcgNDdjNCA0IDQgMTEgMCAxNXoiLz48cGF0aCBmaWxsPSIjYjMwIiBkPSJNMjg1IDI1OGw3IDdjNCA0IDQgMTEgMCAxNWwtOCA4Yy00IDQtMTEgNC0xNiAwbC02LTdjNCA1IDExIDUgMTUgMGw4LTdjNC01IDQtMTIgMC0xNnoiLz48cGF0aCBmaWxsPSIjYTMwIiBkPSJNMjkxIDI2NGw4IDhjNCA0IDQgMTEgMCAxNmwtOCA3Yy00IDUtMTEgNS0xNSAwbC05LThjNSA1IDEyIDUgMTYgMGw4LThjNC00IDQtMTEgMC0xNXoiLz48cGF0aCBmaWxsPSIjZTYyIiBkPSJNMjYwIDIzM2wtNC00Yy02LTYtMTctNi0yMyAwLTcgNy03IDE3IDAgMjRsNCA0Yy00LTUtNC0xMSAwLTE2bDgtOGM0LTQgMTEtNCAxNSAweiIvPjxwYXRoIGZpbGw9IiNiNDAiIGQ9Ik0yODQgMzA0Yy00IDAtOC0xLTExLTRsLTQ3LTQ3Yy02LTYtNi0xNiAwLTIybDgtOGM2LTYgMTYtNiAyMiAwbDQ3IDQ2YzYgNyA2IDE3IDAgMjNsLTggOGMtMyAzLTcgNC0xMSA0em0tMzktNzZjLTEgMC0zIDAtNCAybC04IDdjLTIgMy0yIDcgMCA5bDQ3IDQ3YTYgNiAwIDAwOSAwbDctOGMzLTIgMy02IDAtOWwtNDYtNDZjLTItMi0zLTItNS0yeiIvPjxwYXRoIGZpbGw9IiMxY2MiIGQ9Ik0xNTIgMTEzbDE4LTE4IDE4IDE4LTE4IDE4em0xLTM1bDE4LTE4IDE4IDE4LTE4IDE4em0tOTAgODlsMTgtMTggMTggMTgtMTggMTh6bTM1LTM2bDE4LTE4IDE4IDE4LTE4IDE4eiIvPjxwYXRoIGZpbGw9IiMxZGQiIGQ9Ik0xMzQgMTMxbDE4LTE4IDE4IDE4LTE4IDE4em0tMzUgMzZsMTgtMTggMTggMTgtMTggMTh6Ii8+PHBhdGggZmlsbD0iIzJiYiIgZD0iTTExNiAxNDlsMTgtMTggMTggMTgtMTggMTh6bTU0LTU0bDE4LTE4IDE4IDE4LTE4IDE4em0tODkgOTBsMTgtMTggMTggMTgtMTggMTh6bTEzOS04NWwyMyAyM2M0IDQgNCAxMSAwIDE2TDE0MiAyNDBjLTQgNC0xMSA0LTE1IDBsLTI0LTI0Yy00LTQtNC0xMSAwLTE1bDEwMS0xMDFjNS01IDEyLTUgMTYgMHoiLz48cGF0aCBmaWxsPSIjM2VlIiBkPSJNMTM0IDk1bDE4LTE4IDE4IDE4LTE4IDE4em0tNTQgMThsMTgtMTcgMTggMTctMTggMTh6bTU1LTUzbDE4LTE4IDE4IDE4LTE4IDE4em05MyA0OGwtOC04Yy00LTUtMTEtNS0xNiAwTDEwMyAyMDFjLTQgNC00IDExIDAgMTVsOCA4Yy00LTQtNC0xMSAwLTE1bDEwMS0xMDFjNS00IDEyLTQgMTYgMHoiLz48cGF0aCBmaWxsPSIjOWVlIiBkPSJNMjcgMTMxbDE4LTE4IDE4IDE4LTE4IDE4em01NC01M2wxOC0xOCAxOCAxOC0xOCAxOHoiLz48cGF0aCBmaWxsPSIjMGFhIiBkPSJNMjMwIDExMGwxMyAxM2M0IDQgNCAxMSAwIDE2TDE0MiAyNDBjLTQgNC0xMSA0LTE1IDBsLTEzLTEzYzQgNCAxMSA0IDE1IDBsMTAxLTEwMWM1LTUgNS0xMSAwLTE2eiIvPjxwYXRoIGZpbGw9IiMxYWIiIGQ9Ik0xMzQgMjQ4Yy00IDAtOC0yLTExLTVsLTIzLTIzYTE2IDE2IDAgMDEwLTIzTDIwMSA5NmExNiAxNiAwIDAxMjIgMGwyNCAyNGM2IDYgNiAxNiAwIDIyTDE0NiAyNDNjLTMgMy03IDUtMTIgNXptNzgtMTQ3bC00IDItMTAxIDEwMWE2IDYgMCAwMDAgOWwyMyAyM2E2IDYgMCAwMDkgMGwxMDEtMTAxYTYgNiAwIDAwMC05bC0yNC0yMy00LTJ6Ii8+PC9zdmc+ ) - [![NPM](https://nodei.co/npm/crowdsec-client-scenarios.png)](https://nodei.co/npm/crowdsec-client-scenarios/) -This library is a Node.js client to talk with crowdsec rest API . Please note that Node.js >= 24 is required. -# Start +This package provides optional CrowdSec detection scenarios for [crowdsec-http-middleware](https://www.npmjs.com/package/crowdsec-http-middleware) and compatible middlewares. Node.js >= 24 is required. + +## Start install it @@ -37,30 +38,36 @@ install it npm i crowdsec-client-scenarios ``` -and then read the documentation in the [wiki](https://github.com/thib3113/node-crowdsec/wiki) +and then read the [scenarios guide](https://github.com/thib3113/node-crowdsec/wiki/Scenarios) or the [API reference](https://thib3113.github.io/node-crowdsec/). -# Usage +## Usage -This package, is planned to host scenarios used by [crowdsec-http-middleware](https://www.npmjs.com/package/crowdsec-http-middleware) and other middleware that extend it +This package hosts scenarios used by [crowdsec-http-middleware](https://www.npmjs.com/package/crowdsec-http-middleware) and other middlewares that extend it. -# Scenarios +## Scenarios in this part, we will use the variables `scenarios` and `scenariosOptions`, to illustrate the use in the middlewares -## Defaults scenarios +### Defaults scenarios + the defaults scenarios are ([defined here](https://github.com/thib3113/node-crowdsec/blob/main/packages/crowdsec-client-scenarios/src/index.ts#L8)) : - [AllowListEnricher](#allowlist) : allow you to skip alerts on your local ips - [XForwardedForChecker](#xforwardedforchecker) : allow to extract visitor ip - [HTTPEnricher](#httpenricher) : enrich alerts with information from the http request -## Available scenarios -The available scenarios are : +### Available scenarios + +The available scenarios are: + +#### XForwardedForChecker + +This scenario validates the `X-Forwarded-For` header. Some malicious clients send a fake `X-Forwarded-For` header to hide their real IP. +This scenario triggers an alert if an untrusted IP tries to pass `X-Forwarded-For`. -### XForwardedForChecker -This scenario will validate the XForwardedFor header . Some malicious persons will send you a fake `X-Forwarded-For` header, to hide their real IP . -This scenario will trigger an alert, if an untrusted IP try to pass `X-Forwarded-For` ````typescript +import { XForwardedForChecker } from 'crowdsec-client-scenarios'; + const scenarios = [XForwardedForChecker]; const scenariosOptions = { 'x-forwarded-for': { @@ -74,10 +81,12 @@ const scenariosOptions = { this scenario support the extractIp capability, so, if you enable it, it will extract the IP automatically from the headers, will use set it on `req.ip`, and will use it to trigger alerts and check if decisions exists -#### Example +##### Example + you are using cloudflare (you can get cloudflare [ips](https://www.cloudflare.com/fr-fr/ips/) here, then traefik in subnet 10.0.3.0/24, and finally your webserver on your server, you will configure : + ````typescript const scenarios = [XForwardedForChecker]; const scenariosOptions = { @@ -122,9 +131,13 @@ This scenario will parse the information, and so set `req.ip = "1.2.3.4"`, so yo I need to warn you, if you miss configured the list of allowed ips, it can ban your reverse proxy . you can test it before, by setting `alertOnNotTrustedIps` to false, and log `req.ip`, if the IP is correct, you can enable the alerts -### AllowList -This scenario allow you to exclude some ips from alerts +#### AllowList + +This scenario allows you to exclude some IPs from alerts. + ````typescript +import { AllowListEnricher } from 'crowdsec-client-scenarios'; + const scenarios = [AllowListEnricher]; const scenariosOptions = { 'allow-list': { @@ -135,6 +148,7 @@ const scenariosOptions = { ```` by default, the CIDR allowed are the [RFC 1918](https://en.wikipedia.org/wiki/Private_network) one (private network) + ````typescript const scenariosOptions = { 'allow-list': { @@ -143,40 +157,50 @@ const scenariosOptions = { } ```` -### HTTPEnricher +#### HTTPEnricher -this scenario will add context to your alert, linked with the current http request +This scenario adds context to your alert, linked with the current HTTP request. ````typescript -const scenarios = [AllowListEnricher]; -//no options -const scenariosOptions = {} +import { HTTPEnricher } from 'crowdsec-client-scenarios'; + +const scenarios = [HTTPEnricher]; +// no options +const scenariosOptions = {}; ```` -### MaxMindEnricher +#### MaxMindEnricher -this scenario will add context to your alert with the help of the maxmind databases +This scenario adds context to your alert with the help of the MaxMind databases. ````typescript +import { MaxMindEnricher } from 'crowdsec-client-scenarios'; + const scenarios = [MaxMindEnricher]; const scenariosOptions = { maxmind: { - //specify path to ASN or city databases . you need need to set one, or the two databases + // specify the path to the ASN and/or city databases; set at least one paths: { ASN: 'path/to/geoLite2-ASN.mmdb', city: 'path/to/geoLite2-City.mmdb' }, - //you can watch for updates, and so, updating the database on the filesytem will automatically reload the database + // watch for updates: updating the database on the filesystem reloads it automatically watchForUpdates: true } } ```` -to use this scenario, you will need to download free databases available [here](http://dev.maxmind.com/geoip/geoip2/geolite2/). +To use this scenario, download the free databases from the [MaxMind GeoLite2 page](http://dev.maxmind.com/geoip/geoip2/geolite2/). If you need better accuracy you should consider buying [commercial subscription](https://www.maxmind.com/en/geoip2-databases). ## Debug + this library include [debug](https://www.npmjs.com/package/debug), to debug, you can set the env variable : + ````dotenv DEBUG=crowdsec-client-scenarios:* ```` + +## License + +MIT diff --git a/packages/crowdsec-client-scenarios/src/utils.ts b/packages/crowdsec-client-scenarios/src/utils.ts index 98465eed6..c39e95a55 100644 --- a/packages/crowdsec-client-scenarios/src/utils.ts +++ b/packages/crowdsec-client-scenarios/src/utils.ts @@ -24,7 +24,7 @@ export type AddressObject = Address4 | Address6; * A valid IP is at most 45 chars, 49 with a CIDR suffix. This bound is * deliberately large (20x margin) : it only rejects oversized * attacker-controlled input before `ip-address` parsing - * (CVE-2026-101911 : unbounded Address6 diagnostic โ†’ sync stall / OOM). + * (CVE-2026-101911 : unbounded Address6 diagnostic -> sync stall / OOM). */ export const MAX_IP_LENGTH = 1024; diff --git a/packages/crowdsec-client/.npmignore b/packages/crowdsec-client/.npmignore deleted file mode 100644 index 804d10f2b..000000000 --- a/packages/crowdsec-client/.npmignore +++ /dev/null @@ -1,11 +0,0 @@ -src -_OATemplates -coverage -tests -*.tgz -tsconfig.json -.env -generator.ts -esbuild.js -jest.config.js -typedoc.json diff --git a/packages/crowdsec-client/LICENSE b/packages/crowdsec-client/LICENSE new file mode 100644 index 000000000..21c073ef2 --- /dev/null +++ b/packages/crowdsec-client/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Thibaut SEVERAC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/crowdsec-client/README.md b/packages/crowdsec-client/README.md index b23da7106..5091c7da8 100644 --- a/packages/crowdsec-client/README.md +++ b/packages/crowdsec-client/README.md @@ -1,7 +1,7 @@ # crowdsec-client + [![NPM version](https://img.shields.io/npm/v/crowdsec-client.svg)](https://www.npmjs.com/package/crowdsec-client) [![CI](https://github.com/thib3113/node-crowdsec/actions/workflows/CI.yml/badge.svg)](https://github.com/thib3113/node-crowdsec/actions/workflows/CI.yml) -[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=thib3113_node-crowdsec&metric=coverage)](https://sonarcloud.io/summary/new_code?id=thib3113_node-crowdsec) [![Downloads](https://img.shields.io/npm/dm/crowdsec-client.svg)](https://www.npmjs.com/package/crowdsec-client) [![License](https://img.shields.io/npm/l/crowdsec-client)](https://github.com/thib3113/node-crowdsec/blob/main/LICENSE) [![Known Vulnerabilities](https://snyk.io/test/github/thib3113/node-crowdsec/badge.svg)](https://snyk.io/test/github/thib3113/node-crowdsec) @@ -24,10 +24,10 @@ ![Dependencies update - renovate](https://img.shields.io/badge/renovate-enabled-green?style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjUgNSAzNzAgMzcwIj48Y2lyY2xlIGN4PSIxODkiIGN5PSIxOTAiIHI9IjE4NCIgZmlsbD0iI2ZlMiIvPjxwYXRoIGZpbGw9IiM4YmIiIGQ9Ik0yNTEgMjU2bC0zOC0zOGExNyAxNyAwIDAxMC0yNGw1Ni01NmMyLTIgMi02IDAtN2wtMjAtMjFhNSA1IDAgMDAtNyAwbC0xMyAxMi05LTggMTMtMTNhMTcgMTcgMCAwMTI0IDBsMjEgMjFjNyA3IDcgMTcgMCAyNGwtNTYgNTdhNSA1IDAgMDAwIDdsMzggMzh6Ii8+PHBhdGggZmlsbD0iI2Q1MSIgZD0iTTMwMCAyODhsLTggOGMtNCA0LTExIDQtMTYgMGwtNDYtNDZjLTUtNS01LTEyIDAtMTZsOC04YzQtNCAxMS00IDE1IDBsNDcgNDdjNCA0IDQgMTEgMCAxNXoiLz48cGF0aCBmaWxsPSIjYjMwIiBkPSJNMjg1IDI1OGw3IDdjNCA0IDQgMTEgMCAxNWwtOCA4Yy00IDQtMTEgNC0xNiAwbC02LTdjNCA1IDExIDUgMTUgMGw4LTdjNC01IDQtMTIgMC0xNnoiLz48cGF0aCBmaWxsPSIjYTMwIiBkPSJNMjkxIDI2NGw4IDhjNCA0IDQgMTEgMCAxNmwtOCA3Yy00IDUtMTEgNS0xNSAwbC05LThjNSA1IDEyIDUgMTYgMGw4LThjNC00IDQtMTEgMC0xNXoiLz48cGF0aCBmaWxsPSIjZTYyIiBkPSJNMjYwIDIzM2wtNC00Yy02LTYtMTctNi0yMyAwLTcgNy03IDE3IDAgMjRsNCA0Yy00LTUtNC0xMSAwLTE2bDgtOGM0LTQgMTEtNCAxNSAweiIvPjxwYXRoIGZpbGw9IiNiNDAiIGQ9Ik0yODQgMzA0Yy00IDAtOC0xLTExLTRsLTQ3LTQ3Yy02LTYtNi0xNiAwLTIybDgtOGM2LTYgMTYtNiAyMiAwbDQ3IDQ2YzYgNyA2IDE3IDAgMjNsLTggOGMtMyAzLTcgNC0xMSA0em0tMzktNzZjLTEgMC0zIDAtNCAybC04IDdjLTIgMy0yIDcgMCA5bDQ3IDQ3YTYgNiAwIDAwOSAwbDctOGMzLTIgMy02IDAtOWwtNDYtNDZjLTItMi0zLTItNS0yeiIvPjxwYXRoIGZpbGw9IiMxY2MiIGQ9Ik0xNTIgMTEzbDE4LTE4IDE4IDE4LTE4IDE4em0xLTM1bDE4LTE4IDE4IDE4LTE4IDE4em0tOTAgODlsMTgtMTggMTggMTgtMTggMTh6bTM1LTM2bDE4LTE4IDE4IDE4LTE4IDE4eiIvPjxwYXRoIGZpbGw9IiMxZGQiIGQ9Ik0xMzQgMTMxbDE4LTE4IDE4IDE4LTE4IDE4em0tMzUgMzZsMTgtMTggMTggMTgtMTggMTh6Ii8+PHBhdGggZmlsbD0iIzJiYiIgZD0iTTExNiAxNDlsMTgtMTggMTggMTgtMTggMTh6bTU0LTU0bDE4LTE4IDE4IDE4LTE4IDE4em0tODkgOTBsMTgtMTggMTggMTgtMTggMTh6bTEzOS04NWwyMyAyM2M0IDQgNCAxMSAwIDE2TDE0MiAyNDBjLTQgNC0xMSA0LTE1IDBsLTI0LTI0Yy00LTQtNC0xMSAwLTE1bDEwMS0xMDFjNS01IDEyLTUgMTYgMHoiLz48cGF0aCBmaWxsPSIjM2VlIiBkPSJNMTM0IDk1bDE4LTE4IDE4IDE4LTE4IDE4em0tNTQgMThsMTgtMTcgMTggMTctMTggMTh6bTU1LTUzbDE4LTE4IDE4IDE4LTE4IDE4em05MyA0OGwtOC04Yy00LTUtMTEtNS0xNiAwTDEwMyAyMDFjLTQgNC00IDExIDAgMTVsOCA4Yy00LTQtNC0xMSAwLTE1bDEwMS0xMDFjNS00IDEyLTQgMTYgMHoiLz48cGF0aCBmaWxsPSIjOWVlIiBkPSJNMjcgMTMxbDE4LTE4IDE4IDE4LTE4IDE4em01NC01M2wxOC0xOCAxOCAxOC0xOCAxOHoiLz48cGF0aCBmaWxsPSIjMGFhIiBkPSJNMjMwIDExMGwxMyAxM2M0IDQgNCAxMSAwIDE2TDE0MiAyNDBjLTQgNC0xMSA0LTE1IDBsLTEzLTEzYzQgNCAxMSA0IDE1IDBsMTAxLTEwMWM1LTUgNS0xMSAwLTE2eiIvPjxwYXRoIGZpbGw9IiMxYWIiIGQ9Ik0xMzQgMjQ4Yy00IDAtOC0yLTExLTVsLTIzLTIzYTE2IDE2IDAgMDEwLTIzTDIwMSA5NmExNiAxNiAwIDAxMjIgMGwyNCAyNGM2IDYgNiAxNiAwIDIyTDE0NiAyNDNjLTMgMy03IDUtMTIgNXptNzgtMTQ3bC00IDItMTAxIDEwMWE2IDYgMCAwMDAgOWwyMyAyM2E2IDYgMCAwMDkgMGwxMDEtMTAxYTYgNiAwIDAwMC05bC0yNC0yMy00LTJ6Ii8+PC9zdmc+ ) - [![NPM](https://nodei.co/npm/crowdsec-client.png)](https://nodei.co/npm/crowdsec-client/) -This library is a Node.js client to talk with crowdsec rest API . +This library is a Node.js client for the CrowdSec REST API. + ## Start install it @@ -36,14 +36,17 @@ install it npm i crowdsec-client ``` -and then read the documentation in the [wiki](https://github.com/thib3113/node-crowdsec/wiki) +and then read the [getting started guide](https://github.com/thib3113/node-crowdsec/wiki/Getting-Started) or the [API reference](https://thib3113.github.io/node-crowdsec/). ## Usage + ### as a Bouncer -First, create a client, pointing to your crowdsec instance . With a bouncer api key ([doc](https://doc.crowdsec.net/docs/user_guides/bouncers_configuration/)) +First, create a client pointing to your CrowdSec instance, with a bouncer API key ([doc](https://doc.crowdsec.net/docs/user_guides/bouncers_configuration/)). ````typescript +import { BouncerClient } from 'crowdsec-client'; + const client = new BouncerClient({ url: process.env.CROWDSEC_URL, auth: { @@ -59,13 +62,13 @@ Second, ask for a decision ````typescript const stream = client.Decisions.getStream({ - //the stream will poll the API at the interval . in ms + // the stream polls the API at this interval, in ms interval: 10000 }); //or with filters const filteredStream = client.Decisions.getStream({ - //the stream will poll the API at the interval . in ms + // the stream polls the API at this interval, in ms interval: 10000, scopes: ['ip', 'range'], origins: ['capi'] , @@ -74,44 +77,42 @@ const filteredStream = client.Decisions.getStream({ }); ```` -now, use this stream +now, use this stream (`stream` is the object returned by `client.Decisions.getStream()`) ````typescript -import * as stream from "stream"; - stream.on('added', (decision) => { - //will be emited when a new decision is added + // emitted when a new decision is added }); stream.on('deleted', (decision) => { - //will be emitted when a decision is deleted + // emitted when a decision is deleted }); -//you can control the stream - -//start the stream +// start the stream stream.resume(); -//pause the stream -stream.pause() - -//check if the stream is paused -if(stream.paused) { +// pause the stream +stream.pause(); +// check if the stream is paused +if (stream.paused) { + // ... } ```` ### as a Watcher -First, create a client, pointing to your crowdsec instance . With a machine login/password ([doc](https://doc.crowdsec.net/docs/user_guides/machines_mgmt)) +First, create a client pointing to your CrowdSec instance, with a machine login/password ([doc](https://doc.crowdsec.net/docs/user_guides/machines_mgmt)). ````typescript +import { WatcherClient } from 'crowdsec-client'; + const client = new WatcherClient({ url: process.env.CROWDSEC_URL, auth: { machineID: 'nameOfTheMachine', password: 'password', - //the crowdsec token is valid for only 1h ... did you want to autorenew it ? + // the CrowdSec token is valid for 1h; autoRenew (default: true) keeps it fresh autoRenew: true, }, //use this option if you use a self signed ssl certificate @@ -143,10 +144,32 @@ await client.Alerts.delete({ }); ``` -More authentications options (like TLS) are documented in the [wiki](https://github.com/thib3113/node-crowdsec/wiki) +More authentication options (such as TLS) are documented in the [wiki](https://github.com/thib3113/node-crowdsec/wiki/Authentications). + +## Type overrides + +The generated API types under `src/types/generated` come from the upstream +CrowdSec swagger and are never edited by hand. Known discrepancies with the +actual LAPI are typed in `src/types/overrides.ts` and exported as +`SearchAlertsQuery` / `DeleteAlertsQuery` (also available as +`APITypes.SearchAlertsQuery` / `APITypes.DeleteAlertsQuery`): + +- `since` and `until` are Go durations (`24h`, `7d`) on both alert search and + alert delete, despite the swagger documenting them as timestamps for delete. +- `contains` and `kind` are supported by the alert filters but missing from the + generated search type; `decision_type` is also missing from the delete type. + +Run `pnpm run generate-api` to refresh the generated files, then re-check the +overrides against the new schema. ## Debug + this library include [debug](https://www.npmjs.com/package/debug), to debug, you can set the env variable : + ````dotenv DEBUG=crowdsec-client:* ```` + +## License + +MIT diff --git a/packages/crowdsec-client/package.json b/packages/crowdsec-client/package.json index 0521eda69..d23619713 100644 --- a/packages/crowdsec-client/package.json +++ b/packages/crowdsec-client/package.json @@ -1,11 +1,15 @@ { "name": "crowdsec-client", "version": "1.3.0", - "description": "A Crowdsec client that allow you to easily create bouncer or watcher", + "description": "A CrowdSec client that allows you to easily create a bouncer or watcher", "main": "./lib/index.cjs", "module": "./lib/index.mjs", "types": "./lib/index.d.ts", "type": "module", + "sideEffects": false, + "files": [ + "lib" + ], "homepage": "https://github.com/thib3113/node-crowdsec/tree/main/packages/crowdsec-client#readme", "scripts": { "prepack": "npm run build", @@ -42,20 +46,25 @@ }, "author": { "email": "thib3113@gmail.com", - "name": "Thibaut severac" + "name": "Thibaut Severac" }, "repository": { "type": "git", - "url": "https://github.com/thib3113/node-crowdsec.git" + "url": "git+https://github.com/thib3113/node-crowdsec.git", + "directory": "packages/crowdsec-client" }, "keywords": [ "crowdsec", + "bouncer", + "watcher", + "lapi", "firewall", - "security" + "security", + "typescript" ], "license": "MIT", "dependencies": { - "axios": "^1.20.0", + "axios": "^1.21.0", "debug": "^4.4.3" }, "engines": { diff --git a/packages/crowdsec-client/src/Alerts/Alerts.ts b/packages/crowdsec-client/src/Alerts/Alerts.ts index 9c2e95141..02f2c8845 100644 --- a/packages/crowdsec-client/src/Alerts/Alerts.ts +++ b/packages/crowdsec-client/src/Alerts/Alerts.ts @@ -1,5 +1,6 @@ import { BaseSubObject } from '../BaseSubObject.js'; import { Alerts as AlertsTypes } from '../types/index.js'; +import type { DeleteAlertsQuery, SearchAlertsQuery } from '../types/index.js'; import { AxiosResponse } from 'axios'; export class Alerts extends BaseSubObject { @@ -17,7 +18,7 @@ export class Alerts extends BaseSubObject { ).data; } - public async search(options?: AlertsTypes.SearchAlerts.RequestQuery): Promise { + public async search(options?: SearchAlertsQuery): Promise { return ( await this.http.get< AlertsTypes.SearchAlerts.ResponseBody, @@ -37,7 +38,7 @@ export class Alerts extends BaseSubObject { ).data; } - public async delete(options: AlertsTypes.DeleteAlerts.RequestQuery): Promise { + public async delete(options: DeleteAlertsQuery): Promise { return ( await this.http.delete< AlertsTypes.DeleteAlerts.ResponseBody, diff --git a/packages/crowdsec-client/src/duration.ts b/packages/crowdsec-client/src/duration.ts new file mode 100644 index 000000000..70c440edc --- /dev/null +++ b/packages/crowdsec-client/src/duration.ts @@ -0,0 +1,170 @@ +const UNIT_SECONDS: Record = { + h: 3600, + m: 60, + s: 1 +}; + +/** + * A duration string accepted by CrowdSec (`30m`, `4h`, `7d`, `1h30m`). + * `d` is a CrowdSec extension to Go's `time.ParseDuration`. + * + * The optional brand keeps the alias distinct from a bare `string` while still + * accepting plain strings, so it documents intent without forcing casts. + */ +export type GoDuration = string & { readonly __goDurationBrand?: never }; + +const DAY_SECONDS = 86400; + +function invalidDuration(input: string): Error { + return new Error(`invalid duration '${input}': use 30m, 4h or 7d`); +} + +interface ParsedNumber { + amount: number; + next: number; +} + +function readNumber(value: string, start: number): ParsedNumber | undefined { + let index = start; + while (value[index] !== undefined && value[index] >= '0' && value[index] <= '9') { + index++; + } + if (index === start) { + return undefined; + } + + if (value[index] === '.') { + index++; + const fractionStart = index; + while (value[index] !== undefined && value[index] >= '0' && value[index] <= '9') { + index++; + } + if (index === fractionStart) { + return undefined; + } + } + + return { amount: Number(value.slice(start, index)), next: index }; +} + +/** + * Parse the Go-style part of a duration (h/m/s, fractions allowed). + * Days are handled by the caller because CrowdSec only accepts `d` as the first unit. + */ +function parseGoDuration(value: string, input: string): number { + let total = 0; + let index = 0; + let matched = false; + + while (index < value.length) { + const parsed = readNumber(value, index); + if (!parsed) { + throw invalidDuration(input); + } + + const unit = value[parsed.next]; + const multiplier = unit === undefined ? undefined : UNIT_SECONDS[unit]; + if (multiplier === undefined) { + throw invalidDuration(input); + } + + total += parsed.amount * multiplier; + index = parsed.next + 1; + matched = true; + } + + if (!matched) { + throw invalidDuration(input); + } + + return total; +} + +/** + * Parse a CrowdSec/cscli duration into seconds. + * + * Mirrors `github.com/crowdsecurity/go-cs-lib/cstime`: `d` (24h) is supported + * only as the first unit, must be an integer, and `w` is not supported. + */ +export function parseDuration(input: string): number { + const value = input.trim(); + if (value === '') { + throw new Error('duration must not be empty'); + } + + let total = 0; + let rest = value; + const days = /^(\d+)d(.*)$/.exec(rest); + + if (days) { + total += Number(days[1]) * DAY_SECONDS; + rest = days[2]; + if (rest.includes('d')) { + throw invalidDuration(input); + } + if (rest !== '') { + total += parseGoDuration(rest, input); + } + } else { + if (value.includes('d')) { + throw invalidDuration(input); + } + total += parseGoDuration(value, input); + } + + if (!Number.isFinite(total) || total <= 0) { + throw invalidDuration(input); + } + + return total; +} + +/** + * Human-readable duration, used in tool output (e.g. `1d 1h`). + */ +export function formatDuration(seconds: number): string { + if (seconds <= 0) { + return '0s'; + } + + const days = Math.floor(seconds / DAY_SECONDS); + const hours = Math.floor((seconds % DAY_SECONDS) / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = Math.floor(seconds % 60); + + const parts: string[] = []; + if (days > 0) parts.push(`${days}d`); + if (hours > 0) parts.push(`${hours}h`); + if (minutes > 0) parts.push(`${minutes}m`); + if (secs > 0 && days === 0) parts.push(`${secs}s`); + + return parts.join(' ') || '0s'; +} + +function formatSeconds(seconds: number): string { + return String(Number(seconds.toFixed(6))); +} + +/** + * Normalize a duration to the Go `time.ParseDuration` format used on the LAPI + * wire. Days are converted to hours because Go does not understand `d`. + * + * Numbers are interpreted as seconds. + */ +export function toGoDuration(input: string | number): GoDuration { + const total = typeof input === 'number' ? input : parseDuration(input); + if (!Number.isFinite(total) || total <= 0) { + throw invalidDuration(String(input)); + } + + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const seconds = total - hours * 3600 - minutes * 60; + + const parts: string[] = []; + if (hours > 0) parts.push(`${hours}h`); + if (minutes > 0) parts.push(`${minutes}m`); + if (seconds > 0 || parts.length === 0) parts.push(`${formatSeconds(seconds)}s`); + + return parts.join(''); +} diff --git a/packages/crowdsec-client/src/index.ts b/packages/crowdsec-client/src/index.ts index 25d97e272..560fe0e3d 100644 --- a/packages/crowdsec-client/src/index.ts +++ b/packages/crowdsec-client/src/index.ts @@ -9,6 +9,8 @@ export * from './Errors/index.js'; export * from './interfaces/index.js'; export * from './BaseSubObject.js'; export * from './EventEmitter.js'; +export * from './duration.js'; export { APITypes }; +export type { DeleteAlertsQuery, SearchAlertsQuery } from './types/overrides.js'; export const VERSION = pkg.version; diff --git a/packages/crowdsec-client/src/types/index.ts b/packages/crowdsec-client/src/types/index.ts index b4c76530a..5551a68b1 100644 --- a/packages/crowdsec-client/src/types/index.ts +++ b/packages/crowdsec-client/src/types/index.ts @@ -3,6 +3,7 @@ export * from './generated/AllowlistsRoute.js'; export * from './generated/data-contracts.js'; export * from './generated/DecisionsRoute.js'; export * from './generated/WatchersRoute.js'; +export * from './overrides.js'; export type decisionOrigin = 'capi' | 'crowdsec' | 'cscli' | 'console' | 'cscli-import' | 'lists' | string; export type decisionType = 'ban' | 'captcha' | string; diff --git a/packages/crowdsec-client/src/types/overrides.ts b/packages/crowdsec-client/src/types/overrides.ts new file mode 100644 index 000000000..3a6f19e6d --- /dev/null +++ b/packages/crowdsec-client/src/types/overrides.ts @@ -0,0 +1,34 @@ +import type { GoDuration } from '../duration.js'; +import type { Alerts } from './generated/AlertsRoute.js'; + +export type { GoDuration } from '../duration.js'; + +/** + * `contains` and `kind` are supported by the LAPI alert filter but missing from + * the generated swagger type. + * + * @see https://github.com/crowdsecurity/crowdsec/blob/909b5157986a2b2c2163300fdaef5ed01289f7d2/pkg/database/alertfilter.go + */ +export type SearchAlertsQuery = Omit & { + since?: GoDuration; + until?: GoDuration; + contains?: boolean; + kind?: string; +}; + +/** + * The upstream swagger documents `since`/`until` as timestamps, but the LAPI + * parses them as Go durations for both the alert search and the alert delete. + * + * `decision_type`, `kind` and `contains` are supported by the same filter but + * missing from the generated swagger type. + * + * @see https://github.com/crowdsecurity/crowdsec/blob/909b5157986a2b2c2163300fdaef5ed01289f7d2/pkg/database/alertfilter.go#L44-L67 + */ +export type DeleteAlertsQuery = Omit & { + since?: GoDuration; + until?: GoDuration; + decision_type?: string; + kind?: string; + contains?: boolean; +}; diff --git a/packages/crowdsec-client/tests/Alerts/Alerts.test.ts b/packages/crowdsec-client/tests/Alerts/Alerts.test.ts index 394fcf565..5e3d39aef 100644 --- a/packages/crowdsec-client/tests/Alerts/Alerts.test.ts +++ b/packages/crowdsec-client/tests/Alerts/Alerts.test.ts @@ -1,6 +1,6 @@ import { describe, it, afterEach, beforeEach, expect, vi } from 'vitest'; -import type { Alerts as AlertsTypes } from '../../src/types/index.js'; +import type { DeleteAlertsQuery, SearchAlertsQuery } from '../../src/types/index.js'; import type { Alerts } from '../../src/index.js'; vi.mock('../../src/BaseSubObject.js', () => ({ BaseSubObject: vi.fn() })); @@ -67,18 +67,19 @@ describe('DecisionsWatcher', () => { it('should search', async () => { httpGetMock.mockImplementationOnce(() => ({ data: 'test' })); - const deleteOptions: AlertsTypes.SearchAlerts.RequestQuery = { - ip: '1.2.3.4' + const searchOptions: SearchAlertsQuery = { + ip: '1.2.3.4', + contains: false }; - expect(await alerts.search(deleteOptions)).toBe('test'); + expect(await alerts.search(searchOptions)).toBe('test'); - expect(httpGetMock).toHaveBeenCalledWith('/v1/alerts', { params: deleteOptions }); + expect(httpGetMock).toHaveBeenCalledWith('/v1/alerts', { params: searchOptions }); }); it('should call delete with options', async () => { httpDeleteMock.mockImplementationOnce(() => ({ data: 'test' })); - const deleteOptions: AlertsTypes.DeleteAlerts.RequestQuery = { + const deleteOptions: DeleteAlertsQuery = { ip: '1.2.3.4' }; @@ -87,6 +88,19 @@ describe('DecisionsWatcher', () => { expect(httpDeleteMock).toHaveBeenCalledWith('/v1/alerts', { params: deleteOptions }); }); + it('should forward duration and extended delete filters', async () => { + httpDeleteMock.mockImplementationOnce(() => ({ data: 'test' })); + const deleteOptions: DeleteAlertsQuery = { + since: '24h', + until: '1h', + decision_type: 'ban' + }; + + expect(await alerts.delete(deleteOptions)).toBe('test'); + + expect(httpDeleteMock).toHaveBeenCalledWith('/v1/alerts', { params: deleteOptions }); + }); + it('should call delete with id', async () => { httpDeleteMock.mockImplementationOnce(() => ({ data: 'test' })); diff --git a/packages/crowdsec-client/tests/Duration/duration.test.ts b/packages/crowdsec-client/tests/Duration/duration.test.ts new file mode 100644 index 000000000..a77153fad --- /dev/null +++ b/packages/crowdsec-client/tests/Duration/duration.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { formatDuration, parseDuration, toGoDuration } from '../../src/duration.js'; + +describe('duration', () => { + describe('parseDuration', () => { + it('parses cscli durations', () => { + expect(parseDuration('30m')).toBe(1800); + expect(parseDuration('4h')).toBe(14400); + expect(parseDuration('7d')).toBe(604800); + expect(parseDuration('1h30m')).toBe(5400); + expect(parseDuration('2d3h4m5s')).toBe(2 * 86400 + 3 * 3600 + 4 * 60 + 5); + }); + + it('rejects invalid durations', () => { + expect(() => parseDuration('')).toThrow(); + expect(() => parseDuration('abc')).toThrow(); + expect(() => parseDuration('4 hours')).toThrow(); + expect(() => parseDuration('0h')).toThrow(); + expect(() => parseDuration('90')).toThrow("invalid duration '90': use 30m, 4h or 7d"); + expect(() => parseDuration('1.5d')).toThrow(); + expect(() => parseDuration('2d4d')).toThrow(); + expect(() => parseDuration('1h30m2d')).toThrow(); + expect(() => parseDuration('1w')).toThrow(); + }); + }); + + describe('formatDuration', () => { + it('formats durations for humans', () => { + expect(formatDuration(0)).toBe('0s'); + expect(formatDuration(5400)).toBe('1h 30m'); + expect(formatDuration(90000)).toBe('1d 1h'); + }); + }); + + describe('toGoDuration', () => { + it('normalizes days to hours for the LAPI wire format', () => { + expect(toGoDuration('7d')).toBe('168h'); + expect(toGoDuration('2d3h')).toBe('51h'); + expect(toGoDuration('1h30m')).toBe('1h30m'); + expect(toGoDuration('90s')).toBe('1m30s'); + expect(toGoDuration(300)).toBe('5m'); + }); + + it('rejects invalid input', () => { + expect(() => toGoDuration('nope')).toThrow(); + expect(() => toGoDuration(0)).toThrow(); + expect(() => toGoDuration(-1)).toThrow(); + }); + }); +}); diff --git a/packages/crowdsec-http-middleware/.npmignore b/packages/crowdsec-http-middleware/.npmignore deleted file mode 100644 index b4ac8855f..000000000 --- a/packages/crowdsec-http-middleware/.npmignore +++ /dev/null @@ -1,9 +0,0 @@ -src -coverage -tests -*.tgz -tsconfig.json -.env -esbuild.js -jest.config.js -typedoc.json diff --git a/packages/crowdsec-http-middleware/LICENSE b/packages/crowdsec-http-middleware/LICENSE new file mode 100644 index 000000000..21c073ef2 --- /dev/null +++ b/packages/crowdsec-http-middleware/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Thibaut SEVERAC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/crowdsec-http-middleware/bench/ip-lookup.ts b/packages/crowdsec-http-middleware/bench/ip-lookup.ts index 6d4223ba3..0041c1cb6 100644 --- a/packages/crowdsec-http-middleware/bench/ip-lookup.ts +++ b/packages/crowdsec-http-middleware/bench/ip-lookup.ts @@ -46,7 +46,7 @@ class LinearScanDB { } // --------------------------------------------------------------------------- -// 2. Map>> โ€” current IpDecisionDB style +// 2. Map>> - current IpDecisionDB style // --------------------------------------------------------------------------- class MapOfMaps { private masks = new Map>>(); @@ -88,7 +88,7 @@ class MapOfMaps { } // --------------------------------------------------------------------------- -// 3. Map> โ€” values packed in a single number +// 3. Map> - values packed in a single number // --------------------------------------------------------------------------- class MapSMI { private masks = new Map>(); @@ -187,7 +187,7 @@ class SortedU32 { } // --------------------------------------------------------------------------- -// 5. Custom open-addressing hash (SoA) โ€” cap 2x, per mask +// 5. Custom open-addressing hash (SoA) - cap 2x, per mask // --------------------------------------------------------------------------- class OpenHash { private keys = new Uint32Array(65536); diff --git a/packages/crowdsec-http-middleware/package.json b/packages/crowdsec-http-middleware/package.json index f1fff41bb..3e5f1f80e 100644 --- a/packages/crowdsec-http-middleware/package.json +++ b/packages/crowdsec-http-middleware/package.json @@ -1,11 +1,15 @@ { "name": "crowdsec-http-middleware", "version": "0.1.1", - "description": "HTTP server middleware that can act as a crowdsec bouncer/watcher", + "description": "HTTP server middleware that can act as a CrowdSec bouncer/watcher", "main": "./lib/index.cjs", "module": "./lib/index.mjs", "types": "./lib/index.d.ts", "type": "module", + "sideEffects": false, + "files": [ + "lib" + ], "homepage": "https://github.com/thib3113/node-crowdsec/tree/main/packages/crowdsec-http-middleware#readme", "scripts": { "prepack": "npm run build", @@ -42,18 +46,24 @@ }, "author": { "email": "thib3113@gmail.com", - "name": "Thibaut severac" + "name": "Thibaut Severac" }, "repository": { "type": "git", - "url": "https://github.com/thib3113/node-crowdsec.git" + "url": "git+https://github.com/thib3113/node-crowdsec.git", + "directory": "packages/crowdsec-http-middleware" }, "keywords": [ "crowdsec", + "bouncer", + "watcher", + "http", + "middleware", "firewall", - "security" + "security", + "typescript" ], - "license": "ISC", + "license": "MIT", "dependencies": { "crowdsec-client": "workspace:^", "debug": "^4.4.3", diff --git a/packages/crowdsec-http-middleware/readme.md b/packages/crowdsec-http-middleware/readme.md index 1a9e342d4..112215f89 100644 --- a/packages/crowdsec-http-middleware/readme.md +++ b/packages/crowdsec-http-middleware/readme.md @@ -1,4 +1,5 @@ # crowdsec-http-middleware + [![NPM version](https://img.shields.io/npm/v/crowdsec-http-middleware.svg)](https://www.npmjs.com/package/crowdsec-http-middleware) [![CI](https://github.com/thib3113/node-crowdsec/actions/workflows/CI.yml/badge.svg)](https://github.com/thib3113/node-crowdsec/actions/workflows/CI.yml) [![codecov](https://codecov.io/gh/thib3113/node-crowdsec/branch/main/graph/badge.svg?token=bQdlb31gcU)](https://codecov.io/gh/thib3113/node-crowdsec) @@ -11,28 +12,27 @@ [![GitHub stars](https://img.shields.io/github/stars/thib3113/node-crowdsec.svg?style=social&label=Star)](https://github.com/thib3113/node-crowdsec/stargazers/) [![Package Quality](https://packagequality.com/shield/crowdsec-http-middleware.svg)](https://packagequality.com/#?package=crowdsec-http-middleware) -[![Bugs](https://sonarcloud.io/api/project_badges/measure?project=thib3113_crowdsec-http-middleware&metric=bugs)](https://sonarcloud.io/dashboard?id=thib3113_crowdsec-http-middleware) -[![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=thib3113_crowdsec-http-middleware&metric=code_smells)](https://sonarcloud.io/dashboard?id=thib3113_crowdsec-http-middleware) -[![Duplicated Lines (%)](https://sonarcloud.io/api/project_badges/measure?project=thib3113_crowdsec-http-middleware&metric=duplicated_lines_density)](https://sonarcloud.io/dashboard?id=thib3113_crowdsec-http-middleware) -[![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=thib3113_crowdsec-http-middleware&metric=ncloc)](https://sonarcloud.io/dashboard?id=thib3113_crowdsec-http-middleware) -[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=thib3113_crowdsec-http-middleware&metric=sqale_rating)](https://sonarcloud.io/dashboard?id=thib3113_crowdsec-http-middleware) -[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=thib3113_crowdsec-http-middleware&metric=alert_status)](https://sonarcloud.io/dashboard?id=thib3113_crowdsec-http-middleware) -[![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=thib3113_crowdsec-http-middleware&metric=reliability_rating)](https://sonarcloud.io/dashboard?id=thib3113_crowdsec-http-middleware) -[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=thib3113_crowdsec-http-middleware&metric=security_rating)](https://sonarcloud.io/dashboard?id=thib3113_crowdsec-http-middleware) -[![Technical Debt](https://sonarcloud.io/api/project_badges/measure?project=thib3113_crowdsec-http-middleware&metric=sqale_index)](https://sonarcloud.io/dashboard?id=thib3113_crowdsec-http-middleware) -[![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=thib3113_crowdsec-http-middleware&metric=vulnerabilities)](https://sonarcloud.io/dashboard?id=thib3113_crowdsec-http-middleware) +[![Bugs](https://sonarcloud.io/api/project_badges/measure?project=thib3113_node-crowdsec&metric=bugs)](https://sonarcloud.io/dashboard?id=thib3113_node-crowdsec) +[![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=thib3113_node-crowdsec&metric=code_smells)](https://sonarcloud.io/dashboard?id=thib3113_node-crowdsec) +[![Duplicated Lines (%)](https://sonarcloud.io/api/project_badges/measure?project=thib3113_node-crowdsec&metric=duplicated_lines_density)](https://sonarcloud.io/dashboard?id=thib3113_node-crowdsec) +[![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=thib3113_node-crowdsec&metric=ncloc)](https://sonarcloud.io/dashboard?id=thib3113_node-crowdsec) +[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=thib3113_node-crowdsec&metric=sqale_rating)](https://sonarcloud.io/dashboard?id=thib3113_node-crowdsec) +[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=thib3113_node-crowdsec&metric=alert_status)](https://sonarcloud.io/dashboard?id=thib3113_node-crowdsec) +[![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=thib3113_node-crowdsec&metric=reliability_rating)](https://sonarcloud.io/dashboard?id=thib3113_node-crowdsec) +[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=thib3113_node-crowdsec&metric=security_rating)](https://sonarcloud.io/dashboard?id=thib3113_node-crowdsec) +[![Technical Debt](https://sonarcloud.io/api/project_badges/measure?project=thib3113_node-crowdsec&metric=sqale_index)](https://sonarcloud.io/dashboard?id=thib3113_node-crowdsec) +[![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=thib3113_node-crowdsec&metric=vulnerabilities)](https://sonarcloud.io/dashboard?id=thib3113_node-crowdsec) ![Dependencies update - renovate](https://img.shields.io/badge/renovate-enabled-green?style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjUgNSAzNzAgMzcwIj48Y2lyY2xlIGN4PSIxODkiIGN5PSIxOTAiIHI9IjE4NCIgZmlsbD0iI2ZlMiIvPjxwYXRoIGZpbGw9IiM4YmIiIGQ9Ik0yNTEgMjU2bC0zOC0zOGExNyAxNyAwIDAxMC0yNGw1Ni01NmMyLTIgMi02IDAtN2wtMjAtMjFhNSA1IDAgMDAtNyAwbC0xMyAxMi05LTggMTMtMTNhMTcgMTcgMCAwMTI0IDBsMjEgMjFjNyA3IDcgMTcgMCAyNGwtNTYgNTdhNSA1IDAgMDAwIDdsMzggMzh6Ii8+PHBhdGggZmlsbD0iI2Q1MSIgZD0iTTMwMCAyODhsLTggOGMtNCA0LTExIDQtMTYgMGwtNDYtNDZjLTUtNS01LTEyIDAtMTZsOC04YzQtNCAxMS00IDE1IDBsNDcgNDdjNCA0IDQgMTEgMCAxNXoiLz48cGF0aCBmaWxsPSIjYjMwIiBkPSJNMjg1IDI1OGw3IDdjNCA0IDQgMTEgMCAxNWwtOCA4Yy00IDQtMTEgNC0xNiAwbC02LTdjNCA1IDExIDUgMTUgMGw4LTdjNC01IDQtMTIgMC0xNnoiLz48cGF0aCBmaWxsPSIjYTMwIiBkPSJNMjkxIDI2NGw4IDhjNCA0IDQgMTEgMCAxNmwtOCA3Yy00IDUtMTEgNS0xNSAwbC05LThjNSA1IDEyIDUgMTYgMGw4LThjNC00IDQtMTEgMC0xNXoiLz48cGF0aCBmaWxsPSIjZTYyIiBkPSJNMjYwIDIzM2wtNC00Yy02LTYtMTctNi0yMyAwLTcgNy03IDE3IDAgMjRsNCA0Yy00LTUtNC0xMSAwLTE2bDgtOGM0LTQgMTEtNCAxNSAweiIvPjxwYXRoIGZpbGw9IiNiNDAiIGQ9Ik0yODQgMzA0Yy00IDAtOC0xLTExLTRsLTQ3LTQ3Yy02LTYtNi0xNiAwLTIybDgtOGM2LTYgMTYtNiAyMiAwbDQ3IDQ2YzYgNyA2IDE3IDAgMjNsLTggOGMtMyAzLTcgNC0xMSA0em0tMzktNzZjLTEgMC0zIDAtNCAybC04IDdjLTIgMy0yIDcgMCA5bDQ3IDQ3YTYgNiAwIDAwOSAwbDctOGMzLTIgMy02IDAtOWwtNDYtNDZjLTItMi0zLTItNS0yeiIvPjxwYXRoIGZpbGw9IiMxY2MiIGQ9Ik0xNTIgMTEzbDE4LTE4IDE4IDE4LTE4IDE4em0xLTM1bDE4LTE4IDE4IDE4LTE4IDE4em0tOTAgODlsMTgtMTggMTggMTgtMTggMTh6bTM1LTM2bDE4LTE4IDE4IDE4LTE4IDE4eiIvPjxwYXRoIGZpbGw9IiMxZGQiIGQ9Ik0xMzQgMTMxbDE4LTE4IDE4IDE4LTE4IDE4em0tMzUgMzZsMTgtMTggMTggMTgtMTggMTh6Ii8+PHBhdGggZmlsbD0iIzJiYiIgZD0iTTExNiAxNDlsMTgtMTggMTggMTgtMTggMTh6bTU0LTU0bDE4LTE4IDE4IDE4LTE4IDE4em0tODkgOTBsMTgtMTggMTggMTgtMTggMTh6bTEzOS04NWwyMyAyM2M0IDQgNCAxMSAwIDE2TDE0MiAyNDBjLTQgNC0xMSA0LTE1IDBsLTI0LTI0Yy00LTQtNC0xMSAwLTE1bDEwMS0xMDFjNS01IDEyLTUgMTYgMHoiLz48cGF0aCBmaWxsPSIjM2VlIiBkPSJNMTM0IDk1bDE4LTE4IDE4IDE4LTE4IDE4em0tNTQgMThsMTgtMTcgMTggMTctMTggMTh6bTU1LTUzbDE4LTE4IDE4IDE4LTE4IDE4em05MyA0OGwtOC04Yy00LTUtMTEtNS0xNiAwTDEwMyAyMDFjLTQgNC00IDExIDAgMTVsOCA4Yy00LTQtNC0xMSAwLTE1bDEwMS0xMDFjNS00IDEyLTQgMTYgMHoiLz48cGF0aCBmaWxsPSIjOWVlIiBkPSJNMjcgMTMxbDE4LTE4IDE4IDE4LTE4IDE4em01NC01M2wxOC0xOCAxOCAxOC0xOCAxOHoiLz48cGF0aCBmaWxsPSIjMGFhIiBkPSJNMjMwIDExMGwxMyAxM2M0IDQgNCAxMSAwIDE2TDE0MiAyNDBjLTQgNC0xMSA0LTE1IDBsLTEzLTEzYzQgNCAxMSA0IDE1IDBsMTAxLTEwMWM1LTUgNS0xMSAwLTE2eiIvPjxwYXRoIGZpbGw9IiMxYWIiIGQ9Ik0xMzQgMjQ4Yy00IDAtOC0yLTExLTVsLTIzLTIzYTE2IDE2IDAgMDEwLTIzTDIwMSA5NmExNiAxNiAwIDAxMjIgMGwyNCAyNGM2IDYgNiAxNiAwIDIyTDE0NiAyNDNjLTMgMy03IDUtMTIgNXptNzgtMTQ3bC00IDItMTAxIDEwMWE2IDYgMCAwMDAgOWwyMyAyM2E2IDYgMCAwMDkgMGwxMDEtMTAxYTYgNiAwIDAwMC05bC0yNC0yMy00LTJ6Ii8+PC9zdmc+ ) - [![NPM](https://nodei.co/npm/crowdsec-http-middleware.png)](https://nodei.co/npm/crowdsec-http-middleware/) **Protect your Node.js HTTP server with CrowdSec.** This package runs a CrowdSec **bouncer** inside your app : it checks each -incoming request's IP against CrowdSec's decisions and tells you โ€” via -`req.decision` โ€” whether the visitor is banned, should be challenged with a +incoming request's IP against CrowdSec's decisions and tells you - via +`req.decision` - whether the visitor is banned, should be challenged with a captcha, or is clean. **You** decide the response (custom page, captcha, redirect, JSON...). A **watcher** can also detect malicious requests and report them to CrowdSec. @@ -83,19 +83,14 @@ That's it. `req.decision` is `undefined` for clean visitors and holds the decisi Need more control (live mode, which subnets to trust, a watcher, an AI agent to configure it) ? Read on. -## Start +Guides live in the [wiki](https://github.com/thib3113/node-crowdsec/wiki/HTTP-Middleware); +the generated API reference is in the [technical documentation](https://thib3113.github.io/node-crowdsec/). -install it +## Default scenarios -``` -npm i crowdsec-http-middleware -``` - -and then read the documentation in the [wiki](https://github.com/thib3113/node-crowdsec/wiki) - - -This package, support a default setup, with default scenarios . -You can use the default mode by installing crowdsec-http-middleware and crowdsec-client-scenarios, and passing an empty `scenarios` configuration +This package supports a default setup with default scenarios. +You can use the default mode by installing `crowdsec-http-middleware` and +`crowdsec-client-scenarios`, and omitting the `scenarios` option: ``` npm i crowdsec-http-middleware crowdsec-client-scenarios @@ -105,7 +100,7 @@ you can read what are the default scenarios enabled in [crowdsec-client-scenario ## Usage -This package, is a base package to create HTTP Middleware for HTTP Servers +This package is a base package for creating HTTP middleware for HTTP servers. A full example, showing how you stay in control of the response (custom message per decision type) : @@ -164,6 +159,7 @@ with optional scenarios). ```` First the global options + ````typescript const middlewareOptions: ICrowdSecHTTPMiddlewareOptions = { // this is the url of the crowdsec instances @@ -184,21 +180,22 @@ const middlewareOptions: ICrowdSecHTTPMiddlewareOptions = { ```` #### Watcher options + the watcher options allow you to setup an optional watcher . The watcher, will connect with crowdsec LAPI, and run scenarios to send alerts when analyzing requests -you need to remember, that crowdSec is an [IDS](https://en.wikipedia.org/wiki/Intrusion_detection_system), it will detect the alert and block it __the next time__ +you need to remember, that crowdSec is an [IDS](https://en.wikipedia.org/wiki/Intrusion_detection_system), it will detect the alert and block it **the next time** about authentication, you can also use TLS certificates . Check the [wiki](https://github.com/thib3113/node-crowdsec/wiki/Authentications) + ````typescript const watcherOptions = { machineID: 'myMachine', password: 'myPassword', // send heartbeat to LAPI ? it allow the LAPI to see the watcher "online" heartbeat: true, - // a list of scenarios constructors that will be used - scenarios: [], - // options passed to the scenarions + // scenarios: [], // omit to use the default scenarios; [] disables them + // options passed to the scenarios scenariosOptions: {} } ```` @@ -206,9 +203,11 @@ const watcherOptions = { you can read more about scenarios and scenarioOptions in [the crowdsec-client-scenario package](../crowdsec-client-scenarios) #### Bouncer options + bouncer, will check if a decision is associated with the current IP . about authentication, you can also use TLS certificates . Check the [wiki](https://github.com/thib3113/node-crowdsec/wiki/Authentications) + ````typescript const bouncerOptions = { apiKey: process.env.CROWDSEC_API_KEY || '', @@ -273,7 +272,13 @@ from that IP is blocked. A malicious unknown IP can pass once, never twice. lookups, `subnetLevel` presets and index setup. ## Debug + this library include [debug](https://www.npmjs.com/package/debug), to debug, you can set the env variable : + ````dotenv DEBUG=crowdsec-http-middleware:* ```` + +## License + +MIT diff --git a/packages/crowdsec-http-middleware/src/utils.ts b/packages/crowdsec-http-middleware/src/utils.ts index 2f9718c6d..0cb2527b9 100644 --- a/packages/crowdsec-http-middleware/src/utils.ts +++ b/packages/crowdsec-http-middleware/src/utils.ts @@ -22,9 +22,9 @@ export type AddressObject = Address4 | Address6; * * A valid IP is at most 45 chars (`1111:2222:3333:4444:5555:6666:255.255.255.255`), * 49 with a CIDR suffix (`.../128`). This bound is deliberately large (20x margin) - * to stay future-proof (zone ids, โ€ฆ) : its only purpose is to reject oversized + * to stay future-proof (zone ids, ...) : its only purpose is to reject oversized * attacker-controlled input before it reaches `ip-address` parsing - * (CVE-2026-101911 : unbounded Address6 diagnostic โ†’ sync stall / OOM on MiB-scale strings). + * (CVE-2026-101911 : unbounded Address6 diagnostic -> sync stall / OOM on MiB-scale strings). */ export const MAX_IP_LENGTH = 1024; diff --git a/packages/crowdsec-mcp/Dockerfile b/packages/crowdsec-mcp/Dockerfile new file mode 100644 index 000000000..e8e9f1d30 --- /dev/null +++ b/packages/crowdsec-mcp/Dockerfile @@ -0,0 +1,54 @@ +# syntax=docker/dockerfile:1 + +# Digest pinning and updates are handled by Renovate (see renovate.json). +# Keep a single base stage so Renovate only has one FROM line to update. +FROM node:26-alpine AS node + +FROM node AS build + +ENV PNPM_HOME=/pnpm +ENV PATH=$PNPM_HOME:$PATH + +# Node 26 no longer ships Corepack. +RUN npm install -g pnpm@12.11.2 --ignore-scripts + +WORKDIR /repo + +# Install from the lockfile only, so this layer is cached until dependencies change. +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.json .npmrc ./ +COPY packages/crowdsec-client/package.json packages/crowdsec-client/ +COPY packages/crowdsec-mcp/package.json packages/crowdsec-mcp/ +RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \ + pnpm fetch --store-dir /pnpm/store +RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \ + pnpm install --frozen-lockfile --offline --store-dir /pnpm/store + +COPY packages/crowdsec-client packages/crowdsec-client +COPY packages/crowdsec-mcp packages/crowdsec-mcp + +RUN pnpm --filter crowdsec-mcp... build + +# Produce a self-contained directory with production dependencies only. +# The workspace protocol (crowdsec-client: workspace:^) requires pnpm deploy. +RUN pnpm --filter crowdsec-mcp --prod deploy --legacy /out +RUN find /out -type f \( -name '*.map' -o -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' \) -delete + +FROM node AS runtime + +ENV NODE_ENV=production + +# npm is only needed to install pnpm in the build stage. +RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx + +WORKDIR /app +COPY --from=build --chown=node:node /out ./ + +USER node +EXPOSE 3939 + +# The HTTP transport is optional; stdio deployments are considered healthy as +# long as the process is running. For HTTP, probe the configured port. +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD node -e "if ((process.env.CROWDSEC_MCP_TRANSPORT || 'stdio') !== 'http') process.exit(0); const net = require('node:net'); let host = process.env.CROWDSEC_MCP_HTTP_HOST || '127.0.0.1'; if (host === '0.0.0.0') host = '127.0.0.1'; const port = Number(process.env.CROWDSEC_MCP_HTTP_PORT || 3939); const socket = net.connect(port, host, () => { socket.end(); process.exit(0); }); socket.on('error', () => process.exit(1)); socket.setTimeout(4000, () => { socket.destroy(); process.exit(1); });" + +ENTRYPOINT ["node", "dist/index.js"] diff --git a/packages/crowdsec-mcp/Dockerfile.dockerignore b/packages/crowdsec-mcp/Dockerfile.dockerignore new file mode 100644 index 000000000..e7d45c536 --- /dev/null +++ b/packages/crowdsec-mcp/Dockerfile.dockerignore @@ -0,0 +1,14 @@ +.git +.github +.idea +.vscode +node_modules +**/node_modules +**/dist +**/lib +**/coverage +coverage +docs +dev +examples +*.log diff --git a/packages/crowdsec-mcp/LICENSE b/packages/crowdsec-mcp/LICENSE new file mode 100644 index 000000000..21c073ef2 --- /dev/null +++ b/packages/crowdsec-mcp/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Thibaut SEVERAC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/crowdsec-mcp/README.md b/packages/crowdsec-mcp/README.md new file mode 100644 index 000000000..a65d9ca51 --- /dev/null +++ b/packages/crowdsec-mcp/README.md @@ -0,0 +1,568 @@ +# crowdsec-mcp + +[![CI](https://github.com/thib3113/node-crowdsec/actions/workflows/CI.yml/badge.svg)](https://github.com/thib3113/node-crowdsec/actions/workflows/CI.yml) +[![codecov](https://codecov.io/gh/thib3113/node-crowdsec/branch/main/graph/badge.svg?token=bQdlb31gcU)](https://codecov.io/gh/thib3113/node-crowdsec) +[![License](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/thib3113/node-crowdsec/blob/main/LICENSE) +[![Known Vulnerabilities](https://snyk.io/test/github/thib3113/node-crowdsec/badge.svg)](https://snyk.io/test/github/thib3113/node-crowdsec) +[![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg?logo=paypal)](https://paypal.me/thib3113) +[![GitHub stars](https://img.shields.io/github/stars/thib3113/node-crowdsec.svg?style=social&label=Star)](https://github.com/thib3113/node-crowdsec/stargazers/) + +[![Bugs](https://sonarcloud.io/api/project_badges/measure?project=thib3113_node-crowdsec&metric=bugs)](https://sonarcloud.io/dashboard?id=thib3113_node-crowdsec) +[![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=thib3113_node-crowdsec&metric=code_smells)](https://sonarcloud.io/dashboard?id=thib3113_node-crowdsec) +[![Duplicated Lines (%)](https://sonarcloud.io/api/project_badges/measure?project=thib3113_node-crowdsec&metric=duplicated_lines_density)](https://sonarcloud.io/dashboard?id=thib3113_node-crowdsec) +[![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=thib3113_node-crowdsec&metric=ncloc)](https://sonarcloud.io/dashboard?id=thib3113_node-crowdsec) +[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=thib3113_node-crowdsec&metric=sqale_rating)](https://sonarcloud.io/dashboard?id=thib3113_node-crowdsec) +[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=thib3113_node-crowdsec&metric=alert_status)](https://sonarcloud.io/dashboard?id=thib3113_node-crowdsec) +[![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=thib3113_node-crowdsec&metric=reliability_rating)](https://sonarcloud.io/dashboard?id=thib3113_node-crowdsec) +[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=thib3113_node-crowdsec&metric=security_rating)](https://sonarcloud.io/dashboard?id=thib3113_node-crowdsec) +[![Technical Debt](https://sonarcloud.io/api/project_badges/measure?project=thib3113_node-crowdsec&metric=sqale_index)](https://sonarcloud.io/dashboard?id=thib3113_node-crowdsec) +[![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=thib3113_node-crowdsec&metric=vulnerabilities)](https://sonarcloud.io/dashboard?id=thib3113_node-crowdsec) + +**Give this to your AI agent.** Copy/paste this prompt: + +> Read and give me a brief of what the MCP can do, then explain how to install and configure it for my client (Claude Desktop/Code, Cursor or opencode). Never ask me for credentials: just tell me which environment variables to set. Also explain the security impact before enabling `ban_ip`, `unban_ip` or `delete_alerts`. + +The agent can use this document as the installation and configuration reference. + +A [Model Context Protocol](https://modelcontextprotocol.io) server for CrowdSec. It exposes decisions, alerts and allowlists to an LLM as readable Markdown, with guarded write operations for watcher credentials. + +- `npx`-friendly, stdio by default +- Bouncer API key, watcher machine credentials, or mutual TLS +- Read-only by default +- Optional Streamable HTTP transport with a mandatory bearer token +- Server-side tool filtering +- cscli-compatible behavior for decisions, alerts and allowlists + +## Requirements + +- Node.js 24 or newer. +- A reachable CrowdSec Local API (LAPI). + +## Quick start + +```bash +npx -y crowdsec-mcp +``` + +From a checkout, before the npm release or for development: + +```bash +pnpm --filter crowdsec-mcp build +node packages/crowdsec-mcp/dist/index.js +``` + +With a bouncer API key: + +```bash +CROWDSEC_URL=https://crowdsec:8080 \ +CROWDSEC_API_KEY=your-bouncer-key \ +npx -y crowdsec-mcp +``` + +With a watcher (required for alerts, allowlists and write tools): + +```bash +CROWDSEC_URL=https://crowdsec:8080 \ +CROWDSEC_MACHINE_ID=crowdsec-mcp \ +CROWDSEC_PASSWORD=your-password \ +npx -y crowdsec-mcp +``` + +## Authentication + +The authentication mode is auto-detected from the provided credentials, or can be forced with `CROWDSEC_AUTH=bouncer|watcher`. + +| Mode | Credentials | Capabilities | +| --- | --- | --- | +| `bouncer` | `CROWDSEC_API_KEY` | read decisions | +| `watcher` | `CROWDSEC_MACHINE_ID` + `CROWDSEC_PASSWORD` | alerts, decisions, allowlists, writes | +| either | `CROWDSEC_TLS_CERT` + `CROWDSEC_TLS_KEY` + `CROWDSEC_TLS_CA` | mutual TLS | + +All three TLS files are required together. Use `CROWDSEC_STRICT_SSL=false` only for self-signed certificates in development. + +With mutual TLS and no explicit `CROWDSEC_AUTH`, the server defaults to `watcher` (agent certificate). Set `CROWDSEC_AUTH=bouncer` when the certificate belongs to a bouncer. + +If both a bouncer key and machine credentials are present, set `CROWDSEC_AUTH` explicitly. Without it, the bouncer API key takes precedence. + +### Environment variables + +| Variable | Description | Default | +| --- | --- | --- | +| `CROWDSEC_URL` | CrowdSec LAPI URL | required | +| `CROWDSEC_AUTH` | `bouncer` or `watcher` | auto | +| `CROWDSEC_API_KEY` | Bouncer API key | - | +| `CROWDSEC_MACHINE_ID` | Watcher machine ID | - | +| `CROWDSEC_PASSWORD` | Watcher password | - | +| `CROWDSEC_TLS_CERT` | Client certificate path | - | +| `CROWDSEC_TLS_KEY` | Client key path | - | +| `CROWDSEC_TLS_CA` | CA certificate path | - | +| `CROWDSEC_STRICT_SSL` | Validate TLS certificates | `true` | +| `CROWDSEC_TIMEOUT` | HTTP timeout in milliseconds | `10000` | +| `CROWDSEC_MCP_TRANSPORT` | `stdio` or `http` | `stdio` | +| `CROWDSEC_MCP_PASSTHROUGH` | Use per-request LAPI credentials (HTTP only) | `false` | +| `CROWDSEC_MCP_HTTP_HOST` | HTTP bind host | `127.0.0.1` | +| `CROWDSEC_MCP_HTTP_PORT` | HTTP bind port | `3939` | +| `CROWDSEC_MCP_HTTP_TOKEN` | HTTP bearer token | required for HTTP; optional in passthrough | +| `CROWDSEC_MCP_READONLY` | Hide write tools | `true` | +| `CROWDSEC_MCP_ENABLE_ALERT_DELETE` | Expose `delete_alerts` (requires the MCP next to the LAPI) | `false` | +| `CROWDSEC_MCP_ALLOW_ALERT_DELETE_WITHOUT_TOKEN` | Allow `delete_alerts` over HTTP without a service token (dangerous) | `false` | +| `CROWDSEC_MCP_TOOLS` | Comma-separated allowlist of tools | all | +| `CROWDSEC_MCP_DISABLE_TOOLS` | Comma-separated denylist of tools | none | +| `CROWDSEC_MCP_MAX_RESULTS` | Default result limit | `25` | +| `CROWDSEC_MCP_MAX_BAN_DURATION` | Maximum ban duration without `force` | `24h` | + +Secrets should stay in the environment. Command-line arguments are available for convenience, but are visible in the process list. + +### CLI arguments + +Most environment variables above have a kebab-case argument equivalent: `--url`, `--auth`, `--api-key`, `--machine-id`, `--password`, `--tls-cert`, `--tls-key`, `--tls-ca`, `--timeout`, `--transport`, `--http-host`, `--http-port`, `--http-token`, `--passthrough`, `--enable-alert-delete`, `--allow-alert-delete-without-token`, `--tools`, `--disable-tools`, `--max-results`, `--max-ban-duration`. Exceptions: `CROWDSEC_STRICT_SSL` uses `--insecure`, and `CROWDSEC_MCP_READONLY` uses `--readonly`/`--write`. + +Use `--help` for the full list. + +## Tools + +Read-only tools are always available according to the authentication mode. Write tools are hidden by default and require `CROWDSEC_MCP_READONLY=false`. + +| Tool | Kind | Auth | Description | +| --- | --- | --- | --- | +| `crowdsec_status` | read | any | Connection status, auth mode, capabilities and active tools | +| `list_decisions` | read | bouncer, watcher | Active decisions, like `cscli decisions list` | +| `get_decision` | read | watcher | Get an active decision by ID, with alert context | +| `list_alerts` | read | watcher | Alerts with filters, like `cscli alerts list` | +| `get_alert` | read | watcher | Alert details, decisions, meta and events | +| `list_allowlists` | read | watcher | Allowlists and their size | +| `get_allowlist` | read | watcher | Allowlist details and items | +| `check_allowlist` | read | watcher | Check one or many IPs/ranges against allowlists | +| `ban_ip` | write | watcher | Add a decision via the cscli alert payload | +| `unban_ip` | write | watcher | Delete decisions by ID, filters, or all with confirmation | +| `delete_alerts` | write | watcher | Opt-in; delete alerts by ID, filters, or all with confirmation (local deployment only) | + +### Read-only by default + +```bash +CROWDSEC_MCP_READONLY=true # default: write tools are not registered +CROWDSEC_MCP_READONLY=false # expose write tools +``` + +When read-only is enabled, write tools are not advertised at all: the model cannot call them. + +`list_alerts` reports active decisions only in its Decisions column. `list_decisions`, `get_decision` and `get_alert` expose the decision ID and a computed expiry (from the remaining decision duration). + +When `limit` is explicit, the MCP fetches all matching rows so the `hidden` count is exact. Without an explicit `limit`, it fetches `CROWDSEC_MCP_MAX_RESULTS` rows and only hints that more may exist. This keeps default calls cheap on large alert datasets. + +### Limit the tools exposed to the model + +```bash +# Only these tools are registered +CROWDSEC_MCP_TOOLS=crowdsec_status,list_decisions,list_alerts + +# Hide specific tools +CROWDSEC_MCP_DISABLE_TOOLS=delete_alerts,ban_ip +``` + +The allowlist wins over the denylist, and read-only mode always wins over both. Unknown names in `CROWDSEC_MCP_TOOLS` make the server fail at startup, so typos are visible immediately. + +## Documentation for the model + +The server ships its own CrowdSec documentation, so an agent can work without prior knowledge of the product: + +- **Server instructions** are sent at initialization. They carry the current authentication mode, write access, limits, availability of alerts/allowlists/alert deletion, and the working rules (investigate before acting, confirm before writing, never invent IDs). +- **Resources** can be read on demand: + - `crowdsec://docs/overview`: components, detection lifecycle, and what this MCP exposes. + - `crowdsec://docs/concepts`: alert vs decision, scopes, types, durations, origins, allowlists, bouncer vs watcher. + - `crowdsec://docs/triage`: alert to decision workflow with guardrails and `cscli` equivalents. +- **Prompts** provide ready-made workflows: + - `triage_alerts`: review recent alerts, correlate them with active decisions, propose or apply remediation. + - `investigate_ip`: summarise the detection history and remediation state of one IP. + - `banned_from_country`: list the IPs with an active decision that are geolocated in a given country. The LAPI alert search has no country filter, so the selection is done on the alert `Country` column, filled by MaxMind enrichment. + +The content adapts to the configuration: a bouncer only gets `investigate_ip` (the other prompts need alerts), and a read-only server gets analysis-only prompts. + +### Upstream CrowdSec documentation + +| Topic | Link | +| --- | --- | +| Introduction | | +| Concepts | | +| Local API | | +| Remediation components (bouncers) | | +| Scenarios | | +| Central API and community blocklist | | +| Centralized allowlists | | +| `cscli alerts` | | +| `cscli decisions` | | +| `cscli allowlists` | | + +## Write operations + +### `ban_ip` + +Mirrors `cscli decisions add`: + +```text +ban_ip(ip="203.0.113.10", duration="4h", type="ban", reason="manual block") +ban_ip(range="203.0.113.0/24", duration="1h", type="captcha") +``` + +- Provide exactly one of `ip`, `range`, or `scope` + `value`; mixing them is rejected. +- IP and CIDR inputs are validated before sending anything. Scopes and decision types are validated too. +- The value is checked against allowlists first. If it is allowlisted, the call is refused unless `force=true`. +- `duration` accepts a unit (`30m`, `4h`, `7d`, `1h30m`). A bare number is interpreted as seconds (`300` = `5m`). +- `Country` values must be two-letter codes; `AS` values must be numeric ASNs. `Ip`/`Range` values are validated as IP/CIDR. +- `duration` is limited by `CROWDSEC_MCP_MAX_BAN_DURATION` unless `force=true`. +- CrowdSec accepts private, unspecified (`0.0.0.0`), broadcast and TEST-NET addresses; the MCP does not filter them. +- The decision is created through `POST /v1/alerts`, exactly like cscli. It is therefore visible as an alert, and may be forwarded to CAPI depending on your CrowdSec configuration. +- `origin` is always `cscli`; the MCP does not impersonate a scenario. + +### `unban_ip` + +`unban_ip` deletes decisions and works from anywhere: + +```text +unban_ip(ip="203.0.113.10") +unban_ip(id="42") +unban_ip(all=true, confirm=true) +``` + +`all=true` without `confirm=true` is rejected. `scope` must be combined with `value` (`unban_ip(scope="Country", value="FR")`); `scope` alone is rejected so a broad filter cannot delete every decision of that scope. `range` containment is resolved through an alert search and deleted by ID; other scopes are sent directly to the LAPI. `type`/`scenario` alone are broad and also require `confirm=true`; narrow with `ip`, `range`, or `scope`+`value`. + +### `delete_alerts` (opt-in) + +`delete_alerts` is disabled by default because CrowdSec only allows alert deletion from the LAPI host. Enable it only when the MCP runs next to the LAPI: + +```bash +CROWDSEC_MCP_ENABLE_ALERT_DELETE=true +``` + +A remote watcher receives `403 access forbidden from this IP`. Use `unban_ip` to remove decisions remotely, and `cscli alerts delete` on the LAPI host to clean up alerts. + +Over HTTP, `delete_alerts` is hidden unless a service token (`CROWDSEC_MCP_HTTP_TOKEN`) is configured or `CROWDSEC_MCP_ALLOW_ALERT_DELETE_WITHOUT_TOKEN=true` is set. This prevents a tokenless shared MCP (for example in passthrough mode) from exposing the local-only destructive path. Over stdio, the process is already local, so only `CROWDSEC_MCP_ENABLE_ALERT_DELETE` is required. + +`all=true` cannot be combined with filters. `ip` and `range` accept a CIDR and perform containment. `type` is resolved through an alert search followed by deletion by ID, as the conservative path for LAPIs that do not accept `decision_type` on DELETE. `since` and `until` are Go durations (`24h`, `7d`) and are sent directly to the delete endpoint, like the alert search. `id`, `origin` (sent as `alert_source`) and `has_active_decision` are sent directly. `scope` must be combined with `value`; scope alone is rejected to avoid deleting every alert of that scope. Deleting without a narrow target (`id`, `ip`, `range`, or `scope`+`value`) โ€” e.g. `scenario`, `origin`, `type`, `since`/`until` or `has_active_decision` alone โ€” requires `confirm=true`, like `unban_ip`. Deleting an alert also deletes its active decisions; this cascade is a CrowdSec LAPI behavior. + +### CIDR semantics + +- `list_decisions(ip="198.51.100.0/24")` performs containment (`contains=false`, the default). `contains=true` returns decisions that contain the value. +- `list_alerts`, `delete_alerts` and `unban_ip` accept a CIDR in `ip`/`range` and perform containment. +- `delete_alerts` deletes matching alerts by ID when containment is used. +- `unban_ip` supports non-IP scopes (`Country`, `AS`, `Session`, `Username`) by resolving active decisions and deleting them by ID. + +## Allowlists + +Allowlists are read/check only. The CrowdSec local API exposes no create, update or delete endpoint for allowlists: those operations are done with `cscli allowlists ...` or the CrowdSec Console. The MCP therefore provides: + +- `list_allowlists` +- `get_allowlist` +- `check_allowlist` + +`ban_ip` automatically calls `check_allowlist` for IP and range scopes, like cscli. `check_allowlist` accepts IPs and CIDR ranges only; the LAPI bulk check endpoint does not support other scopes. When the target is a CIDR, a match means the CIDR overlaps or contains an allowlisted entry; the output labels it as a CIDR match rather than an exact IP match. + +## MCP client configuration + +### Claude Desktop / Claude Code + +```json +{ + "mcpServers": { + "crowdsec": { + "command": "npx", + "args": ["-y", "crowdsec-mcp"], + "env": { + "CROWDSEC_URL": "https://crowdsec:8080", + "CROWDSEC_MACHINE_ID": "crowdsec-mcp", + "CROWDSEC_PASSWORD": "change-me", + "CROWDSEC_MCP_READONLY": "true" + } + } + } +} +``` + +### Cursor + +```json +{ + "mcpServers": { + "crowdsec": { + "command": "npx", + "args": ["-y", "crowdsec-mcp"], + "env": { + "CROWDSEC_URL": "https://crowdsec:8080", + "CROWDSEC_API_KEY": "your-bouncer-key", + "CROWDSEC_MCP_TOOLS": "crowdsec_status,list_decisions" + } + } + } +} +``` + +### opencode + +```json +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "crowdsec": { + "type": "local", + "command": ["npx", "-y", "crowdsec-mcp"], + "environment": { + "CROWDSEC_URL": "https://crowdsec:8080", + "CROWDSEC_API_KEY": "your-bouncer-key" + }, + "enabled": true + } + } +} +``` + +## HTTP transport + +```bash +CROWDSEC_MCP_TRANSPORT=http \ +CROWDSEC_MCP_HTTP_HOST=127.0.0.1 \ +CROWDSEC_MCP_HTTP_PORT=3939 \ +CROWDSEC_MCP_HTTP_TOKEN=change-me \ +CROWDSEC_URL=https://crowdsec:8080 \ +CROWDSEC_API_KEY=your-bouncer-key \ +npx -y crowdsec-mcp +``` + +The token is **mandatory for every request**, including on `127.0.0.1` and including unknown routes. Send it as: + +```text +Authorization: Bearer change-me +``` + +The MCP endpoint is `/mcp`. Do not expose this server publicly without TLS and a strong token: it can read security data and, when writes are enabled, ban or unban addresses. + +### Shared deployment next to CrowdSec + +A common setup is one MCP server next to the LAPI, with remote clients connecting over HTTP: + +```bash +CROWDSEC_URL=http://127.0.0.1:8080 \ +CROWDSEC_MACHINE_ID=crowdsec-mcp \ +CROWDSEC_PASSWORD=change-me \ +CROWDSEC_MCP_TRANSPORT=http \ +CROWDSEC_MCP_HTTP_HOST=0.0.0.0 \ +CROWDSEC_MCP_HTTP_TOKEN=change-me \ +CROWDSEC_MCP_READONLY=false \ +CROWDSEC_MCP_ENABLE_ALERT_DELETE=true \ +npx -y crowdsec-mcp +``` + +Clients point their MCP client at `https://mcp.example.com/mcp` and send: + +```text +Authorization: Bearer change-me +``` + +The MCP holds the LAPI credentials; users only need the MCP token. Put TLS and a reverse proxy in front, and use one token per MCP instance. This deployment allows `delete_alerts` because the LAPI sees the MCP as local. + +### Per-user passthrough (multi-user) + +For multiple users with individual LAPI credentials, enable passthrough: + +```bash +CROWDSEC_URL=http://127.0.0.1:8080 \ +CROWDSEC_MCP_TRANSPORT=http \ +CROWDSEC_MCP_HTTP_HOST=0.0.0.0 \ +CROWDSEC_MCP_PASSTHROUGH=true \ +CROWDSEC_MCP_READONLY=false \ +# Optional service gate: +# CROWDSEC_MCP_HTTP_TOKEN=change-me \ +npx -y crowdsec-mcp +``` + +Each request provides exactly one LAPI credential: + +```text +X-CrowdSec-Api-Key: # read decisions only +# OR +X-CrowdSec-Token: # full watcher tools / writes +``` + +If `CROWDSEC_MCP_HTTP_TOKEN` is configured, every request must also send: + +```text +Authorization: Bearer +``` + +Rules: +- Both `X-CrowdSec-Api-Key` and `X-CrowdSec-Token` -> `400`. +- Neither -> `401`. +- Bouncer keys expose only bouncer-compatible tools; watcher JWTs expose the watcher toolset. +- Clients are created per request and never reuse another user's credential. +- The watcher JWT must be refreshed by the user when it expires. + +**Why two tokens?** The optional service token gates the MCP endpoint itself, while the LAPI credential authorizes the actual CrowdSec action. The service token is optional because the LAPI credential already authenticates the request, but it is recommended when exposing the MCP: it prevents anonymous probing and gates the local-only `delete_alerts`. Without a service token, `delete_alerts` is hidden unless `CROWDSEC_MCP_ALLOW_ALERT_DELETE_WITHOUT_TOKEN=true` is set. + +## Security + +The MCP speaks to CrowdSec with real LAPI credentials. The security posture +depends on two choices: the transport (`stdio` vs `http`) and where the +credentials live (server environment vs per-request headers). + +### Threat model + +- **stdio**: the MCP is a local process started by the client. Nothing listens + on the network. The main risk is who can read the client config or the + process environment. +- **http**: the MCP listens on `CROWDSEC_MCP_HTTP_HOST:PORT` and only serves + `POST /mcp` (anything else is 404/405). It runs stateless: one fresh + server and transport per request, no session stored. Bodies are capped at + 4 MiB (413 beyond that) and server errors are returned as generic JSON-RPC + messages without stack traces. There is **no TLS on the MCP port itself**: + the bearer token and any passthrough credentials travel in cleartext. In + practice the server is reachable from the network (e.g. a management VLAN + for the AI agents), so put a TLS reverse proxy in front and restrict at + the firewall who can reach the port. +- Credentials (API key, password, token, TLS material) are kept in process + memory and never written to logs or tool outputs. Tool errors surfaced to + the model are truncated to their first line. + +### Recommended deployments + +| Deployment | Access control | LAPI identity | Notes | +| --- | --- | --- | --- | +| stdio, local client | OS user boundary | server env (`CROWDSEC_API_KEY` or machine credentials) | Default. Nothing exposed on the network. | +| http on `127.0.0.1` + service token | `Authorization: Bearer` per request | server env, shared by all callers | Token is mandatory at startup outside passthrough. Local-only setups. | +| http on a management VLAN + TLS reverse proxy + service token | proxy + service token, firewall-limited to the VLAN | server env, shared by all callers | The realistic production setup. Rotate the token if it leaks: every holder shares the same LAPI identity. | +| http passthrough + service token | service token + per-request LAPI credential | per request (`X-CrowdSec-Api-Key` or `X-CrowdSec-Token`) | Multi-user friendly: each caller only gets their own LAPI rights. | +| http passthrough, no service token | per-request LAPI credential only | per request | Acceptable on a trusted management VLAN; anyone with a valid LAPI credential can probe the endpoint. | + +In passthrough mode, `X-CrowdSec-Api-Key` (bouncer, read decisions only) and +`X-CrowdSec-Token` (watcher JWT, full toolset) are mutually exclusive: +sending both is a 400, sending neither is a 401. Requests headers are +validated and then discarded; one request never reuses another's credential. +The passthrough mode ignores `CROWDSEC_TLS_*`: mTLS client certificates are +not applied per request. + +### Security-relevant variables + +| Variable | Effect | Safe values | +| --- | --- | --- | +| `CROWDSEC_MCP_HTTP_HOST` | Network interface the MCP binds to. `0.0.0.0` exposes it to the LAN/container network. | `127.0.0.1` for local-only setups; otherwise bind behind a TLS reverse proxy on a management VLAN and firewall the port. | +| `CROWDSEC_MCP_HTTP_TOKEN` | Bearer token required on every request (compared in constant time). Mandatory in non-passthrough HTTP: the server refuses to start without it. | A long random value, rotated like a password. One token per MCP instance. | +| `CROWDSEC_MCP_READONLY` | `true` hides `ban_ip`, `unban_ip` and `delete_alerts` from `listTools`: the model cannot call what it cannot see. | Keep `true` (the default) unless writes are needed. | +| `CROWDSEC_MCP_ENABLE_ALERT_DELETE` | Exposes `delete_alerts`. Deletion only works from the LAPI host (403 otherwise). | `true` only in a sidecar/host deployment next to the LAPI. | +| `CROWDSEC_MCP_ALLOW_ALERT_DELETE_WITHOUT_TOKEN` | Forces `delete_alerts` to be exposed over HTTP **without** a service token. | Leave `false`. With this flag, anyone holding a watcher JWT can trigger local-only destructive deletions as soon as the port is reachable. | +| `CROWDSEC_MCP_TOOLS` / `CROWDSEC_MCP_DISABLE_TOOLS` | Allowlist/denylist of exposed tools. | Expose the smallest useful surface, e.g. `CROWDSEC_MCP_DISABLE_TOOLS=delete_alerts,ban_ip`. | +| `CROWDSEC_MCP_MAX_BAN_DURATION` | Bans above this need `force=true`. | `24h` default; tighten for shared deployments. | +| `CROWDSEC_STRICT_SSL` / `--insecure` | Disabling TLS validation between the MCP and the LAPI exposes credentials to MITM. | Keep `true`. Only disable for self-signed certificates in development. | + +Keep secrets in the environment, not in command-line arguments: arguments are +visible in the process list. + +### Confirmations are not authorization + +Destructive tools ask for explicit confirmation, but that is a guardrail, not +an access boundary: + +- `all=true` always requires `confirm=true`. +- `unban_ip` filtered only by `type`/`scenario` requires `confirm=true`. +- `delete_alerts` without a narrow target (`id`, `ip`, `range`, or + `scope`+`value`) โ€” e.g. `scenario`, `origin`, `type`, time filters or + `has_active_decision` alone โ€” requires `confirm=true`. +- `ban_ip` refuses allowlisted targets unless `force=true`; `check_allowlist` + first when in doubt. + +The server instructions repeat these rules to the model, but any client that +can reach the endpoint can also confirm: the real protection is the token, +the bind address, and the read-only default. + +## Troubleshooting + +| Symptom | Cause / fix | +| --- | --- | +| `401 Unauthorized` | Missing or invalid HTTP bearer token. In HTTP mode, send `Authorization: Bearer `. | +| `403 access forbidden from this IP` on `delete_alerts` | CrowdSec only allows alert deletion from the LAPI host. Use `unban_ip` for decisions, or enable `delete_alerts` only in a deployment next to the LAPI. | +| ` is allowlisted by item ...` | The target matches an allowlist. Use `force=true` only if you really want to ban an allowlisted value. | +| A tool is missing from `listTools` | It may be a write tool while `CROWDSEC_MCP_READONLY=true`, a watcher tool in bouncer mode, or hidden by `CROWDSEC_MCP_TOOLS` / `CROWDSEC_MCP_DISABLE_TOOLS`. | +| `invalid duration` | Use durations such as `30m`, `4h`, `7d`, `3650d`, or `1h30m`. | +| `Duration ... exceeds maximum allowed ...` | The duration is above `CROWDSEC_MCP_MAX_BAN_DURATION`. Lower it or use `force=true`. | + +## Development + +```bash +pnpm --filter crowdsec-mcp build +pnpm --filter crowdsec-mcp test +node packages/crowdsec-mcp/dist/index.js --help +``` + +The test suite includes unit tests, HTTP contract tests for the CrowdSec client, and a stdio end-to-end test that spawns the built binary against a fake LAPI. The integration test against a real CrowdSec LAPI in Docker also runs in CI (see the HTTP E2E script below). + +Live client checks are opt-in: + +```bash +CROWDSEC_LIVE=1 \ +CROWDSEC_URL=https://crowdsec:8080 \ +CROWDSEC_API_KEY=your-bouncer-key \ +pnpm --filter crowdsec-client exec vitest run tests/Live/live.test.ts +``` + +Set `CROWDSEC_MACHINE_ID` and `CROWDSEC_PASSWORD` instead of `CROWDSEC_API_KEY` to exercise watcher mode, including allowlists. + +### Integration test against a real LAPI + +Start a disposable CrowdSec container: + +```bash +docker run -d --name crowdsec-mcp-test \ + -p 18080:8080 \ + -v crowdsec-mcp-data:/var/lib/crowdsec/data \ + crowdsecurity/crowdsec:latest +``` + +Create credentials and seed data: + +```bash +export BOUNCER_KEY=$(docker exec crowdsec-mcp-test cscli bouncers add crowdsec-mcp-bouncer -o raw) +docker exec crowdsec-mcp-test cscli machines add crowdsec-mcp-watcher --auto -f - +# export CROWDSEC_MACHINE_ID= and CROWDSEC_PASSWORD= from the output +docker exec crowdsec-mcp-test cscli allowlists create test-allowlist -d "mcp integration" +docker exec crowdsec-mcp-test cscli allowlists add test-allowlist 192.0.2.10 -d "mcp test" +docker exec crowdsec-mcp-test cscli decisions add --ip 203.0.113.10 --duration 24h --reason "mcp integration" +``` + +Run the integration test: + +```bash +CROWDSEC_INTEGRATION=1 \ +CROWDSEC_INTEGRATION_WRITE=1 \ +CROWDSEC_URL=http://127.0.0.1:18080 \ +CROWDSEC_API_KEY="$BOUNCER_KEY" \ +CROWDSEC_MACHINE_ID= \ +CROWDSEC_PASSWORD= \ +pnpm --filter crowdsec-mcp exec vitest run tests/integration/live.test.ts +``` + +`delete_alerts` is not covered by the integration test because it requires the MCP to run next to the LAPI. Clean up with: + +```bash +docker rm -f crowdsec-mcp-test && docker volume rm crowdsec-mcp-data +``` + +### HTTP transport and passthrough E2E + +This E2E starts a disposable CrowdSec LAPI, an nginx log generator, and two MCP HTTP servers: + +- `mcp` on `23939` with a service token; +- `mcp-open` on `23940` without a service token; +- both share CrowdSec's network namespace, so `delete_alerts` works. + +Requirements: Docker Compose v2, `pnpm`, `jq` and `openssl`. The script builds the client and MCP first unless `E2E_SKIP_BUILD=1` is set. + +```bash +pnpm --filter crowdsec-mcp test:e2e:http +``` + +The test waits for alerts generated by nginx logs, then exercises bouncer/watcher tool filtering, per-request credentials, service-token gating, CIDR filtering of decisions, `ban_ip`, `unban_ip` by range, and local-only `delete_alerts`. It then seeds an allowlist and a decision and runs `tests/integration/live.test.ts` against the same LAPI. The orchestration script lives in `.github/scripts/mcp-http-e2e.sh`. + +Ports can be overridden with `E2E_LAPI_PORT`, `E2E_MCP_PORT`, and `E2E_MCP_OPEN_PORT`. Use `E2E_KEEP=1` to keep the stack for debugging. The stack is removed automatically, including volumes, unless `E2E_KEEP=1` is set. + +The GitHub Actions workflow is `.github/workflows/mcp-http-e2e.yml`; it runs on MCP/client/workflow changes, on pushes to `main`, and on manual dispatch. + +## License + +MIT diff --git a/packages/crowdsec-mcp/compose.yaml b/packages/crowdsec-mcp/compose.yaml new file mode 100644 index 000000000..48117f27d --- /dev/null +++ b/packages/crowdsec-mcp/compose.yaml @@ -0,0 +1,50 @@ +name: crowdsec + +services: + crowdsec: + image: crowdsecurity/crowdsec:latest + restart: unless-stopped + ports: + # 3939 is published by CrowdSec because the MCP shares its network + # namespace (Docker does not allow `ports` on the sidecar service). + - "8080:8080" + - "3939:3939" + volumes: + - crowdsec-data:/var/lib/crowdsec/data + - crowdsec-config:/etc/crowdsec + + crowdsec-mcp: + image: ghcr.io/thib3113/crowdsec-mcp:latest + # For local builds, uncomment the following and run + # `docker compose -f packages/crowdsec-mcp/compose.yaml up --build`. + # build: + # context: ../.. + # dockerfile: packages/crowdsec-mcp/Dockerfile + restart: unless-stopped + init: true + # `delete_alerts` is local-only on the LAPI, so the MCP must reach it + # through 127.0.0.1. + network_mode: "service:crowdsec" + depends_on: + - crowdsec + environment: + CROWDSEC_URL: http://127.0.0.1:8080 + CROWDSEC_MCP_TRANSPORT: http + CROWDSEC_MCP_HTTP_HOST: 0.0.0.0 + CROWDSEC_MCP_HTTP_PORT: "3939" + CROWDSEC_MCP_PASSTHROUGH: "true" + CROWDSEC_MCP_READONLY: "false" + CROWDSEC_MCP_ENABLE_ALERT_DELETE: "true" + # Optional service gate. Unset/empty disables it. + CROWDSEC_MCP_HTTP_TOKEN: ${CROWDSEC_MCP_HTTP_TOKEN:-} + read_only: true + tmpfs: + - /tmp + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + +volumes: + crowdsec-data: + crowdsec-config: diff --git a/packages/crowdsec-mcp/esbuild.js b/packages/crowdsec-mcp/esbuild.js new file mode 100644 index 000000000..df1247145 --- /dev/null +++ b/packages/crowdsec-mcp/esbuild.js @@ -0,0 +1,40 @@ +import esbuild from 'esbuild'; +import path from 'node:path'; +import fs from 'node:fs'; +import * as url from 'node:url'; + +const __dirname = url.fileURLToPath(new URL('.', import.meta.url)); + +//create pkg file +fs.writeFileSync( + path.join(__dirname, 'src', 'pkg.ts'), + `export const pkg = { name: '${process.env.npm_package_name}', version: '${process.env.npm_package_version}' };\n` +); + +let makeAllPackagesExternalPlugin = { + name: 'make-all-packages-external', + setup(build) { + let filter = /^[^./]|^\.[^./]|^\.\.[^/]/; // Must not start with "/" or "./" or "../" + build.onResolve({ filter }, (args) => ({ path: args.path, external: true })); + } +}; + +const buildOptions = { + entryPoints: ['src/index.ts'], + outfile: path.join(__dirname, 'dist', 'index.js'), + bundle: true, + sourcemap: true, + minify: false, + platform: 'node', + format: 'esm', + target: ['esnext'], + plugins: [makeAllPackagesExternalPlugin] +}; + +if (process.argv.includes('--watch')) { + const context = await esbuild.context(buildOptions); + await context.watch(); + console.log('watching crowdsec-mcp...'); +} else { + await esbuild.build(buildOptions).catch(() => process.exit(1)); +} diff --git a/packages/crowdsec-mcp/package.json b/packages/crowdsec-mcp/package.json new file mode 100644 index 000000000..40bc837a5 --- /dev/null +++ b/packages/crowdsec-mcp/package.json @@ -0,0 +1,78 @@ +{ + "name": "crowdsec-mcp", + "version": "0.1.0", + "description": "Model Context Protocol (MCP) server for CrowdSec: decisions, alerts and allowlists", + "type": "module", + "main": "./dist/index.js", + "bin": { + "crowdsec-mcp": "./dist/index.js" + }, + "exports": { + ".": "./dist/index.js" + }, + "files": [ + "dist" + ], + "homepage": "https://github.com/thib3113/node-crowdsec/tree/main/packages/crowdsec-mcp#readme", + "bugs": { + "url": "https://github.com/thib3113/node-crowdsec/issues?labels=crowdsec-mcp" + }, + "author": { + "email": "thib3113@gmail.com", + "name": "Thibaut Severac" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/thib3113/node-crowdsec.git", + "directory": "packages/crowdsec-mcp" + }, + "keywords": [ + "crowdsec", + "mcp", + "model-context-protocol", + "llm", + "security" + ], + "funding": [ + { + "type": "github", + "url": "https://github.com/thib3113/node-crowdsec" + }, + { + "type": "individual", + "url": "https://paypal.me/thib3113" + } + ], + "license": "MIT", + "engines": { + "node": ">=24" + }, + "scripts": { + "prepack": "npm run build", + "build": "rimraf dist && node ./esbuild.js", + "dev": "node ./esbuild.js --watch", + "start": "node dist/index.js", + "test": "vitest run", + "test:ci": "vitest run", + "test:e2e:http": "bash ../../.github/scripts/mcp-http-e2e.sh", + "test:ci:coverage": "vitest run --coverage", + "lint": "eslint src", + "lint:fix": "npm run lint -- --fix", + "ci:eslint": "npm run lint -- -f json -o ./coverage/eslint-report.json" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "1.32.2", + "crowdsec-client": "workspace:^", + "debug": "^4.4.3", + "zod": "4.6.7" + }, + "devDependencies": { + "@types/debug": "4.1.13", + "@types/node": "26.6.5", + "@vitest/coverage-v8": "5.0.5", + "esbuild": "0.28.2", + "rimraf": "6.1.3", + "typescript": "6.0.3", + "vitest": "5.0.5" + } +} diff --git a/packages/crowdsec-mcp/src/allowlists-port.ts b/packages/crowdsec-mcp/src/allowlists-port.ts new file mode 100644 index 000000000..f4028b747 --- /dev/null +++ b/packages/crowdsec-mcp/src/allowlists-port.ts @@ -0,0 +1,22 @@ +import type { WatcherClient } from 'crowdsec-client'; +import type { AllowlistsPort } from './types.js'; + +const unavailable = (): Promise => Promise.reject(new Error('allowlists are not available in bouncer mode')); + +export function createAllowlistsPort(watcher: WatcherClient): AllowlistsPort { + return { + list: () => watcher.Allowlists.list(), + get: (name, withContent) => watcher.Allowlists.get(name, withContent), + check: (value) => watcher.Allowlists.check(value), + bulkCheck: (targets) => watcher.Allowlists.bulkCheck(targets) + }; +} + +export function unavailableAllowlistsPort(): AllowlistsPort { + return { + list: unavailable, + get: unavailable, + check: unavailable, + bulkCheck: unavailable + }; +} diff --git a/packages/crowdsec-mcp/src/client.ts b/packages/crowdsec-mcp/src/client.ts new file mode 100644 index 000000000..8413e48c2 --- /dev/null +++ b/packages/crowdsec-mcp/src/client.ts @@ -0,0 +1,227 @@ +import { readFile } from 'node:fs/promises'; +import type { IncomingHttpHeaders } from 'node:http'; +import { BouncerClient, CrowdsecClientError, WatcherClient } from 'crowdsec-client'; +import type { Config } from './config.js'; +import type { AppContext } from './types.js'; +import { createAllowlistsPort, unavailableAllowlistsPort } from './allowlists-port.js'; +import { HttpRequestError } from './http-error.js'; + +interface TokenWatcherClientOptions { + url: string; + token: string; + strictSSL?: boolean; + timeout?: number; +} + +function isAuthenticationFailure(error: unknown): boolean { + // CrowdSec answers 403 for an invalid key/token and 401 for a missing one. + // The client wraps 403 as UNEXPECTED_KEY and any other status as + // UNEXPECTED_STATUS_CODE, so the status has to be read from the cause. + // Anything else (DNS, timeout, TLS, 5xx) must keep its original message. + if (!(error instanceof CrowdsecClientError)) { + return false; + } + if (error.message === 'UNEXPECTED_KEY') { + return true; + } + return error.message === 'UNEXPECTED_STATUS_CODE' && /\b(401|403)\b/.test(error.exception?.message ?? ''); +} + +class TokenWatcherClient extends WatcherClient { + public constructor(options: TokenWatcherClientOptions) { + super({ + url: options.url, + auth: { + machineID: 'passthrough', + password: 'passthrough' + }, + strictSSL: options.strictSSL, + timeout: options.timeout, + heartbeat: false + }); + + this.setAuthenticationHeaders({ + Authorization: `Bearer ${options.token}` + }); + } + + public override async login(): Promise { + try { + await this.testConnection(); + } catch (error) { + if (isAuthenticationFailure(error)) { + throw new Error('Invalid CrowdSec token'); + } + throw error; + } + } +} + +export type PassthroughAuth = { mode: 'bouncer'; apiKey: string } | { mode: 'watcher'; token: string }; + +function headerValue(headers: IncomingHttpHeaders, name: string): string | undefined { + const value = headers[name]; + return Array.isArray(value) ? value[0] : value; +} + +export function resolvePassthroughAuth(headers: IncomingHttpHeaders): PassthroughAuth { + const apiKey = headerValue(headers, 'x-crowdsec-api-key')?.trim(); + const token = headerValue(headers, 'x-crowdsec-token')?.trim(); + + if (apiKey && token) { + throw new HttpRequestError(400, -32602, 'Provide either X-CrowdSec-Api-Key or X-CrowdSec-Token, not both'); + } + if (apiKey) { + return { mode: 'bouncer', apiKey }; + } + if (token) { + return { mode: 'watcher', token }; + } + + throw new HttpRequestError(401, -32001, 'Missing X-CrowdSec-Api-Key or X-CrowdSec-Token'); +} + +async function loadTls(config: Config) { + if (!config.tls) { + return undefined; + } + + const [cert, key, ca] = await Promise.all([readFile(config.tls.cert), readFile(config.tls.key), readFile(config.tls.ca)]); + + return { cert, key, ca }; +} + +export async function createContext(config: Config): Promise { + const tls = await loadTls(config); + + let watcher: WatcherClient | undefined; + let bouncer: BouncerClient | undefined; + + if (config.auth === 'bouncer') { + bouncer = new BouncerClient({ + url: config.url, + auth: tls ?? { apiKey: config.apiKey! }, + strictSSL: config.strictSSL, + timeout: config.timeout + }); + } else { + watcher = new WatcherClient({ + url: config.url, + auth: tls ?? { + machineID: config.machineId!, + password: config.password!, + autoRenew: true + }, + strictSSL: config.strictSSL, + timeout: config.timeout, + heartbeat: false + }); + } + + let loginPromise: Promise | undefined; + + const ensureReady = async (): Promise => { + loginPromise ??= (watcher ?? bouncer)!.login(); + + try { + await loginPromise; + } catch (error) { + loginPromise = undefined; + throw error; + } + }; + + const stop = async (): Promise => { + await (watcher ?? bouncer)?.stop(); + }; + + return { + mode: config.auth, + capabilities: { + read: true, + write: config.auth === 'watcher', + allowlists: config.auth === 'watcher' + }, + watcher, + bouncer, + allowlists: watcher ? createAllowlistsPort(watcher) : unavailableAllowlistsPort(), + config, + ensureReady, + stop + }; +} + +export function createPassthroughContext(config: Config, headers: IncomingHttpHeaders): AppContext { + const auth = resolvePassthroughAuth(headers); + + if (auth.mode === 'bouncer') { + const bouncer = new BouncerClient({ + url: config.url, + auth: { apiKey: auth.apiKey }, + strictSSL: config.strictSSL, + timeout: config.timeout + }); + + let loginPromise: Promise | undefined; + const ensureReady = async (): Promise => { + loginPromise ??= bouncer.login(); + + try { + await loginPromise; + } catch (error) { + loginPromise = undefined; + if (isAuthenticationFailure(error)) { + throw new Error('Invalid CrowdSec API key'); + } + throw error; + } + }; + + return { + mode: 'bouncer', + capabilities: { + read: true, + write: false, + allowlists: false + }, + bouncer, + allowlists: unavailableAllowlistsPort(), + config, + ensureReady, + stop: () => bouncer.stop() + }; + } + + const watcher = new TokenWatcherClient({ + url: config.url, + token: auth.token, + strictSSL: config.strictSSL, + timeout: config.timeout + }); + + let loginPromise: Promise | undefined; + const ensureReady = async (): Promise => { + loginPromise ??= watcher.login(); + + try { + await loginPromise; + } catch (error) { + loginPromise = undefined; + throw error; + } + }; + + return { + mode: 'watcher', + capabilities: { + read: true, + write: true, + allowlists: true + }, + watcher, + allowlists: createAllowlistsPort(watcher), + config, + ensureReady, + stop: () => watcher.stop() + }; +} diff --git a/packages/crowdsec-mcp/src/config.ts b/packages/crowdsec-mcp/src/config.ts new file mode 100644 index 000000000..afec54fd7 --- /dev/null +++ b/packages/crowdsec-mcp/src/config.ts @@ -0,0 +1,371 @@ +import { parseArgs } from 'node:util'; +import { parseDuration, splitList } from './utils.js'; +import { NAME, VERSION } from './version.js'; + +export type AuthMode = 'bouncer' | 'watcher'; +export type TransportMode = 'stdio' | 'http'; + +export interface TlsConfig { + cert: string; + key: string; + ca: string; +} + +export interface HttpConfig { + host: string; + port: number; + token?: string; +} + +export interface Config { + url: string; + auth: AuthMode; + apiKey?: string; + machineId?: string; + password?: string; + tls?: TlsConfig; + strictSSL: boolean; + timeout: number; + transport: TransportMode; + http: HttpConfig; + passthrough: boolean; + readonly: boolean; + enableAlertDelete: boolean; + allowAlertDeleteWithoutToken: boolean; + tools?: string[]; + disableTools: string[]; + maxResults: number; + maxBanDuration: string; + maxBanDurationSeconds: number; +} + +const cliOptions = { + help: { type: 'boolean', short: 'h' }, + version: { type: 'boolean', short: 'v' }, + url: { type: 'string' }, + auth: { type: 'string' }, + 'api-key': { type: 'string' }, + 'machine-id': { type: 'string' }, + password: { type: 'string' }, + 'tls-cert': { type: 'string' }, + 'tls-key': { type: 'string' }, + 'tls-ca': { type: 'string' }, + insecure: { type: 'boolean' }, + timeout: { type: 'string' }, + transport: { type: 'string' }, + 'http-host': { type: 'string' }, + 'http-port': { type: 'string' }, + 'http-token': { type: 'string' }, + readonly: { type: 'boolean' }, + write: { type: 'boolean' }, + passthrough: { type: 'boolean' }, + 'enable-alert-delete': { type: 'boolean' }, + 'allow-alert-delete-without-token': { type: 'boolean' }, + tools: { type: 'string' }, + 'disable-tools': { type: 'string' }, + 'max-results': { type: 'string' }, + 'max-ban-duration': { type: 'string' } +} as const; + +export type CliValues = ReturnType['values']; + +export function parseCliArgs(argv = process.argv.slice(2)) { + return parseArgs({ + args: argv, + options: cliOptions, + strict: true, + allowPositionals: false + }); +} + +function readBoolean(value: string | undefined, name: string, defaultValue: boolean): boolean { + if (value === undefined || value === '') { + return defaultValue; + } + + switch (value.toLowerCase()) { + case '1': + case 'true': + case 'yes': + case 'on': + return true; + case '0': + case 'false': + case 'no': + case 'off': + return false; + default: + throw new Error(`${name} must be a boolean (true/false), got '${value}'`); + } +} + +function readInteger(value: string | undefined, name: string, defaultValue: number, min: number, max: number): number { + if (value === undefined || value === '') { + return defaultValue; + } + + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < min || parsed > max) { + throw new Error(`${name} must be an integer between ${min} and ${max}, got '${value}'`); + } + + return parsed; +} + +function firstDefined(...values: Array): string | undefined { + return values.find((value) => value !== undefined && value !== ''); +} + +function requireUrl(values: CliValues, env: NodeJS.ProcessEnv): string { + const url = firstDefined(values.url as string | undefined, env.CROWDSEC_URL); + if (!url) { + throw new Error('CROWDSEC_URL (or --url) is required'); + } + if (!/^https?:\/\//i.test(url)) { + throw new Error(`CROWDSEC_URL must start with http:// or https://, got '${url}'`); + } + return url; +} + +function resolveAuth( + requestedAuth: string | undefined, + apiKey: string | undefined, + machineId: string | undefined, + password: string | undefined, + passthrough: boolean, + hasTls: boolean +): AuthMode { + if (requestedAuth === 'bouncer' || requestedAuth === 'watcher') { + return requestedAuth; + } + if (requestedAuth !== undefined) { + throw new Error(`CROWDSEC_AUTH must be 'bouncer' or 'watcher', got '${requestedAuth}'`); + } + if (apiKey) { + return 'bouncer'; + } + if (machineId || password) { + return 'watcher'; + } + if (passthrough) { + // The env credentials are not used in passthrough mode; keep a valid + // mode for the shared Config shape. + return 'watcher'; + } + if (hasTls) { + // With mutual TLS alone we cannot tell a bouncer certificate from an + // agent one, so default to the richer watcher mode. Set + // CROWDSEC_AUTH=bouncer when the certificate belongs to a bouncer. + return 'watcher'; + } + throw new Error( + 'unable to detect authentication mode: set CROWDSEC_API_KEY, CROWDSEC_MACHINE_ID/CROWDSEC_PASSWORD, TLS certificates, or CROWDSEC_AUTH' + ); +} + +function resolveTls(cert: string | undefined, key: string | undefined, ca: string | undefined): Config['tls'] { + const tlsCount = [cert, key, ca].filter(Boolean).length; + if (tlsCount !== 0 && tlsCount !== 3) { + throw new Error('TLS authentication requires CROWDSEC_TLS_CERT, CROWDSEC_TLS_KEY and CROWDSEC_TLS_CA together'); + } + return tlsCount === 3 ? { cert: cert!, key: key!, ca: ca! } : undefined; +} + +function validateCredentials( + passthrough: boolean, + auth: AuthMode, + apiKey: string | undefined, + tls: Config['tls'], + machineId: string | undefined, + password: string | undefined +): void { + if (!passthrough && auth === 'bouncer' && !apiKey && !tls) { + throw new Error('bouncer authentication requires CROWDSEC_API_KEY or TLS certificates'); + } + if (!passthrough && auth === 'watcher' && !tls && (!machineId || !password)) { + throw new Error('watcher authentication requires CROWDSEC_MACHINE_ID and CROWDSEC_PASSWORD (or TLS certificates)'); + } +} + +function resolveTransport(values: CliValues, env: NodeJS.ProcessEnv, passthrough: boolean): 'stdio' | 'http' { + const transport = (firstDefined(values.transport as string | undefined, env.CROWDSEC_MCP_TRANSPORT) ?? 'stdio').toLowerCase(); + if (transport !== 'stdio' && transport !== 'http') { + throw new Error(`CROWDSEC_MCP_TRANSPORT must be 'stdio' or 'http', got '${transport}'`); + } + if (passthrough && transport !== 'http') { + throw new Error('passthrough mode requires CROWDSEC_MCP_TRANSPORT=http'); + } + return transport; +} + +function validateHttpToken(transport: 'stdio' | 'http', httpToken: string | undefined, passthrough: boolean): void { + if (transport === 'http' && !httpToken && !passthrough) { + throw new Error('HTTP transport requires CROWDSEC_MCP_HTTP_TOKEN (or --http-token)'); + } +} + +function readBooleanOption(cliValue: boolean | undefined, envValue: string | undefined, name: string, defaultValue: boolean): boolean { + return cliValue === true ? true : readBoolean(envValue, name, defaultValue); +} + +function resolveReadonly(values: CliValues, env: NodeJS.ProcessEnv): boolean { + if (values.write === true) { + return false; + } + if (values.readonly === true) { + return true; + } + return readBoolean(env.CROWDSEC_MCP_READONLY, 'CROWDSEC_MCP_READONLY', true); +} + +export function loadConfig(values: CliValues, env: NodeJS.ProcessEnv = process.env): Config { + const url = requireUrl(values, env); + const apiKey = firstDefined(values['api-key'] as string | undefined, env.CROWDSEC_API_KEY); + const machineId = firstDefined(values['machine-id'] as string | undefined, env.CROWDSEC_MACHINE_ID); + const password = firstDefined(values.password as string | undefined, env.CROWDSEC_PASSWORD); + const passthrough = values.passthrough === true || readBoolean(env.CROWDSEC_MCP_PASSTHROUGH, 'CROWDSEC_MCP_PASSTHROUGH', false); + + const requestedAuth = firstDefined(values.auth as string | undefined, env.CROWDSEC_AUTH)?.toLowerCase(); + + const tls = resolveTls( + firstDefined(values['tls-cert'] as string | undefined, env.CROWDSEC_TLS_CERT), + firstDefined(values['tls-key'] as string | undefined, env.CROWDSEC_TLS_KEY), + firstDefined(values['tls-ca'] as string | undefined, env.CROWDSEC_TLS_CA) + ); + const auth = resolveAuth(requestedAuth, apiKey, machineId, password, passthrough, Boolean(tls)); + validateCredentials(passthrough, auth, apiKey, tls, machineId, password); + + const transport = resolveTransport(values, env, passthrough); + const httpToken = firstDefined(values['http-token'] as string | undefined, env.CROWDSEC_MCP_HTTP_TOKEN); + validateHttpToken(transport, httpToken, passthrough); + + if (values.readonly === true && values.write === true) { + throw new Error('--readonly and --write cannot be used together'); + } + + const readonly = resolveReadonly(values, env); + const enableAlertDelete = readBooleanOption( + values['enable-alert-delete'] === true, + env.CROWDSEC_MCP_ENABLE_ALERT_DELETE, + 'CROWDSEC_MCP_ENABLE_ALERT_DELETE', + false + ); + const allowAlertDeleteWithoutToken = readBooleanOption( + values['allow-alert-delete-without-token'] === true, + env.CROWDSEC_MCP_ALLOW_ALERT_DELETE_WITHOUT_TOKEN, + 'CROWDSEC_MCP_ALLOW_ALERT_DELETE_WITHOUT_TOKEN', + false + ); + + const strictSSL = values.insecure === true ? false : readBoolean(env.CROWDSEC_STRICT_SSL, 'CROWDSEC_STRICT_SSL', true); + const timeout = readInteger((values.timeout as string | undefined) ?? env.CROWDSEC_TIMEOUT, 'CROWDSEC_TIMEOUT', 10000, 1, 600000); + const httpPort = readInteger( + (values['http-port'] as string | undefined) ?? env.CROWDSEC_MCP_HTTP_PORT, + 'CROWDSEC_MCP_HTTP_PORT', + 3939, + 1, + 65535 + ); + const maxResults = readInteger( + (values['max-results'] as string | undefined) ?? env.CROWDSEC_MCP_MAX_RESULTS, + 'CROWDSEC_MCP_MAX_RESULTS', + 25, + 1, + 10000 + ); + + const maxBanDuration = firstDefined(values['max-ban-duration'] as string | undefined, env.CROWDSEC_MCP_MAX_BAN_DURATION) ?? '24h'; + const maxBanDurationSeconds = parseDuration(maxBanDuration); + + return { + url, + auth, + apiKey, + machineId, + password, + tls, + strictSSL, + timeout, + transport, + http: { + host: firstDefined(values['http-host'] as string | undefined, env.CROWDSEC_MCP_HTTP_HOST) ?? '127.0.0.1', + port: httpPort, + token: httpToken + }, + passthrough, + readonly, + enableAlertDelete, + allowAlertDeleteWithoutToken, + tools: splitList(firstDefined(values.tools as string | undefined, env.CROWDSEC_MCP_TOOLS)), + disableTools: splitList(firstDefined(values['disable-tools'] as string | undefined, env.CROWDSEC_MCP_DISABLE_TOOLS)) ?? [], + maxResults, + maxBanDuration, + maxBanDurationSeconds + }; +} + +export function isAlertDeleteAvailable(config: Config): boolean { + if (!config.enableAlertDelete) { + return false; + } + + // HTTP is reachable over the network: without a service token, delete_alerts + // would expose the local-only destructive path to any client. Keep it hidden + // unless the operator explicitly overrides this with the dedicated flag. + if (config.transport === 'http' && !config.http.token && !config.allowAlertDeleteWithoutToken) { + return false; + } + + return true; +} + +export function redactedUrl(url: string): string { + try { + const parsed = new URL(url); + if (parsed.username) parsed.username = '***'; + if (parsed.password) parsed.password = '***'; + return parsed.toString(); + } catch { + return url; + } +} + +export function printHelp(): void { + process.stdout.write(`Usage: ${NAME} [options] + +Connection: + --url CrowdSec LAPI URL (env: CROWDSEC_URL) + --auth Authentication mode (env: CROWDSEC_AUTH) + --api-key Bouncer API key (env: CROWDSEC_API_KEY) + --machine-id Watcher machine ID (env: CROWDSEC_MACHINE_ID) + --password Watcher password (env: CROWDSEC_PASSWORD) + --tls-cert Client certificate (env: CROWDSEC_TLS_CERT) + --tls-key Client key (env: CROWDSEC_TLS_KEY) + --tls-ca CA certificate (env: CROWDSEC_TLS_CA) + --insecure Disable TLS certificate validation + --timeout HTTP timeout, default 10000 + +MCP: + --transport Transport, default stdio (env: CROWDSEC_MCP_TRANSPORT) + --http-host HTTP host, default 127.0.0.1 + --http-port HTTP port, default 3939 + --http-token HTTP bearer token (required for HTTP) + --readonly Force read-only mode (default) + --write Enable write tools + --passthrough Use per-request LAPI credentials (HTTP only) + --enable-alert-delete Expose delete_alerts (only works next to the LAPI) + --allow-alert-delete-without-token + Allow delete_alerts over HTTP without a service token (dangerous) + --tools Only expose these tools + --disable-tools Hide these tools + --max-results Default result limit, default 25 + --max-ban-duration Maximum ban duration without force, default 24h + + -h, --help Show this help + -v, --version Show the version +`); +} + +export function printVersion(): void { + process.stdout.write(`${NAME} ${VERSION}\n`); +} diff --git a/packages/crowdsec-mcp/src/docs.ts b/packages/crowdsec-mcp/src/docs.ts new file mode 100644 index 000000000..feec1a5d8 --- /dev/null +++ b/packages/crowdsec-mcp/src/docs.ts @@ -0,0 +1,309 @@ +import type { AppContext } from './types.js'; +import { isAlertDeleteAvailable } from './config.js'; + +export const DOC_BASE_URL = 'https://docs.crowdsec.net'; + +export const DOC_LINKS = { + intro: `${DOC_BASE_URL}/docs/intro/`, + concepts: `${DOC_BASE_URL}/docs/concepts/`, + lapi: `${DOC_BASE_URL}/docs/local_api/intro/`, + bouncers: `${DOC_BASE_URL}/u/bouncers/intro/`, + scenarios: `${DOC_BASE_URL}/docs/log_processor/scenarios/intro/`, + communityBlocklist: `${DOC_BASE_URL}/docs/central_api/community_blocklist/`, + allowlists: `${DOC_BASE_URL}/docs/local_api/centralized_allowlists/`, + cscliAlerts: `${DOC_BASE_URL}/docs/cscli/cscli_alerts/`, + cscliDecisions: `${DOC_BASE_URL}/docs/cscli/cscli_decisions/`, + cscliAllowlists: `${DOC_BASE_URL}/docs/cscli/cscli_allowlists/` +} as const; + +export interface DocResource { + uri: string; + name: string; + title: string; + description: string; + render: (ctx: AppContext) => string; +} + +function authenticationSummary(ctx: AppContext): string { + if (ctx.mode === 'watcher') { + const machine = ctx.config.machineId ? ` with machine ${ctx.config.machineId}` : ''; + return `watcher${machine} (machine credentials): alerts, decisions, allowlists and, unless read-only, write tools are available.`; + } + + return 'bouncer (API key): only active decisions are visible. Alerts, allowlists and write tools are unavailable.'; +} + +function writeSummary(ctx: AppContext): string { + if (ctx.mode === 'bouncer') { + return 'bouncer mode (read-only): no write tools (ban_ip and unban_ip require watcher credentials).'; + } + + return ctx.config.readonly + ? 'read-only: ban_ip and unban_ip are hidden.' + : 'read/write: ban_ip and unban_ip are enabled and change real remediation.'; +} + +function canDeleteAlerts(ctx: AppContext): boolean { + // delete_alerts is a write tool: it also requires watcher credentials and + // read-only mode disables every write tool. + return ctx.mode === 'watcher' && !ctx.config.readonly && isAlertDeleteAvailable(ctx.config); +} + +function availabilityLines(ctx: AppContext): string[] { + return [ + `- Alerts (list_alerts, get_alert): ${ctx.mode === 'watcher' ? 'available' : 'unavailable (watcher credentials required)'}.`, + `- Decisions (list_decisions): available. get_decision: ${ctx.mode === 'watcher' ? 'available' : 'unavailable (watcher credentials required)'}.`, + `- Allowlists: ${ctx.capabilities.allowlists ? 'available' : 'unavailable in this mode'}.`, + `- delete_alerts: ${canDeleteAlerts(ctx) ? 'available (destructive, only works on the LAPI host)' : 'hidden'}.` + ]; +} + +function workingRules(ctx: AppContext): string[] { + const rules: string[] = []; + const add = (rule: string): void => { + rules.push(`${rules.length + 1}. ${rule}`); + }; + + add('Call crowdsec_status when the connection, mode or active tools are unclear.'); + + if (ctx.mode === 'bouncer') { + add( + 'Only active decisions are visible: use list_decisions. get_decision, alerts and allowlists need watcher credentials: say so instead of calling them.' + ); + add('A decision origin of CAPI comes from the community blocklist and is not necessarily local to this engine.'); + add('Never invent decision IDs. If a lookup returns nothing, say so instead of guessing.'); + return rules; + } + + add('Investigate before acting: list_alerts -> get_alert -> list_decisions/get_decision for the same source.'); + + if (ctx.config.readonly) { + add('Write tools are hidden: analyse and recommend, but never claim that a ban or unban was applied.'); + } else { + const destructive = canDeleteAlerts(ctx) ? 'ban_ip, unban_ip and delete_alerts' : 'ban_ip and unban_ip'; + add(`${destructive} change real state: summarise the target, type and duration and wait for user confirmation.`); + add('Durations need a unit (30m, 4h, 7d). Durations above the limit require force=true and explicit approval.'); + add('ban_ip refuses allowlisted targets unless force=true; check_allowlist first when in doubt.'); + add('unban_ip with only type/scenario filters can delete many decisions and requires confirm=true.'); + add('delete_alerts without a narrow target (ip, range, or scope+value) requires confirm=true.'); + } + + add('Never invent alert or decision IDs. If a lookup returns nothing, say so instead of guessing.'); + + if (canDeleteAlerts(ctx)) { + add('Deleting an alert also deletes its active decisions (LAPI cascade); do not unban them afterwards.'); + } + + return rules; +} + +export function buildInstructions(ctx: AppContext): string { + const limits = + ctx.mode === 'watcher' && !ctx.config.readonly + ? [`Limits: bans longer than ${ctx.config.maxBanDuration} are refused unless force=true.`] + : []; + + return [ + 'CrowdSec MCP exposes alerts, decisions and allowlists from a CrowdSec Local API (LAPI).', + '', + 'How CrowdSec works: the Log Processor detects behaviour and creates an alert; LAPI profiles turn alerts into', + 'decisions (ban, captcha, ...); remediation components (bouncers) pull decisions and enforce them. Alerts are the', + 'history of what was detected, decisions are the active remediation. An allowlist exempts a source from decisions.', + '', + `Authentication: ${authenticationSummary(ctx)}`, + `Write access: ${writeSummary(ctx)}`, + ...limits, + ...availabilityLines(ctx), + '', + 'Working rules:', + ...workingRules(ctx), + '', + `Documentation: crowdsec://docs/overview, crowdsec://docs/concepts, crowdsec://docs/triage. Upstream: ${DOC_LINKS.concepts}` + ].join('\n'); +} + +const OVERVIEW = (ctx: AppContext): string => + [ + '# CrowdSec overview', + '', + 'CrowdSec is an open-source security engine: it detects malicious behaviour in logs and HTTP traffic, stores what', + 'it sees, and lets remediation components block the source.', + '', + '## Components', + '', + '- Log Processor (LP): acquires logs, parses and enriches events, then matches them against scenarios (and AppSec', + ' rules for HTTP traffic). A match creates an alert.', + '- Local API (LAPI): stores alerts and turns them into decisions according to profiles, then serves decisions to', + ' bouncers. This MCP server talks to the LAPI.', + '- Remediation Components (bouncers): pull decisions and enforce them (firewall, reverse proxy, web server, ...).', + ' Some support captcha or throttle in addition to ban.', + '- Central API (CAPI): shares signals and redistributes curated community decisions (community blocklist).', + '- Console: hosted web UI for alerts, decisions and blocklists.', + '', + '## Lifecycle of one detection', + '', + '1. A log line or HTTP request is acquired.', + '2. Parsers extract fields (source IP, user, status, ...) and enrichers add context (GeoIP, ASN, ...).', + '3. A scenario correlates events over a time window and triggers an alert.', + '4. The LAPI stores the alert and applies profiles, creating a decision (for example ban for 4h).', + '5. A bouncer reads the decision and enforces it until it expires.', + '', + '## What this MCP server exposes', + '', + '- Alerts: detection history (list_alerts, get_alert).', + '- Decisions: active remediation (list_decisions; get_decision, ban_ip and unban_ip require watcher credentials).', + '- Allowlists: exemptions that prevent remediation (list_allowlists, get_allowlist, check_allowlist).', + '- crowdsec_status: connection mode, capabilities and limits.', + '', + 'Banning manually creates a decision, not just an alert. An alert alone does not block anything.', + '', + '## Current server configuration', + '', + `- ${authenticationSummary(ctx)}`, + `- ${writeSummary(ctx)}`, + ...availabilityLines(ctx), + '', + '## Upstream documentation', + '', + `- Introduction: ${DOC_LINKS.intro}`, + `- Concepts: ${DOC_LINKS.concepts}`, + `- Local API: ${DOC_LINKS.lapi}`, + `- Remediation components (bouncers): ${DOC_LINKS.bouncers}`, + `- Scenarios: ${DOC_LINKS.scenarios}`, + `- Central API and community blocklist: ${DOC_LINKS.communityBlocklist}` + ].join('\n'); + +const CONCEPTS = (): string => + [ + '# CrowdSec concepts', + '', + '## Alert vs decision', + '', + 'An alert records that a scenario (or AppSec rule) matched. It carries events, a start/stop time and metadata.', + 'A decision is a remediation instruction (type, scope, value, duration) derived from an alert by a profile or', + 'created manually. Bouncers only consume decisions, so creating an alert never blocks traffic by itself.', + '', + '## Detection content', + '', + '- Parser: extracts fields from a log line.', + '- Enricher: adds context (GeoIP, ASN, ...).', + '- Scenario: correlates events over time to detect behaviour (for example many failed logins).', + '- Collection: bundle of parsers and scenarios installed together from the Hub.', + '', + '## Decision attributes', + '', + '- scope: Ip, Range, Username, Session, Country or AS. Most deployments only use Ip and Range.', + '- type: ban in most setups; captcha and throttle exist but are only enforced by bouncers that support them.', + '- duration: human duration with a unit (30m, 4h, 7d). A decision disappears when it expires.', + '- origin: cscli (manual), crowdsec (local scenario), CAPI (community blocklist), console, lists. It tells you', + ' whether a block is local or shared with the CrowdSec network.', + '- simulated: a decision that is recorded but not enforced. Use no_simulated=true to hide these.', + '', + '## Allowlists', + '', + 'Centralized allowlists exempt IPs and CIDR ranges from decisions. ban_ip refuses an allowlisted target unless', + 'force=true, and check_allowlist reports whether a value matches an entry or a containing range.', + '', + '## Authentication: bouncer vs watcher', + '', + '- bouncer: API key. Can only read decisions, and only those the bouncer is allowed to see.', + '- watcher: machine ID and password (or TLS certificates). Full access to alerts and decisions, plus allowlists', + ' and write operations depending on the server configuration.', + '', + '## Upstream documentation', + '', + `- Concepts: ${DOC_LINKS.concepts}`, + `- Local API: ${DOC_LINKS.lapi}`, + `- Centralized allowlists: ${DOC_LINKS.allowlists}` + ].join('\n'); + +function triageModeLines(ctx: AppContext): string[] { + if (ctx.mode === 'bouncer') { + return [ + '## Mode caveats', + '', + 'This server runs in bouncer mode: alerts and allowlists are unavailable, only active decisions can be read.', + 'Work from list_decisions, and say when get_decision, alerts or allowlists would need watcher credentials.' + ]; + } + + const lines = ['## Mode caveats', '', 'This server runs in watcher mode: alerts, decisions and allowlists are available.']; + + if (ctx.config.readonly) { + lines.push('Write tools are disabled (read-only): analyse and recommend, but do not claim to have banned or unbanned anything.'); + } else { + lines.push('Write tools are enabled: always get confirmation before ban_ip or unban_ip.'); + } + + return lines; +} + +const TRIAGE = (ctx: AppContext): string => + [ + '# Alert and decision triage', + '', + 'Goal: go from an alert to a justified action, with the right scope, type and duration.', + '', + '## 1. Look at what fired', + '', + 'Use list_alerts to see the detection history. Useful filters: since/until, ip or range (CIDR containment),', + 'scenario, origin, kind, has_active_decision and type. Then use get_alert for events and metadata of one alert.', + '', + '## 2. Check the current remediation', + '', + 'Use list_decisions with the same ip/range to see what is already blocked, and get_decision (watcher only) for context.', + 'An already active ban is often enough: avoid stacking duplicate decisions.', + '', + '## 3. Decide', + '', + '- Confirmed or repeated malicious behaviour: ban_ip on scope Ip, or scope Range when a whole prefix is involved.', + '- Ambiguous or low volume: prefer captcha when the bouncer supports it, or report without acting.', + '- False positive: unban_ip, then ask the operator to add the source with `cscli allowlists ...` or the CrowdSec Console; this MCP is read/check only for allowlists.', + '', + '## 4. Guardrails', + '', + '- Confirm with the user before ban_ip, unban_ip and delete_alerts.', + '- Durations need a unit. Durations above the configured maximum require force=true and explicit approval.', + '- ban_ip refuses allowlisted targets unless force=true.', + '- Deleting an alert also deletes its active decisions (LAPI cascade); do not unban them afterwards.', + '- delete_alerts is destructive and only works when this server runs on the LAPI host.', + '', + ...triageModeLines(ctx), + '', + '## cscli equivalents', + '', + `- list_alerts ~ cscli alerts list (${DOC_LINKS.cscliAlerts})`, + `- list_decisions / get_decision ~ cscli decisions list (${DOC_LINKS.cscliDecisions})`, + `- ban_ip ~ cscli decisions add, unban_ip ~ cscli decisions delete (${DOC_LINKS.cscliDecisions})`, + `- list_allowlists / check_allowlist ~ cscli allowlists (${DOC_LINKS.cscliAllowlists})`, + '', + '## Upstream documentation', + '', + `- Concepts: ${DOC_LINKS.concepts}`, + `- Centralized allowlists: ${DOC_LINKS.allowlists}`, + `- Remediation components: ${DOC_LINKS.bouncers}` + ].join('\n'); + +export const docResources: DocResource[] = [ + { + uri: 'crowdsec://docs/overview', + name: 'crowdsec_overview', + title: 'CrowdSec overview', + description: 'How CrowdSec works: components, detection lifecycle, and what this MCP server exposes.', + render: OVERVIEW + }, + { + uri: 'crowdsec://docs/concepts', + name: 'crowdsec_concepts', + title: 'CrowdSec concepts', + description: 'Glossary and semantics: alert vs decision, scopes, types, durations, origins, allowlists, bouncer vs watcher.', + render: CONCEPTS + }, + { + uri: 'crowdsec://docs/triage', + name: 'crowdsec_triage', + title: 'Alert and decision triage', + description: 'Recommended workflow to go from an alert to a confirmed ban, unban or allowlist entry, with guardrails.', + render: TRIAGE + } +]; diff --git a/packages/crowdsec-mcp/src/format/alerts.ts b/packages/crowdsec-mcp/src/format/alerts.ts new file mode 100644 index 000000000..741fa8688 --- /dev/null +++ b/packages/crowdsec-mcp/src/format/alerts.ts @@ -0,0 +1,217 @@ +import type { APITypes } from 'crowdsec-client'; +import { resolveDecisionExpiry } from '../utils.js'; +import { escapeCell, formatDate, formatDecisionExpiry, markdownTable, moreHint, plural, relativeTime, truncateRows } from './common.js'; + +function formatListHint(hidden: number, maybeMore: boolean, total: number, maxResults: number): string { + if (hidden > 0) { + return moreHint(hidden, 'list_alerts'); + } + if (maybeMore && total >= maxResults) { + return '\n_More results may be available. Increase `limit` or narrow the filters._'; + } + return ''; +} + +function formatDecisionsSummary(decisions: APITypes.Alert['decisions']): string { + if (!decisions || decisions.length === 0) { + return '-'; + } + + const counts = new Map(); + + for (const d of decisions) { + if (!resolveDecisionExpiry(d).active) { + continue; + } + + const type = d.type ?? 'unknown'; + const isSimulated = d.simulated === true; + const entry = counts.get(type) ?? { real: 0, simulated: 0 }; + if (isSimulated) { + entry.simulated += 1; + } else { + entry.real += 1; + } + counts.set(type, entry); + } + + const parts: string[] = []; + for (const [type, entry] of Array.from(counts.entries()).sort(([a], [b]) => a.localeCompare(b))) { + if (entry.real > 0) { + parts.push(`${type}:${entry.real}`); + } + if (entry.simulated > 0) { + parts.push(`(simul)${type}:${entry.simulated}`); + } + } + + return parts.length > 0 ? parts.join(' ') : '-'; +} + +export function formatAlertList(alerts: APITypes.GetAlertsResponse, maxResults: number, maybeMore = false): string { + const sortedAlerts = [...alerts].sort((a, b) => { + const aTime = a.created_at ? Date.parse(a.created_at) || 0 : 0; + const bTime = b.created_at ? Date.parse(b.created_at) || 0 : 0; + return bTime - aTime || (b.id ?? 0) - (a.id ?? 0); + }); + const { rows: displayRows, hidden } = truncateRows(sortedAlerts, maxResults); + + if (displayRows.length === 0) { + return '_No alerts found._'; + } + + const headers = ['ID', 'Scope', 'Value', 'Reason', 'Country', 'AS', 'Decisions', 'Created at', 'Kind']; + const tableRows = displayRows.map((alert) => { + const source = alert.source; + const country = source.cn ?? '-'; + const as = source.as_name ? `${source.as_number} (${source.as_name})` : (source.as_number ?? '-'); + const decisionsSummary = formatDecisionsSummary(alert.decisions); + const createdAt = formatDate(alert.created_at); + const kind = alert.kind ?? '-'; + + return [ + escapeCell(alert.id ?? '-'), + escapeCell(source.scope), + escapeCell(source.value), + escapeCell(alert.message ?? '-'), + escapeCell(country), + escapeCell(as), + escapeCell(decisionsSummary), + escapeCell(createdAt), + escapeCell(kind) + ]; + }); + + const summary = plural(displayRows.length, 'alert'); + const hint = formatListHint(hidden, maybeMore, sortedAlerts.length, maxResults); + + return `${summary}:\n\n${markdownTable(headers, tableRows)}${hint}`; +} + +function formatDecisionRow(decision: APITypes.Decision): string[] { + const type = decision.type ?? '-'; + const scope = decision.scope ?? '-'; + const value = decision.value ?? '-'; + const origin = decision.origin ?? '-'; + const duration = decision.duration ?? '-'; + const expiryInfo = resolveDecisionExpiry(decision); + const until = formatDecisionExpiry(expiryInfo); + const simulated = decision.simulated === true ? 'yes' : 'no'; + + return [ + escapeCell(decision.id ?? '-'), + escapeCell(type), + escapeCell(scope), + escapeCell(value), + escapeCell(origin), + escapeCell(duration), + escapeCell(until), + escapeCell(simulated) + ]; +} + +function formatMeta(meta?: APITypes.Alert['meta']): string { + if (!meta || meta.length === 0) { + return ''; + } + + const headers = ['Key', 'Value']; + const rows = meta.map((m) => { + let value = m.value ?? '-'; + // Try to parse JSON arrays/objects for readability + try { + const parsed = JSON.parse(value); + if (Array.isArray(parsed)) { + value = parsed.map(String).join(', '); + } else if (typeof parsed === 'object' && parsed !== null) { + value = JSON.stringify(parsed); + } + } catch { + // Not JSON, keep as is + } + return [escapeCell(m.key ?? '-'), escapeCell(value)]; + }); + + return `\n### Meta\n\n${markdownTable(headers, rows)}`; +} + +function eventMetaKeys(meta: unknown): string { + if (!meta) { + return '-'; + } + if (Array.isArray(meta)) { + const keys = (meta as Array<{ key?: unknown }>).map((m) => (typeof m?.key === 'string' ? m.key : '')).filter(Boolean); + return keys.length > 0 ? keys.join(', ') : '-'; + } + if (typeof meta === 'object') { + const keys = Object.keys(meta as Record); + return keys.length > 0 ? keys.join(', ') : '-'; + } + return '-'; +} + +function formatEvents(events?: APITypes.Alert['events']): string { + if (!events || events.length === 0) { + return ''; + } + + const headers = ['Timestamp', 'Meta Summary']; + const rows = events.slice(0, 10).map((e) => { + const timestamp = formatDate(e.timestamp); + const metaKeys = eventMetaKeys(e.meta as unknown); + return [escapeCell(timestamp), escapeCell(metaKeys)]; + }); + + let result = `\n### Events (${plural(events.length, 'event')})\n\n${markdownTable(headers, rows)}`; + if (events.length > 10) { + result += `\n\n_... and ${events.length - 10} more event(s)._`; + } + return result; +} + +export function formatAlertDetail(alert: APITypes.Alert): string { + const source = alert.source; + const country = source.cn ?? '-'; + const as = source.as_name ? `${source.as_number} (${source.as_name})` : (source.as_number ?? '-'); + + const lines: string[] = [ + `### Alert #${alert.id ?? '-'}`, + '', + `- **Date:** ${formatDate(alert.created_at)} (${relativeTime(alert.created_at)})`, + `- **Machine:** ${alert.machine_id ?? '-'}`, + `- **Simulation:** ${alert.simulated ? 'yes' : 'no'}`, + `- **Remediation:** ${alert.remediation ? 'yes' : 'no'}`, + `- **Active decisions:** ${(alert.decisions ?? []).filter((d) => resolveDecisionExpiry(d).active).length}`, + `- **Kind:** ${alert.kind ?? '-'}`, + `- **Reason:** ${alert.message ?? '-'}`, + `- **Events count:** ${alert.events_count ?? 0}`, + `- **Scope:Value:** ${source.scope}:${source.value}`, + `- **Country:** ${country}`, + `- **AS:** ${as}`, + `- **Begin:** ${formatDate(alert.start_at)}`, + `- **End:** ${formatDate(alert.stop_at)}`, + `- **UUID:** ${alert.uuid ?? '-'}` + ]; + + // Decisions table: only active decisions are shown. Deleted/expired decisions + // remain embedded in the alert but are hidden to avoid negative durations. + const activeDecisions = (alert.decisions ?? []).filter((decision) => resolveDecisionExpiry(decision).active); + const inactiveCount = (alert.decisions?.length ?? 0) - activeDecisions.length; + + if (activeDecisions.length > 0) { + const decisionHeaders = ['ID', 'Type', 'Scope', 'Value', 'Origin', 'Remaining', 'Until', 'Simulated']; + const decisionRows = activeDecisions.map(formatDecisionRow); + lines.push('', '### Decisions', '', markdownTable(decisionHeaders, decisionRows)); + } else if ((alert.decisions?.length ?? 0) > 0) { + lines.push('', '### Decisions', '', '_No active decisions._'); + } + + if (inactiveCount > 0) { + lines.push('', `_${inactiveCount} inactive decision(s) hidden._`); + } + + // Meta and events + lines.push(formatMeta(alert.meta), formatEvents(alert.events)); + + return lines.join('\n'); +} diff --git a/packages/crowdsec-mcp/src/format/allowlists.ts b/packages/crowdsec-mcp/src/format/allowlists.ts new file mode 100644 index 000000000..9160038bc --- /dev/null +++ b/packages/crowdsec-mcp/src/format/allowlists.ts @@ -0,0 +1,85 @@ +import type { APITypes } from 'crowdsec-client'; +import { isValidCidr } from '../utils.js'; +import { escapeCell, markdownTable, formatDate, expiry, truncateRows, moreHint, bullets, plural } from './common.js'; + +export function formatAllowlistList(allowlists: APITypes.GetAllowlistsResponse, maxResults: number): string { + const { rows, hidden } = truncateRows(allowlists, maxResults); + + const tableRows = rows.map((al) => [ + escapeCell(al.name), + escapeCell(al.description), + escapeCell(formatDate(al.created_at)), + escapeCell(formatDate(al.updated_at)), + escapeCell(al.console_managed ? 'yes' : 'no'), + escapeCell(al.items?.length ?? 0) + ]); + + const header = 'Name | Description | Created at | Updated at | Managed by Console | Size'; + const table = markdownTable(header.split(' | '), tableRows); + + const summary = `\n${plural(allowlists.length, 'allowlist')}`; + const hint = moreHint(hidden, 'list_allowlists'); + + return `${table}${summary}${hint}`; +} + +export function formatAllowlistDetail(allowlist: APITypes.GetAllowlistResponse, withContent: boolean, maxResults: number): string { + const infoLines = [ + `**${escapeCell(allowlist.name)}**`, + '', + bullets([ + `Description: ${escapeCell(allowlist.description ?? '-')}`, + `Created at: ${formatDate(allowlist.created_at)}`, + `Updated at: ${formatDate(allowlist.updated_at)}`, + `Managed by Console: ${allowlist.console_managed ? 'yes' : 'no'}` + ]) + ]; + + if (!withContent) { + return `${infoLines.join('\n')}\n\n_Content hidden (with_content=false)._`; + } + + if (!allowlist.items || allowlist.items.length === 0) { + return `${infoLines.join('\n')}\n\n_No items._`; + } + + const { rows, hidden } = truncateRows(allowlist.items, maxResults); + + const tableRows = rows.map((item: APITypes.AllowlistItem) => [ + escapeCell(item.value), + escapeCell(item.description), + escapeCell(expiry(item.expiration)), + escapeCell(formatDate(item.created_at)) + ]); + + const header = 'Value | Comment | Expiration | Created at'; + const table = markdownTable(header.split(' | '), tableRows); + const hint = moreHint(hidden, 'get_allowlist'); + + return `${infoLines.join('\n')}\n\n${table}${hint}`; +} + +export function formatAllowlistCheck(targets: string[], results: APITypes.BulkCheckAllowlistResult[]): string { + if (targets.length === 0) { + return '_No targets provided._'; + } + + const uniqueTargets = [...new Set(targets)]; + const lines: string[] = []; + let allowlistedCount = 0; + + for (const target of uniqueTargets) { + const result = results.find((r) => r.target === target); + const allowlists = result?.allowlists ?? []; + if (allowlists.length > 0) { + allowlistedCount++; + const matchLabel = isValidCidr(target) ? 'CIDR matches allowlist entry/entries' : 'allowlisted by'; + lines.push(`- ${escapeCell(target)}: ${matchLabel} ${escapeCell(allowlists.join(', '))}`); + } else { + lines.push(`- ${escapeCell(target)}: not allowlisted`); + } + } + + const summary = `${plural(allowlistedCount, 'target')} of ${plural(uniqueTargets.length, 'target')} matched an allowlist.`; + return `${lines.join('\n')}\n\n${summary}`; +} diff --git a/packages/crowdsec-mcp/src/format/common.ts b/packages/crowdsec-mcp/src/format/common.ts new file mode 100644 index 000000000..2785c2d53 --- /dev/null +++ b/packages/crowdsec-mcp/src/format/common.ts @@ -0,0 +1,157 @@ +import { formatDuration } from '../utils.js'; + +const BACKSLASH = String.fromCodePoint(92); + +function stringifyCell(value: unknown): string { + if (value === null || value === undefined) { + return ''; + } + if (typeof value === 'string') { + return value; + } + if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') { + return String(value); + } + if (typeof value === 'object') { + return JSON.stringify(value) ?? ''; + } + if (typeof value === 'symbol') { + return value.toString(); + } + if (typeof value === 'function') { + return value.name || 'function'; + } + return ''; +} + +const ORIGIN_CASING: Record = { + capi: 'CAPI', + console: 'console', + crowdsec: 'crowdsec', + cscli: 'cscli', + 'cscli-import': 'cscli-import', + lists: 'lists' +}; + +/** + * Restore the canonical CrowdSec casing for an origin. The bouncer `Decision` + * class lowercases origins, while the watcher API keeps `CAPI` as-is. + */ +export function formatOrigin(value?: string): string | undefined { + if (!value) { + return value; + } + return ORIGIN_CASING[value.toLowerCase()] ?? value; +} + +export function escapeCell(value: unknown): string { + if (value === undefined || value === null || value === '') { + return '-'; + } + + return stringifyCell(value) + .replaceAll(BACKSLASH, BACKSLASH + BACKSLASH) + .replaceAll('|', BACKSLASH + '|') + .replace(/[\r\n]+/g, ' '); +} + +export function markdownTable(headers: string[], rows: string[][]): string { + if (rows.length === 0) { + return '_No results._'; + } + + const widths = headers.map((header, index) => { + const cells = rows.map((row) => (row[index] ?? '').length); + return Math.max(header.length, ...cells); + }); + + const renderRow = (cells: string[]) => `| ${cells.map((cell, index) => cell.padEnd(widths[index] ?? cell.length)).join(' | ')} |`; + + const separator = `| ${widths.map((width) => '-'.repeat(Math.max(3, width))).join(' | ')} |`; + + return [renderRow(headers), separator, ...rows.map(renderRow)].join('\n'); +} + +export function formatDate(value?: string | Date): string { + if (!value) { + return '-'; + } + + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) { + return String(value); + } + + return date.toISOString().replace('.000Z', 'Z'); +} + +export function relativeTime(value?: string | Date, now = Date.now()): string { + if (!value) { + return '-'; + } + + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) { + return String(value); + } + + const deltaSeconds = Math.round((date.getTime() - now) / 1000); + const absolute = formatDuration(Math.abs(deltaSeconds)); + + if (deltaSeconds === 0) { + return 'now'; + } + + return deltaSeconds > 0 ? `in ${absolute}` : `${absolute} ago`; +} + +export function expiry(value?: string | Date, now = Date.now()): string { + if (!value) { + return 'never'; + } + + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) { + return String(value); + } + + // Go's zero time serializes as 0001-01-01T00:00:00Z (notably for allowlist + // items with no expiration); treat it as "never". + if (date.getUTCFullYear() <= 1) { + return 'never'; + } + + return date.getTime() <= now ? 'expired' : relativeTime(date, now); +} + +export function formatDecisionExpiry(expiryInfo: { expires?: string; active: boolean }): string { + if (expiryInfo.expires) { + return `${formatDate(expiryInfo.expires)} (${expiry(expiryInfo.expires)})`; + } + + return expiryInfo.active ? '-' : 'expired'; +} + +export function truncateRows(rows: T[], maxResults: number): { rows: T[]; hidden: number } { + if (rows.length <= maxResults) { + return { rows, hidden: 0 }; + } + + return { rows: rows.slice(0, maxResults), hidden: rows.length - maxResults }; +} + +export function moreHint(hidden: number, toolName: string): string { + if (hidden <= 0) { + return ''; + } + + return `\n_${hidden} more result(s) hidden. Increase \`limit\` or narrow the filters._`; +} + +export function plural(count: number, singular: string, pluralForm = `${singular}s`): string { + return `${count} ${count === 1 ? singular : pluralForm}`; +} + +export function bullets(items: string[]): string { + return items.map((item) => `- ${item}`).join('\n'); +} diff --git a/packages/crowdsec-mcp/src/format/decisions.ts b/packages/crowdsec-mcp/src/format/decisions.ts new file mode 100644 index 000000000..d1ede9b8e --- /dev/null +++ b/packages/crowdsec-mcp/src/format/decisions.ts @@ -0,0 +1,181 @@ +import type { APITypes } from 'crowdsec-client'; +import { normalizeScope, resolveDecisionExpiry } from '../utils.js'; +import { escapeCell, expiry, formatDate, formatDecisionExpiry, formatOrigin, markdownTable, plural, truncateRows } from './common.js'; + +export interface DecisionRow { + type: string; + scope: string; + value: string; + origin: string; + scenario: string; + expires?: string; + simulated: boolean; + alertId?: number; + decisionId?: number; +} + +function decisionKey(decision: APITypes.Decision): string { + return decision.id !== undefined + ? `id:${decision.id}` + : `${decision.simulated ?? false}:${decision.type}:${decision.scope}:${decision.value}`; +} + +function collectDecisionRows(alerts: APITypes.GetAlertsResponse, now: number): DecisionRow[] { + const rows: DecisionRow[] = []; + const seen = new Set(); + const sortedAlerts = [...alerts].sort((a, b) => { + const aTime = a.created_at ? Date.parse(a.created_at) || 0 : 0; + const bTime = b.created_at ? Date.parse(b.created_at) || 0 : 0; + return bTime - aTime || (b.id ?? 0) - (a.id ?? 0); + }); + + for (const alert of sortedAlerts) { + for (const decision of alert.decisions ?? []) { + const expiryInfo = resolveDecisionExpiry(decision, undefined, now); + if (!expiryInfo.active) { + continue; + } + + const key = decisionKey(decision); + if (seen.has(key)) { + continue; + } + seen.add(key); + + rows.push({ + type: decision.type, + scope: decision.scope, + value: decision.value, + origin: decision.origin, + scenario: decision.scenario, + expires: expiryInfo.expires, + simulated: decision.simulated ?? false, + alertId: alert.id, + decisionId: decision.id + }); + } + } + + return rows; +} + +export function formatDecisionList(alerts: APITypes.GetAlertsResponse, maxResults: number, maybeMore = false): string { + const rows = collectDecisionRows(alerts, Date.now()); + const { rows: displayRows, hidden } = truncateRows(rows, maxResults); + + const table = markdownTable( + ['Type', 'Scope', 'Value', 'Origin', 'Scenario', 'Expires', 'Simulated', 'Alert ID', 'Decision ID'], + displayRows.map((r) => [ + escapeCell(r.type), + escapeCell(r.scope), + escapeCell(r.value), + escapeCell(r.origin), + escapeCell(r.scenario), + escapeCell(expiry(r.expires)), + escapeCell(r.simulated ? 'yes' : 'no'), + escapeCell(r.alertId ?? '-'), + escapeCell(r.decisionId ?? '-') + ]) + ); + + const summary = `\n${plural(displayRows.length, 'decision')} shown`; + if (hidden > 0) { + return table + summary + `\n_${hidden} more result(s) hidden. Increase \`limit\` or narrow the filters._`; + } + if (maybeMore && rows.length >= maxResults) { + return table + summary + `\n_More results may be available. Increase \`limit\` or narrow the filters._`; + } + return table + summary; +} + +export function formatBouncerDecisionList(decisions: APITypes.GetDecisionsResponse, maxResults: number): string { + const now = Date.now(); + const sortedDecisions = [...decisions].sort((a, b) => (b.id ?? 0) - (a.id ?? 0)); + const activeDecisions = sortedDecisions.filter((decision) => { + const endAt = (decision as { endAt?: Date }).endAt; + if (endAt instanceof Date) { + return endAt.getTime() > now; + } + return resolveDecisionExpiry(decision, undefined, now).active; + }); + + const { rows: displayRows, hidden } = truncateRows(activeDecisions, maxResults); + + const table = markdownTable( + ['ID', 'Type', 'Scope', 'Value', 'Origin', 'Scenario', 'Expires', 'Simulated'], + displayRows.map((d) => { + const endAt = (d as { endAt?: Date }).endAt; + const expires = d.until ?? (endAt instanceof Date ? endAt.toISOString() : resolveDecisionExpiry(d).expires); + const scope = d.scope ? normalizeScope(d.scope) : undefined; + + return [ + escapeCell(d.id), + escapeCell(d.type), + escapeCell(scope), + escapeCell(d.value), + escapeCell(formatOrigin(d.origin)), + escapeCell(d.scenario), + escapeCell(expiry(expires)), + escapeCell(d.simulated ? 'yes' : 'no') + ]; + }) + ); + + const summary = `\n${plural(displayRows.length, 'decision')} shown`; + return table + summary + (hidden > 0 ? `\n_${hidden} more result(s) hidden. Increase \`limit\` or narrow the filters._` : ''); +} + +export function formatDecisionDetail(decision: APITypes.Decision, alert?: APITypes.Alert): string { + const expiryInfo = resolveDecisionExpiry(decision); + const expires = formatDecisionExpiry(expiryInfo); + + const lines = [ + `### Decision #${decision.id ?? '-'}`, + '', + `- **Type:** ${decision.type ?? '-'}`, + `- **Scope:** ${decision.scope ?? '-'}`, + `- **Value:** ${decision.value ?? '-'}`, + `- **Origin:** ${decision.origin ?? '-'}`, + `- **Scenario:** ${decision.scenario ?? '-'}`, + `- **Remaining:** ${decision.duration ?? '-'}`, + `- **Expires:** ${expires}`, + `- **Simulated:** ${decision.simulated ? 'yes' : 'no'}` + ]; + + if (alert) { + lines.push( + '', + `**Alert #${alert.id ?? '-'}**`, + `- **Created at:** ${formatDate(alert.created_at)}`, + `- **Reason:** ${alert.message ?? '-'}`, + `- **Source:** ${alert.source?.scope ?? '-'}:${alert.source?.value ?? '-'}` + ); + } + + return lines.join('\n'); +} + +export function formatBanResult( + alertIds: string[], + decision: { scope: string; value: string; type: string; duration: string; reason: string } +): string { + const lines = [ + `**Decision added successfully**`, + `**Scope:** ${decision.scope}`, + `**Value:** ${decision.value}`, + `**Type:** ${decision.type}`, + `**Duration:** ${decision.duration}`, + `**Reason:** ${decision.reason}`, + `**Alert ID(s):** ${alertIds.join(', ')}` + ]; + return lines.join('\n'); +} + +export function formatUnbanResult(nbDeleted: string | number): string { + const count = Number(nbDeleted); + if (!Number.isFinite(count) || count === 0) { + return 'No matching decisions found.'; + } + + return `**${count} decision(s) deleted**`; +} diff --git a/packages/crowdsec-mcp/src/http-error.ts b/packages/crowdsec-mcp/src/http-error.ts new file mode 100644 index 000000000..5783d6017 --- /dev/null +++ b/packages/crowdsec-mcp/src/http-error.ts @@ -0,0 +1,10 @@ +export class HttpRequestError extends Error { + public constructor( + public readonly status: number, + public readonly code: number, + message: string + ) { + super(message); + this.name = 'HttpRequestError'; + } +} diff --git a/packages/crowdsec-mcp/src/index.ts b/packages/crowdsec-mcp/src/index.ts new file mode 100644 index 000000000..27185808b --- /dev/null +++ b/packages/crowdsec-mcp/src/index.ts @@ -0,0 +1,93 @@ +#!/usr/bin/env node +import debug from 'debug'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { loadConfig, parseCliArgs, printHelp, printVersion } from './config.js'; +import { createContext, createPassthroughContext } from './client.js'; +import { createServer, selectTools } from './registry.js'; +import { allTools } from './tools/index.js'; +import { startHttpServer } from './transports/http.js'; +import type { AppContext } from './types.js'; + +const log = debug('crowdsec-mcp'); + +async function main(): Promise { + const { values } = parseCliArgs(); + + if (values.help) { + printHelp(); + return; + } + + if (values.version) { + printVersion(); + return; + } + + const config = loadConfig(values); + let context: AppContext | undefined; + let stdioServer: ReturnType | undefined; + let httpServer: Awaited> | undefined; + + let stopping = false; + const shutdown = async (signal: string): Promise => { + if (stopping) { + return; + } + + stopping = true; + log('received %s, shutting down', signal); + try { + if (httpServer) { + httpServer.closeIdleConnections(); + await new Promise((resolve) => { + httpServer!.close(() => resolve()); + }); + } + await context?.stop(); + await stdioServer?.close(); + } finally { + process.exit(0); + } + }; + + process.once('SIGINT', () => { + void shutdown('SIGINT'); + }); + process.once('SIGTERM', () => { + void shutdown('SIGTERM'); + }); + + if (config.transport === 'http') { + if (config.passthrough) { + httpServer = await startHttpServer((headers) => { + const requestContext = createPassthroughContext(config, headers); + const requestTools = selectTools(allTools(), requestContext); + return { + server: createServer(requestTools, requestContext), + cleanup: () => requestContext.stop() + }; + }, config); + } else { + context = await createContext(config); + const tools = selectTools(allTools(), context); + httpServer = await startHttpServer(() => ({ server: createServer(tools, context!) }), config); + } + log('HTTP transport listening on %s:%d', config.http.host, config.http.port); + return; + } + + context = await createContext(config); + const tools = selectTools(allTools(), context); + stdioServer = createServer(tools, context); + const transport = new StdioServerTransport(); + await stdioServer.connect(transport); + log('stdio transport ready'); +} + +try { + await main(); +} catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`crowdsec-mcp: ${message}\n`); + process.exit(1); +} diff --git a/packages/crowdsec-mcp/src/pkg.ts b/packages/crowdsec-mcp/src/pkg.ts new file mode 100644 index 000000000..7366dc3ab --- /dev/null +++ b/packages/crowdsec-mcp/src/pkg.ts @@ -0,0 +1 @@ +export const pkg = { name: 'crowdsec-mcp', version: '0.1.0' }; diff --git a/packages/crowdsec-mcp/src/prompts.ts b/packages/crowdsec-mcp/src/prompts.ts new file mode 100644 index 000000000..a3e065649 --- /dev/null +++ b/packages/crowdsec-mcp/src/prompts.ts @@ -0,0 +1,236 @@ +import { z } from 'zod'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { GetPromptResult } from '@modelcontextprotocol/sdk/types.js'; +import type { AppContext } from './types.js'; +import { isValidIp, parseDuration } from './utils.js'; + +export interface CrowdSecPrompt { + name: string; + title: string; + description: string; + requires?: Array<'watcher'>; + register: (server: McpServer, ctx: AppContext) => void; +} + +export function isPromptAvailable(prompt: CrowdSecPrompt, ctx: AppContext): boolean { + return !prompt.requires?.includes('watcher') || ctx.mode === 'watcher'; +} + +function promptResult(description: string, text: string): GetPromptResult { + return { + description, + messages: [ + { + role: 'user', + content: { + type: 'text', + text + } + } + ] + }; +} + +function writeGuardrail(ctx: AppContext): string { + if (ctx.mode === 'bouncer') { + return 'Write tools are unavailable in bouncer mode: report findings and recommendations only.'; + } + if (ctx.config.readonly) { + return 'Write tools are disabled (read-only): stop after the analysis and give recommendations.'; + } + + return 'Ask the user for confirmation before calling ban_ip or unban_ip.'; +} + +const DurationArg = z + .string() + .trim() + .refine((value) => { + try { + parseDuration(value); + return true; + } catch { + return false; + } + }, 'Use a duration with a unit, e.g. 30m, 4h or 7d'); + +const CountArg = z + .string() + .trim() + .regex(/^[1-9]\d*$/, 'Use a positive integer'); + +const IpArg = z.string().trim().refine(isValidIp, { message: 'Use a valid IPv4 or IPv6 address' }); + +const CountryArg = z + .string() + .trim() + .regex(/^[A-Za-z]{2}$/, 'Use an ISO 3166-1 alpha-2 country code, e.g. FR or US') + .transform((code) => code.toUpperCase()); + +const TriageAlerts = { + name: 'triage_alerts', + title: 'Triage recent CrowdSec alerts', + description: 'Review recent alerts, correlate them with active decisions, and propose (or apply) remediation.', + argsSchema: { + since: DurationArg.optional().describe('Time window to review, e.g. 24h or 7d (default: 24h)'), + limit: CountArg.optional().describe('Maximum number of alerts to review (default: 25)') + } +} as const; + +type TriageAlertsArgs = z.infer>; + +const InvestigateIp = { + name: 'investigate_ip', + title: 'Investigate an IP in CrowdSec', + description: 'Summarise the detection history and remediation state of one IP, then recommend an action.', + argsSchema: { + ip: IpArg.describe('IPv4 or IPv6 address to investigate') + } +} as const; + +type InvestigateIpArgs = z.infer>; + +const BannedFromCountry = { + name: 'banned_from_country', + title: 'Find banned IPs from a country', + description: 'List the IPs with an active decision that are geolocated in a given country (MaxMind country code).', + argsSchema: { + country: CountryArg.describe('ISO 3166-1 alpha-2 country code, e.g. FR or US'), + since: DurationArg.optional().describe('How far back to scan alerts (default: 24h)'), + limit: CountArg.optional().describe('Maximum number of alerts to scan (default: 200)') + } +} as const; + +type BannedFromCountryArgs = z.infer>; + +function renderTriageAlerts(args: TriageAlertsArgs, ctx: AppContext): string { + const since = args.since ?? '24h'; + const limit = args.limit ?? '25'; + + return [ + `Triage the CrowdSec alerts from the last ${since} (at most ${limit}).`, + '', + `1. Call list_alerts with since="${since}" and limit=${limit}.`, + '2. Group the alerts by source IP and scenario, and keep the most aggressive sources.', + '3. For each serious candidate, call get_alert, then list_decisions with the same IP to see what is already blocked.', + '4. Propose one action per source: ban_ip, leave as is, or allowlist when it looks like a false positive.', + '5. Explain the evidence (scenario, number of events, time window) for every proposal.', + '', + writeGuardrail(ctx), + 'Never invent alert or decision IDs. Read crowdsec://docs/triage for the full workflow.' + ].join('\n'); +} + +function renderInvestigateIp(args: InvestigateIpArgs, ctx: AppContext): string { + const { ip } = args; + const isBouncer = ctx.mode === 'bouncer'; + + let steps: string[]; + let summary: string; + let recommendation: string; + + if (isBouncer) { + steps = [ + `1. Call list_decisions with ip="${ip}" to see whether it is currently blocked.`, + '2. Alerts and allowlists need watcher credentials: state this limitation if it matters.' + ]; + summary = 'Summarise: active decisions (type, origin and remaining duration).'; + recommendation = 'Then recommend an action for a watcher-capable operator (ban, unban or no action), and justify it.'; + } else { + steps = [ + `1. Call list_alerts with ip="${ip}" and since="30d" to get the detection history.`, + `2. Call list_decisions with ip="${ip}" to see whether it is currently blocked.`, + `3. Call check_allowlist with value="${ip}" to detect an exemption.`, + '4. If nothing matches, say so explicitly instead of guessing.' + ]; + summary = 'Summarise: scenarios triggered, event volume, time span, active decisions and allowlist status.'; + recommendation = 'Then recommend ban_ip (with scope, type and duration), unban_ip, or no action, and justify it.'; + } + + return [ + `Investigate the CrowdSec activity for ${ip}.`, + '', + ...steps, + '', + summary, + recommendation, + '', + writeGuardrail(ctx), + 'Read crowdsec://docs/concepts for scope, type and origin semantics.' + ].join('\n'); +} + +function renderBannedFromCountry(args: BannedFromCountryArgs): string { + const { country } = args; + const since = args.since ?? '24h'; + const limit = args.limit ?? '200'; + + return [ + `List the IPs currently banned by CrowdSec that are geolocated in ${country}.`, + '', + `1. Call list_alerts with has_active_decision=true, since="${since}" and limit=${limit}.`, + `2. Keep only the rows whose Country column is ${country}. That column comes from MaxMind enrichment of the alert source.`, + '3. For each match, call list_decisions with the matching IP to confirm the decision is still active, and note its type, origin and remaining duration.', + '4. Report a table: IP, scenario, decision type, origin, remaining duration. If nothing matches, say explicitly that no', + ` ${country} IP has an active decision in this window, and suggest a wider since value instead of guessing.`, + '5. Do not ban or unban anything: this is a reporting task.', + '', + 'Caveats to mention if relevant:', + '- The LAPI alert search has no country filter, so the selection happens on the Country column: raise limit on a busy window.', + '- Country comes from GeoIP enrichment; rows without a GeoIP match show a dash and are excluded.', + `- A decision with scope Country (value ${country}) is not the same thing as an IP geolocated in ${country}.` + ].join('\n'); +} + +export const crowdsecPrompts: CrowdSecPrompt[] = [ + { + name: TriageAlerts.name, + title: TriageAlerts.title, + description: TriageAlerts.description, + requires: ['watcher'], + register: (server, ctx) => { + server.registerPrompt( + TriageAlerts.name, + { + title: TriageAlerts.title, + description: TriageAlerts.description, + argsSchema: TriageAlerts.argsSchema + }, + (args) => promptResult(TriageAlerts.description, renderTriageAlerts(args, ctx)) + ); + } + }, + { + name: InvestigateIp.name, + title: InvestigateIp.title, + description: InvestigateIp.description, + register: (server, ctx) => { + server.registerPrompt( + InvestigateIp.name, + { + title: InvestigateIp.title, + description: InvestigateIp.description, + argsSchema: InvestigateIp.argsSchema + }, + (args) => promptResult(InvestigateIp.description, renderInvestigateIp(args, ctx)) + ); + } + }, + { + name: BannedFromCountry.name, + title: BannedFromCountry.title, + description: BannedFromCountry.description, + requires: ['watcher'], + register: (server, ctx) => { + server.registerPrompt( + BannedFromCountry.name, + { + title: BannedFromCountry.title, + description: BannedFromCountry.description, + argsSchema: BannedFromCountry.argsSchema + }, + (args) => promptResult(BannedFromCountry.description, renderBannedFromCountry(args)) + ); + } + } +]; diff --git a/packages/crowdsec-mcp/src/registry.ts b/packages/crowdsec-mcp/src/registry.ts new file mode 100644 index 000000000..adb364b4e --- /dev/null +++ b/packages/crowdsec-mcp/src/registry.ts @@ -0,0 +1,166 @@ +import debug from 'debug'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import type { AppContext, ToolDefinition } from './types.js'; +import { buildInstructions, docResources } from './docs.js'; +import { isAlertDeleteAvailable } from './config.js'; +import { crowdsecPrompts, isPromptAvailable } from './prompts.js'; +import { NAME, VERSION } from './version.js'; + +const log = debug('crowdsec-mcp:registry'); + +export function textResult(text: string): CallToolResult { + return { content: [{ type: 'text', text }] }; +} + +export function cleanErrorMessage(error: unknown): string { + const raw = error instanceof Error ? error.message : String(error); + let message = raw.split(/\r?\n/, 1)[0].trim(); + + // The client embeds its internal class name (and sometimes the ORM prefix) + // in error messages. Keep only the business message. + message = message.replace(/^CrowdSecError \(\d+\)\s*:\s*/i, ''); + message = message.replace(/^ent:\s*/i, ''); + message = message.replace(/^object not found$/i, 'Resource not found'); + message = message.replace(/^alert not found$/i, 'Alert not found'); + message = message.replace(/^while parsing duration.*/i, 'Invalid duration'); + + const allowlist = /^allowlist '(.+)' not found$/i.exec(message); + if (allowlist) { + message = `Allowlist '${allowlist[1]}' not found`; + } + + const decision = /^decision with id '(.+)' doesn't exist.*/i.exec(message); + if (decision) { + message = `Decision ${decision[1]} not found`; + } + + if (/^access forbidden from this IP/i.test(message)) { + message = 'Access forbidden from this IP (alert deletion is only allowed from the LAPI host)'; + } + + return message || 'unknown error'; +} + +export function errorResult(error: unknown): CallToolResult { + const raw = error instanceof Error ? error.message : String(error); + // Client errors can embed a full stack in their message. Keep only the first + // cleaned line so tool output stays actionable and does not leak internals. + const message = cleanErrorMessage(error); + log('tool error: %s', raw); + return { + content: [{ type: 'text', text: `Error: ${message}` }], + isError: true + }; +} + +export function selectTools(tools: ToolDefinition[], ctx: AppContext): ToolDefinition[] { + const context = ctx; + const knownNames = new Set(tools.map((tool) => tool.name)); + + if (context.config.tools) { + const unknown = context.config.tools.filter((name) => !knownNames.has(name)); + if (unknown.length > 0) { + throw new Error(`unknown tool(s) in CROWDSEC_MCP_TOOLS: ${unknown.join(', ')}`); + } + } + + const selected = tools.filter((tool) => { + if (context.config.readonly && tool.kind === 'write') { + return false; + } + + if (tool.requires?.includes('watcher') && context.mode !== 'watcher') { + return false; + } + + if (tool.requires?.includes('allowlists') && !context.capabilities.allowlists) { + return false; + } + + if (tool.feature === 'alert-delete' && !isAlertDeleteAvailable(context.config)) { + // delete_alerts is local-only and destructive. In passthrough mode + // without a service token, any LAPI token holder could reach it, so + // hide it unless the operator explicitly forces it. + return false; + } + + if (context.config.tools && !context.config.tools.includes(tool.name)) { + return false; + } + + if (context.config.disableTools.includes(tool.name)) { + return false; + } + + return true; + }); + + const selectedNames = new Set(selected.map((tool) => tool.name)); + for (const requested of context.config.tools ?? []) { + if (!selectedNames.has(requested)) { + log('tool %s is known but unavailable in the current mode', requested); + } + } + + return selected; +} + +export function createServer(tools: ToolDefinition[], ctx: AppContext): McpServer { + ctx.activeTools = tools.map((tool) => tool.name); + const server = new McpServer({ name: NAME, version: VERSION }, { instructions: buildInstructions(ctx) }); + + for (const tool of tools) { + const handler = async (input: unknown): Promise => { + try { + await ctx.ensureReady(); + const result = await tool.handler(input ?? {}, ctx); + return typeof result === 'string' ? textResult(result) : result; + } catch (error) { + return errorResult(error); + } + }; + + server.registerTool( + tool.name, + { + title: tool.title, + description: tool.description, + inputSchema: tool.inputSchema, + annotations: tool.annotations + }, + handler + ); + } + + for (const resource of docResources) { + server.registerResource( + resource.name, + resource.uri, + { + title: resource.title, + description: resource.description, + mimeType: 'text/markdown' + }, + (uri) => + Promise.resolve({ + contents: [ + { + uri: uri.href, + mimeType: 'text/markdown', + text: resource.render(ctx) + } + ] + }) + ); + } + + const prompts = crowdsecPrompts.filter((prompt) => isPromptAvailable(prompt, ctx)); + for (const prompt of prompts) { + prompt.register(server, ctx); + } + + log('registered %d tool(s): %s', tools.length, tools.map((tool) => tool.name).join(', ')); + log('registered %d resource(s) and %d prompt(s)', docResources.length, prompts.length); + return server; +} diff --git a/packages/crowdsec-mcp/src/tools/alerts.ts b/packages/crowdsec-mcp/src/tools/alerts.ts new file mode 100644 index 000000000..45ecf3121 --- /dev/null +++ b/packages/crowdsec-mcp/src/tools/alerts.ts @@ -0,0 +1,372 @@ +import { z } from 'zod'; +import type { AppContext, ToolDefinition } from '../types.js'; +import { formatAlertDetail, formatAlertList } from '../format/alerts.js'; +import { cleanErrorMessage } from '../registry.js'; +import type { APITypes } from 'crowdsec-client'; +import { + assertValidCidr, + assertValidDecisionScope, + assertValidDecisionType, + assertValidScopeValue, + isValidCidr, + isValidIp, + isValidTimeFilter, + parsePositiveId, + resolveListWindow, + toGoDuration +} from '../utils.js'; + +async function deleteAlertIds(ctx: AppContext, ids: number[]): Promise { + const responses = await Promise.all(ids.map((id) => ctx.watcher!.Alerts.deleteById(String(id)))); + return responses.reduce((total, response) => total + Number(response.nbDeleted ?? 0), 0); +} + +const ListAlertsInput = z + .object({ + ip: z.string().optional().describe('IP or CIDR range (CIDR performs containment)'), + range: z.string().optional().describe('CIDR range (containment)'), + scope: z.string().optional().describe('Filter by scope (Ip, Range, Username, etc.)'), + value: z.string().optional().describe('Filter by value (used with scope)'), + scenario: z.string().optional().describe('Filter by scenario name'), + type: z.string().optional().describe('Filter by decision type (ban, captcha, etc.)'), + origin: z.string().optional().describe('Filter by origin (cscli, crowdsec, etc.)'), + kind: z.string().optional().describe('Filter by alert kind (crowdsec, waf, bot-detection, etc.)'), + since: z.string().optional().describe('Filter alerts newer than this duration (e.g., 4h, 30d, 3650d)'), + until: z.string().optional().describe('Filter alerts older than this duration (e.g., 4h, 30d, 3650d)'), + has_active_decision: z.boolean().optional().describe('Only return alerts with active (non-expired) decisions'), + all: z.boolean().optional().describe('Return all results (ignore limit)'), + limit: z.number().int().positive().optional().describe('Maximum number of results to return') + }) + .strict(); + +const GetAlertInput = z + .object({ + id: z.union([z.string(), z.number()]).describe('Alert ID') + }) + .strict(); + +const DeleteAlertsInput = z + .object({ + id: z.union([z.string(), z.number()]).optional().describe('Delete a specific alert by ID'), + ip: z.string().optional().describe('IP or CIDR range (CIDR performs containment)'), + range: z.string().optional().describe('CIDR range (containment)'), + scope: z.string().optional().describe('Filter by scope (Ip, Range, Username, etc.)'), + value: z.string().optional().describe('Filter by value (used with scope)'), + scenario: z.string().optional().describe('Filter by scenario name'), + type: z.string().optional().describe('Filter by decision type (ban, captcha, throttle)'), + origin: z.string().optional().describe('Filter by alert source/origin (cscli, crowdsec, etc.)'), + since: z.string().optional().describe('Filter alerts newer than this duration (e.g., 4h, 30d)'), + until: z.string().optional().describe('Filter alerts older than this duration (e.g., 4h, 30d)'), + has_active_decision: z.boolean().optional().describe('Only delete alerts with active decisions'), + all: z.boolean().optional().describe('Delete all matching alerts (requires confirm=true)'), + confirm: z.boolean().optional().describe('Confirm deletion when using all=true or broad filters') + }) + .strict(); + +type DeleteAlertInput = z.infer; + +interface NormalizedDeleteAlertInput extends DeleteAlertInput { + decisionType?: string; + normalizedScope?: string; + normalizedValue?: string; +} + +function validateDeleteTimes(since?: string, until?: string): void { + if (since && !isValidTimeFilter(since)) { + throw new Error(`invalid since '${since}': use h, m, s or d (e.g. 4h, 30d)`); + } + if (until && !isValidTimeFilter(until)) { + throw new Error(`invalid until '${until}': use h, m, s or d (e.g. 4h, 30d)`); + } +} + +function hasDeleteFilters(input: DeleteAlertInput, decisionType?: string): boolean { + return Boolean( + input.ip || + input.range || + input.scope || + input.value || + input.scenario || + input.origin || + decisionType || + input.since || + input.until || + input.has_active_decision !== undefined + ); +} + +function assertAllDelete(input: DeleteAlertInput, decisionType?: string): void { + if (input.confirm !== true) { + throw new Error('Deleting all alerts requires confirm=true'); + } + if (input.id !== undefined || hasDeleteFilters(input, decisionType)) { + throw new Error('Cannot combine all=true with id or filters; use all=true with confirm=true alone to delete everything'); + } +} + +function assertDeleteTargets(input: DeleteAlertInput): void { + if (input.scope && (input.ip || input.range)) { + throw new Error('Provide only one of ip, range, or scope'); + } + if (input.ip && input.range) { + throw new Error('Provide only one of ip or range'); + } + if (input.ip && !isValidIp(input.ip) && !isValidCidr(input.ip)) { + throw new Error(`${input.ip} is not a valid ip or CIDR range`); + } + if (input.range) { + assertValidCidr(input.range); + } +} + +function normalizeDeleteInput(input: DeleteAlertInput): NormalizedDeleteAlertInput { + validateDeleteTimes(input.since, input.until); + const decisionType = input.type ? assertValidDecisionType(input.type) : undefined; + + if (input.all === true) { + assertAllDelete(input, decisionType); + } + assertDeleteTargets(input); + + if (input.scope && !input.value) { + throw new Error('scope and value must be provided together for delete_alerts'); + } + + const normalizedScope = input.scope ? assertValidDecisionScope(input.scope) : undefined; + const normalizedValue = normalizedScope && input.value ? assertValidScopeValue(normalizedScope, input.value) : input.value; + + if (input.id !== undefined && hasDeleteFilters(input, decisionType)) { + throw new Error('Cannot combine id with filters or all=true'); + } + if (input.id === undefined && input.all !== true && !hasDeleteFilters(input, decisionType)) { + throw new Error( + 'At least one filter (ip, range, scope, value, scenario, origin, type, since, until, has_active_decision) or all=true is required' + ); + } + + const hasNarrowTarget = Boolean(input.ip || input.range || (normalizedScope && normalizedValue)); + if (input.id === undefined && !hasNarrowTarget && input.confirm !== true) { + throw new Error( + 'Deleting alerts filtered only by scenario, origin, type, time or has_active_decision requires confirm=true; add ip, range, or scope+value to narrow the target' + ); + } + + return { ...input, decisionType, normalizedScope, normalizedValue }; +} + +function formatDeletedCount(count: number): string { + return count === 0 ? 'No matching alerts found.' : `Deleted ${count} alert(s).`; +} + +async function deleteAlertsByIds(ctx: AppContext, ids: number[]): Promise { + if (ids.length === 0) { + return 'No matching alerts found.'; + } + return formatDeletedCount(await deleteAlertIds(ctx, ids)); +} + +async function deleteAlertsById(ctx: AppContext, id: string | number): Promise { + const alertId = parsePositiveId(id, 'alert id'); + try { + const result = await ctx.watcher!.Alerts.deleteById(alertId); + return formatDeletedCount(Number(result.nbDeleted ?? 0)); + } catch (error) { + const message = cleanErrorMessage(error); + if (/not found/i.test(message)) { + throw new Error(`Alert ${alertId} not found`); + } + throw error; + } +} + +async function deleteAlertsByCidr(ctx: AppContext, filters: NormalizedDeleteAlertInput, cidr: string): Promise { + // The delete endpoint matches exact filter values; search first so alerts + // whose source IP is contained in the range are also removed. + const query: APITypes.SearchAlertsQuery = { + ip: cidr, + scope: filters.normalizedScope, + value: filters.normalizedValue, + scenario: filters.scenario, + origin: filters.origin, + decision_type: filters.decisionType, + since: filters.since ? toGoDuration(filters.since) : undefined, + until: filters.until ? toGoDuration(filters.until) : undefined, + contains: false, + has_active_decision: filters.has_active_decision, + limit: 0 + }; + const alerts = await ctx.watcher!.Alerts.search(query); + + return deleteAlertsByIds( + ctx, + alerts.map((alert) => alert.id).filter((id): id is number => typeof id === 'number') + ); +} + +async function deleteAlertsBySearch(ctx: AppContext, filters: NormalizedDeleteAlertInput): Promise { + // Keep the search path for LAPI versions that do not accept `decision_type` + // on the DELETE endpoint. + const query: APITypes.SearchAlertsQuery = { + ip: filters.ip, + range: filters.range, + scope: filters.normalizedScope, + value: filters.normalizedValue, + scenario: filters.scenario, + origin: filters.origin, + decision_type: filters.decisionType, + since: filters.since ? toGoDuration(filters.since) : undefined, + until: filters.until ? toGoDuration(filters.until) : undefined, + contains: false, + has_active_decision: filters.has_active_decision, + limit: 0 + }; + const alerts = await ctx.watcher!.Alerts.search(query); + + return deleteAlertsByIds( + ctx, + alerts.map((alert) => alert.id).filter((id): id is number => typeof id === 'number') + ); +} + +function buildDeleteQuery(filters: NormalizedDeleteAlertInput): APITypes.DeleteAlertsQuery { + return { + ip: filters.ip, + scope: filters.normalizedScope, + value: filters.normalizedValue, + scenario: filters.scenario, + since: filters.since ? toGoDuration(filters.since) : undefined, + until: filters.until ? toGoDuration(filters.until) : undefined, + has_active_decision: filters.has_active_decision, + alert_source: filters.origin + }; +} + +async function deleteAlertsDirect(ctx: AppContext, filters: NormalizedDeleteAlertInput): Promise { + const result = await ctx.watcher!.Alerts.delete(buildDeleteQuery(filters)); + + return formatDeletedCount(Number(result.nbDeleted ?? 0)); +} + +async function deleteAllAlerts(ctx: AppContext): Promise { + const result = await ctx.watcher!.Alerts.delete({}); + return formatDeletedCount(Number(result.nbDeleted ?? 0)); +} + +export const alertTools: ToolDefinition[] = [ + { + name: 'list_alerts', + title: 'List alerts', + description: + 'List alerts from CrowdSec with optional filters. Returns a table with ID, scope, value, reason, country, AS, decisions, creation time, and kind.', + inputSchema: ListAlertsInput, + kind: 'read', + requires: ['watcher'], + annotations: { + readOnlyHint: true, + idempotentHint: true + }, + handler: async (input, ctx) => { + const { ip, range, scope, value, scenario, type, origin, kind, since, until, has_active_decision, all, limit } = + ListAlertsInput.parse(input); + + if (ip && !isValidIp(ip) && !isValidCidr(ip)) { + throw new Error(`${ip} is not a valid ip or CIDR range`); + } + if (range) { + assertValidCidr(range); + } + const normalizedScope = scope ? assertValidDecisionScope(scope) : undefined; + const normalizedValue = normalizedScope && value ? assertValidScopeValue(normalizedScope, value) : value; + const decisionType = type ? assertValidDecisionType(type) : undefined; + if (since && !isValidTimeFilter(since)) { + throw new Error(`invalid since '${since}': use h, m, s or d (e.g. 4h, 30d)`); + } + if (until && !isValidTimeFilter(until)) { + throw new Error(`invalid until '${until}': use h, m, s or d (e.g. 4h, 30d)`); + } + + const window = resolveListWindow(limit, all === true, ctx.config.maxResults); + const query: APITypes.SearchAlertsQuery = { + ip, + range, + scope: normalizedScope, + value: normalizedValue, + scenario, + decision_type: decisionType, + origin, + kind, + since: since ? toGoDuration(since) : undefined, + until: until ? toGoDuration(until) : undefined, + contains: false, + has_active_decision, + limit: window.fetchLimit + }; + + const alerts = await ctx.watcher!.Alerts.search(query); + + return formatAlertList(alerts, window.displayLimit, window.maybeMore); + } + }, + { + name: 'get_alert', + title: 'Get alert details', + description: 'Get detailed information about a specific alert including decisions, meta, and events.', + inputSchema: GetAlertInput, + kind: 'read', + requires: ['watcher'], + annotations: { + readOnlyHint: true, + idempotentHint: true + }, + handler: async (input, ctx) => { + const { id } = GetAlertInput.parse(input); + const alertId = parsePositiveId(id, 'alert id'); + + try { + const alert = await ctx.watcher!.Alerts.getById(alertId); + return formatAlertDetail(alert); + } catch (error) { + const message = cleanErrorMessage(error); + if (/not found/i.test(message)) { + throw new Error(`Alert ${alertId} not found`); + } + throw error; + } + } + }, + { + name: 'delete_alerts', + title: 'Delete alerts', + description: + 'Delete alerts by ID or filters. Requires at least one filter or all=true with confirm=true; all=true cannot be combined with filters. Broad filters (scenario, origin, type, time, has_active_decision alone) also require confirm=true; narrow with ip, range, or scope+value. ip and range accept a CIDR and perform containment. CrowdSec only allows alert deletion from the LAPI host; remote watchers receive 403.', + inputSchema: DeleteAlertsInput, + kind: 'write', + requires: ['watcher'], + feature: 'alert-delete', + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false + }, + handler: async (input, ctx) => { + const filters = normalizeDeleteInput(DeleteAlertsInput.parse(input)); + + if (filters.id !== undefined) { + return deleteAlertsById(ctx, filters.id); + } + if (filters.all === true) { + return deleteAllAlerts(ctx); + } + + const cidr = filters.range ?? (filters.ip && isValidCidr(filters.ip) ? filters.ip : undefined); + if (cidr) { + return deleteAlertsByCidr(ctx, filters, cidr); + } + if (filters.decisionType) { + return deleteAlertsBySearch(ctx, filters); + } + + return deleteAlertsDirect(ctx, filters); + } + } +]; diff --git a/packages/crowdsec-mcp/src/tools/allowlists.ts b/packages/crowdsec-mcp/src/tools/allowlists.ts new file mode 100644 index 000000000..31734109e --- /dev/null +++ b/packages/crowdsec-mcp/src/tools/allowlists.ts @@ -0,0 +1,115 @@ +import { z } from 'zod'; +import type { ToolDefinition } from '../types.js'; +import { formatAllowlistList, formatAllowlistDetail, formatAllowlistCheck } from '../format/allowlists.js'; +import { cleanErrorMessage } from '../registry.js'; +import { isValidCidr, isValidIp } from '../utils.js'; + +const ListAllowlistsInput = z + .object({ + limit: z.number().int().positive().optional().describe('Maximum number of allowlists to display') + }) + .strict(); + +const GetAllowlistInput = z + .object({ + name: z.string().trim().min(1, 'Allowlist name is required').describe('Name of the allowlist to inspect'), + with_content: z.boolean().default(true).describe('Include allowlist items in the response'), + limit: z.number().int().positive().optional().describe('Maximum number of items to display') + }) + .strict(); + +const CheckAllowlistInput = z + .object({ + value: z.string().trim().min(1).optional().describe('Single IP or CIDR range to check'), + values: z.array(z.string().trim().min(1)).optional().describe('Multiple IPs or CIDR ranges to check in bulk') + }) + .strict() + .refine((data) => data.value !== undefined || (data.values !== undefined && data.values.length > 0), { + message: 'At least one of `value` or `values` must be provided and non-empty' + }) + .refine((data) => !(data.value !== undefined && data.values !== undefined && data.values.length > 0), { + message: 'Provide either `value` or `values`, not both' + }); + +export const allowlistTools: ToolDefinition[] = [ + { + name: 'list_allowlists', + title: 'List allowlists', + description: + 'List all allowlists with their name, description, creation/update dates, console management status, and item count. Mirrors `cscli allowlists list`.', + inputSchema: ListAllowlistsInput, + kind: 'read', + requires: ['watcher', 'allowlists'], + annotations: { + readOnlyHint: true, + idempotentHint: true + }, + handler: async (input, ctx) => { + const { limit } = ListAllowlistsInput.parse(input); + const allowlists = await ctx.allowlists.list(); + + return formatAllowlistList(allowlists, limit ?? ctx.config.maxResults); + } + }, + { + name: 'get_allowlist', + title: 'Get allowlist details', + description: 'Get detailed information about a specific allowlist including its items. Mirrors `cscli allowlists inspect`.', + inputSchema: GetAllowlistInput, + kind: 'read', + requires: ['watcher', 'allowlists'], + annotations: { + readOnlyHint: true, + idempotentHint: true + }, + handler: async (input, ctx) => { + const { name, with_content: withContent, limit } = GetAllowlistInput.parse(input); + + try { + const allowlist = await ctx.allowlists.get(name, withContent); + return formatAllowlistDetail(allowlist, withContent, limit ?? ctx.config.maxResults); + } catch (error) { + const message = cleanErrorMessage(error); + if (/not found/i.test(message)) { + throw new Error(`Allowlist '${name}' not found`); + } + throw error; + } + } + }, + { + name: 'check_allowlist', + title: 'Check allowlist membership', + description: + 'Check whether one or more IPs or CIDR ranges match any allowlist entry. A CIDR can match because it contains an allowlisted IP; the output distinguishes CIDR matches from exact IP matches. Provide either a single `value` or an array of `values`.', + inputSchema: CheckAllowlistInput, + kind: 'read', + requires: ['watcher', 'allowlists'], + annotations: { + readOnlyHint: true, + idempotentHint: true + }, + handler: async (input, ctx) => { + const { value, values } = CheckAllowlistInput.parse(input); + + const targets: string[] = [...(values ?? []), ...(value ? [value] : [])]; + const uniqueTargets = [...new Set(targets)]; + + if (uniqueTargets.length === 0) { + throw new Error('At least one of `value` or `values` must be provided and non-empty'); + } + + for (const target of uniqueTargets) { + if (!isValidIp(target) && !isValidCidr(target)) { + throw new Error(`check_allowlist supports IPs and CIDR ranges only; '${target}' is not valid`); + } + } + + // Always use the bulk endpoint, even for a single target: it returns + // the allowlist names, while the single-value endpoint only returns + // the matched item. This keeps the output consistent. + const bulkResult = await ctx.allowlists.bulkCheck(uniqueTargets); + return formatAllowlistCheck(uniqueTargets, bulkResult.results ?? []); + } + } +]; diff --git a/packages/crowdsec-mcp/src/tools/decisions.ts b/packages/crowdsec-mcp/src/tools/decisions.ts new file mode 100644 index 000000000..8ad497fb8 --- /dev/null +++ b/packages/crowdsec-mcp/src/tools/decisions.ts @@ -0,0 +1,543 @@ +import { z } from 'zod'; +import type { APITypes } from 'crowdsec-client'; +import { + assertValidCidr, + assertValidDecisionScope, + assertValidDecisionType, + assertValidIp, + assertValidScopeValue, + formatDuration, + isValidCidr, + isValidIp, + isValidTimeFilter, + normalizeScope, + parseDuration, + parsePositiveId, + resolveListWindow, + toGoDuration +} from '../utils.js'; +import { + formatBanResult, + formatBouncerDecisionList, + formatDecisionDetail, + formatDecisionList, + formatUnbanResult +} from '../format/decisions.js'; +import { cleanErrorMessage } from '../registry.js'; +import type { AppContext, ToolDefinition } from '../types.js'; + +function collectDecisionIds(alerts: APITypes.GetAlertsResponse): number[] { + const ids = new Set(); + for (const alert of alerts) { + for (const decision of alert.decisions ?? []) { + if (typeof decision.id === 'number') { + ids.add(decision.id); + } + } + } + return [...ids]; +} + +async function searchActiveDecisionIds(ctx: AppContext, query: APITypes.SearchAlertsQuery): Promise { + // `contains: false` means "decisions contained within the value" and is + // required for CIDR containment; the LAPI defaults to the opposite. + const searchQuery: APITypes.SearchAlertsQuery = { + ...query, + contains: query.contains ?? false, + has_active_decision: true, + limit: 0 + }; + const alerts = await ctx.watcher!.Alerts.search(searchQuery); + return collectDecisionIds(alerts); +} + +async function deleteDecisionIds(ctx: AppContext, ids: number[]): Promise { + const results = await Promise.all(ids.map((id) => ctx.watcher!.Decisions.deleteById(String(id)))); + return results.reduce((total, result) => total + Number(result.nbDeleted ?? 0), 0); +} + +async function fetchActiveDecisions(ctx: AppContext): Promise { + const alerts = await ctx.watcher!.Alerts.search({ has_active_decision: true, limit: 0 }); + const decisions = new Map(); + for (const alert of alerts) { + for (const decision of alert.decisions ?? []) { + if (typeof decision.id === 'number') { + decisions.set(decision.id, decision); + } + } + } + return [...decisions.values()]; +} + +const ListDecisionsInput = z + .object({ + ip: z.string().optional().describe('IP or CIDR range to filter by (CIDR performs containment)'), + range: z.string().optional().describe('CIDR range to filter by (shorthand for scope=Range)'), + scope: z.string().optional().describe('Scope to filter by (Ip, Range, Username, Session, Country, AS)'), + value: z.string().optional().describe('Value to filter by (used with scope)'), + type: z.string().optional().describe('Decision type to filter by (ban, captcha, etc.)'), + origin: z.string().optional().describe('Origin to filter by (cscli, crowdsec, capi, console, etc.)'), + scenario: z.string().optional().describe('Scenario name to filter by'), + since: z.string().optional().describe('Show alerts newer than this duration (e.g., 24h, 7d, 3650d)'), + until: z.string().optional().describe('Show alerts older than this duration (e.g., 24h, 7d, 3650d)'), + contains: z + .boolean() + .optional() + .default(false) + .describe('false: decisions contained within the value (CIDR containment); true: decisions that contain the value'), + no_simulated: z.boolean().optional().default(false).describe('Exclude simulated decisions'), + all: z.boolean().optional().default(false).describe('Return all results without limit'), + limit: z.number().int().positive().optional().describe('Maximum number of results to return') + }) + .strict(); + +const BanIpInput = z + .object({ + ip: z.string().optional().describe('IP address to ban (shorthand for scope=Ip)'), + range: z.string().optional().describe('CIDR range to ban (shorthand for scope=Range)'), + scope: z.string().optional().describe('Scope of the decision (Ip, Range, Username, Session, Country, AS)'), + value: z.string().optional().describe('Value of the decision scope (used with scope)'), + duration: z + .union([z.string(), z.number()]) + .optional() + .default('4h') + .describe('Duration of the ban with a unit (e.g., 4h, 30m, 7d)'), + type: z.string().optional().default('ban').describe('Type of decision (ban, captcha, etc.)'), + reason: z.string().optional().describe('Reason for the ban'), + force: z.boolean().optional().default(false).describe('Force the ban even if the IP/range is allowlisted or exceeds max duration') + }) + .strict(); + +const GetDecisionInput = z + .object({ + id: z.union([z.string(), z.number()]).describe('Decision ID') + }) + .strict(); + +const UnbanIpInput = z + .object({ + id: z.union([z.string(), z.number()]).optional().describe('Decision ID to delete'), + ip: z.string().optional().describe('Single IP address to filter by (exact match)'), + range: z.string().optional().describe('CIDR value for decisions whose scope is Range (exact match)'), + scope: z.string().optional().describe('Scope to filter by (must be combined with value)'), + value: z.string().optional().describe('Value to filter by (used with scope)'), + type: z.string().optional().describe('Decision type to filter by (ban, captcha, throttle)'), + scenario: z.string().optional().describe('Scenario name to filter by'), + all: z.boolean().optional().default(false).describe('Delete all matching decisions'), + confirm: z + .boolean() + .optional() + .default(false) + .describe('Confirm deletion when all=true or when only type/scenario filters are used') + }) + .strict(); + +type BanInput = z.infer; +type UnbanInput = z.infer; + +function validateBanTarget(input: BanInput): void { + const hasIp = Boolean(input.ip); + const hasRange = Boolean(input.range); + const hasScope = Boolean(input.scope); + const hasValue = Boolean(input.value); + + if ((hasIp || hasRange) && (hasScope || hasValue)) { + throw new Error('Provide only one of ip, range, or scope+value'); + } + if (hasIp && hasRange) { + throw new Error('Provide only one of ip, range, or scope+value'); + } + if (hasScope !== hasValue) { + throw new Error('scope and value must be provided together'); + } + if (!hasIp && !hasRange && !hasScope) { + throw new Error('Must provide ip, range, or scope+value'); + } +} + +function resolveBanTarget(input: BanInput): { scope: string; targetValue: string } { + if (input.ip) { + return { scope: normalizeScope(undefined, input.ip), targetValue: input.ip }; + } + if (input.range) { + return { scope: normalizeScope(undefined, undefined, input.range), targetValue: input.range }; + } + + const scope = assertValidDecisionScope(input.scope!); + return { scope, targetValue: assertValidScopeValue(scope, input.value!) }; +} + +async function ensureBanAllowed(ctx: AppContext, scope: string, targetValue: string, force: boolean): Promise { + if (force || (scope !== 'Ip' && scope !== 'Range')) { + return; + } + + const checkResult = await ctx.allowlists.check(targetValue); + if (checkResult.allowlisted) { + const suffix = checkResult.reason ? ` by item ${checkResult.reason}` : ''; + throw new Error(`${targetValue} is allowlisted${suffix}, use force=true to add the decision anyway`); + } +} + +function ensureBanDuration(ctx: AppContext, duration: string, durationSeconds: number, force: boolean): void { + if (force || durationSeconds <= ctx.config.maxBanDurationSeconds) { + return; + } + + const maxDuration = formatDuration(ctx.config.maxBanDurationSeconds); + throw new Error(`Duration ${duration} exceeds maximum allowed ${maxDuration}. Use force=true to override.`); +} + +function buildBanAlert(decisionType: string, scope: string, targetValue: string, duration: string, reason: string) { + const now = new Date().toISOString(); + + return { + capacity: 0, + decisions: [ + { + origin: 'cscli', + type: decisionType, + scope, + value: targetValue, + duration, + scenario: reason + } + ], + events: [], + events_count: 1, + leakspeed: '0', + message: reason, + scenario: reason, + scenario_hash: '', + scenario_version: '', + simulated: false, + source: { + scope, + value: targetValue, + ip: scope === 'Ip' ? targetValue : '', + range: scope === 'Range' ? targetValue : '', + as_name: '', + as_number: '', + cn: '' + }, + start_at: now, + stop_at: now, + created_at: now, + remediation: true, + kind: 'cscli' + }; +} + +function validateUnbanTargets(ip?: string, range?: string, scope?: string, value?: string): void { + if (ip && range) { + throw new Error('Provide only one of ip or range'); + } + if ((scope || value) && (ip || range)) { + throw new Error('Provide only one of ip, range, or scope+value'); + } + if (Boolean(scope) !== Boolean(value)) { + throw new Error('scope and value must be provided together'); + } +} + +function assertUnbanIdHasNoFilters(input: UnbanInput): void { + if (input.ip || input.range || input.scope || input.value || input.type || input.scenario) { + throw new Error('Cannot combine id with filters'); + } +} + +function buildUnbanQuery(input: UnbanInput, type: string | undefined): { ip?: string; value?: string; type?: string; scenario?: string } { + const query: { ip?: string; value?: string; type?: string; scenario?: string } = {}; + if (input.ip) query.ip = input.ip; + if (input.value) query.value = input.value; + if (type) query.type = type; + if (input.scenario) query.scenario = input.scenario; + return query; +} + +async function unbanDecisionIds(ctx: AppContext, ids: number[]): Promise { + if (ids.length === 0) { + return formatUnbanResult(0); + } + return formatUnbanResult(await deleteDecisionIds(ctx, ids)); +} + +async function unbanAll(ctx: AppContext, input: UnbanInput): Promise { + if (!input.confirm) { + throw new Error('Deleting all decisions requires confirm=true'); + } + if (input.id !== undefined || input.ip || input.range || input.scope || input.value || input.type || input.scenario) { + throw new Error('Cannot combine all=true with id or filters; use all=true with confirm=true alone to delete everything'); + } + + const decisions = await fetchActiveDecisions(ctx); + const ids = decisions.map((decision) => decision.id).filter((id): id is number => typeof id === 'number'); + return unbanDecisionIds(ctx, ids); +} + +async function unbanById(ctx: AppContext, id: string | number): Promise { + const decisionId = parsePositiveId(id, 'decision id'); + + try { + const result = await ctx.watcher!.Decisions.deleteById(decisionId); + const nbDeleted = Number(result.nbDeleted ?? 0); + if (nbDeleted === 0) { + throw new Error(`Decision ${decisionId} not found`); + } + return formatUnbanResult(nbDeleted); + } catch (error) { + const message = cleanErrorMessage(error); + if (/not found|doesn't exist/i.test(message)) { + throw new Error(`Decision ${decisionId} not found`); + } + throw error; + } +} + +async function unbanByScopeValue( + ctx: AppContext, + scope: string, + value: string, + type: string | undefined, + scenario: string | undefined +): Promise { + const normalized = assertValidDecisionScope(scope); + const normalizedValue = assertValidScopeValue(normalized, value); + + if (normalized === 'Ip') { + return unbanByQuery(ctx, { ip: normalizedValue, type, scenario }); + } + if (normalized === 'Range') { + // The delete endpoint matches the exact range value; search first so + // decisions for individual IPs inside the range are also removed. + const ids = await searchActiveDecisionIds(ctx, { ip: normalizedValue, decision_type: type, scenario }); + return unbanDecisionIds(ctx, ids); + } + + return unbanByQuery(ctx, { scope: normalized, value: normalizedValue, type, scenario }); +} + +async function unbanByRange(ctx: AppContext, range: string, type: string | undefined, scenario: string | undefined): Promise { + // Same as scope=Range: containment requires an alert search to collect IDs. + const ids = await searchActiveDecisionIds(ctx, { ip: range, decision_type: type, scenario }); + return unbanDecisionIds(ctx, ids); +} + +interface UnbanQuery { + ip?: string; + scope?: string; + value?: string; + type?: string; + scenario?: string; +} + +async function unbanByQuery(ctx: AppContext, query: UnbanQuery) { + if (!query.ip && !query.scope && !query.value && !query.type && !query.scenario) { + throw new Error('Must provide id, at least one filter, or all=true with confirm=true'); + } + + const result = await ctx.watcher!.Decisions.delete(query); + return formatUnbanResult(result.nbDeleted ?? 0); +} + +type ListDecisionsFilters = z.infer; + +interface NormalizedListDecisionsFilters { + scope?: string; + value?: string; +} + +function normalizeListDecisionsFilters(input: ListDecisionsFilters): NormalizedListDecisionsFilters { + if (input.ip && !isValidIp(input.ip) && !isValidCidr(input.ip)) { + throw new Error(`${input.ip} is not a valid ip or CIDR range`); + } + if (input.range) { + assertValidCidr(input.range); + } + const scope = input.scope ? assertValidDecisionScope(input.scope) : undefined; + const value = scope && input.value ? assertValidScopeValue(scope, input.value) : input.value; + if (input.since && !isValidTimeFilter(input.since)) { + throw new Error(`invalid since '${input.since}': use h, m, s or d (e.g. 4h, 30d)`); + } + if (input.until && !isValidTimeFilter(input.until)) { + throw new Error(`invalid until '${input.until}': use h, m, s or d (e.g. 4h, 30d)`); + } + return { scope, value }; +} + +export const decisionTools: ToolDefinition[] = [ + { + name: 'list_decisions', + title: 'List Decisions', + description: 'List active decisions from CrowdSec. Works in both bouncer and watcher modes. Mirrors `cscli decisions list`.', + inputSchema: ListDecisionsInput, + kind: 'read', + annotations: { readOnlyHint: true, idempotentHint: true }, + handler: async (input, ctx) => { + const parsed = ListDecisionsInput.parse(input); + const { scope: normalizedScope, value: normalizedValue } = normalizeListDecisionsFilters(parsed); + const { ip, range, type, origin, scenario, since, until, contains, no_simulated, all, limit } = parsed; + const decisionType = type ? assertValidDecisionType(type) : undefined; + const window = resolveListWindow(limit, all, ctx.config.maxResults); + const normalizedSince = since ? toGoDuration(since) : undefined; + const normalizedUntil = until ? toGoDuration(until) : undefined; + + if (ctx.mode === 'watcher' && ctx.watcher) { + const query: APITypes.SearchAlertsQuery = { + ip, + range, + scope: normalizedScope, + value: normalizedValue, + scenario, + decision_type: decisionType, + origin, + since: normalizedSince, + until: normalizedUntil, + simulated: no_simulated ? false : undefined, + contains, + has_active_decision: true, + limit: window.fetchLimit + }; + + const alerts = await ctx.watcher.Alerts.search(query); + + return formatDecisionList(alerts, window.displayLimit, window.maybeMore); + } + + if (ctx.mode === 'bouncer' && ctx.bouncer) { + const decisions = await ctx.bouncer.Decisions.search({ + ip, + range, + scope: normalizedScope, + value: normalizedValue, + type: decisionType, + origins: origin, + scenarios_containing: scenario, + contains + }); + + return formatBouncerDecisionList(decisions, window.displayLimit); + } + + throw new Error('No client available for the current mode'); + } + }, + { + name: 'get_decision', + title: 'Get decision details', + description: 'Get an active decision by ID, including its alert context. Requires watcher mode.', + inputSchema: GetDecisionInput, + kind: 'read', + requires: ['watcher'], + annotations: { readOnlyHint: true, idempotentHint: true }, + handler: async (input, ctx) => { + if (!ctx.watcher) { + throw new Error('Watcher client not available'); + } + + const { id } = GetDecisionInput.parse(input); + const decisionId = parsePositiveId(id, 'decision id'); + const alerts = await ctx.watcher.Alerts.search({ has_active_decision: true, limit: 0 }); + + for (const alert of alerts) { + const decision = (alert.decisions ?? []).find((candidate) => candidate.id === Number(decisionId)); + if (decision) { + return formatDecisionDetail(decision, alert); + } + } + + throw new Error(`Decision ${decisionId} not found`); + } + }, + { + name: 'ban_ip', + title: 'Ban IP', + description: + 'Add a new decision (ban, captcha, etc.) for an IP, range, or other scope. Requires watcher mode. Mirrors `cscli decisions add`.', + inputSchema: BanIpInput, + kind: 'write', + requires: ['watcher'], + annotations: { readOnlyHint: false, destructiveHint: true }, + handler: async (input, ctx) => { + if (!ctx.watcher) { + throw new Error('Watcher client not available'); + } + + const banInput = BanIpInput.parse(input); + validateBanTarget(banInput); + + const decisionType = assertValidDecisionType(banInput.type); + const { scope, targetValue } = resolveBanTarget(banInput); + const normalizedDuration = toGoDuration(banInput.duration); + const durationSeconds = typeof banInput.duration === 'number' ? banInput.duration : parseDuration(banInput.duration); + + await ensureBanAllowed(ctx, scope, targetValue, banInput.force); + ensureBanDuration(ctx, normalizedDuration, durationSeconds, banInput.force); + + const alertReason = banInput.reason?.trim() ? banInput.reason : `manual '${decisionType}' from 'crowdsec-mcp'`; + const alert = buildBanAlert(decisionType, scope, targetValue, normalizedDuration, alertReason); + const alertIds = await ctx.watcher.Alerts.pushAlerts([alert]); + + return formatBanResult(alertIds, { + scope, + value: targetValue, + type: decisionType, + duration: normalizedDuration, + reason: alertReason + }); + } + }, + { + name: 'unban_ip', + title: 'Unban IP', + description: + 'Delete decisions by ID or filters. Requires watcher mode. Broad type/scenario-only filters require confirm=true. Mirrors `cscli decisions delete`.', + inputSchema: UnbanIpInput, + kind: 'write', + requires: ['watcher'], + annotations: { readOnlyHint: false, destructiveHint: true }, + handler: async (input, ctx) => { + if (!ctx.watcher) { + throw new Error('Watcher client not available'); + } + + const unbanInput = UnbanIpInput.parse(input); + + if (unbanInput.all) { + return unbanAll(ctx, unbanInput); + } + + if (unbanInput.id !== undefined) { + assertUnbanIdHasNoFilters(unbanInput); + return unbanById(ctx, unbanInput.id); + } + + validateUnbanTargets(unbanInput.ip, unbanInput.range, unbanInput.scope, unbanInput.value); + + if (unbanInput.ip) { + assertValidIp(unbanInput.ip); + } + if (unbanInput.range) { + assertValidCidr(unbanInput.range); + } + + const normalizedType = unbanInput.type ? assertValidDecisionType(unbanInput.type) : undefined; + + const hasNarrowTarget = Boolean(unbanInput.ip || unbanInput.range || (unbanInput.scope && unbanInput.value)); + if (!hasNarrowTarget && (normalizedType || unbanInput.scenario) && unbanInput.confirm !== true) { + throw new Error( + 'Deleting decisions filtered only by type/scenario requires confirm=true; add ip, range, or scope+value to narrow the target' + ); + } + + if (unbanInput.scope && unbanInput.value) { + return unbanByScopeValue(ctx, unbanInput.scope, unbanInput.value, normalizedType, unbanInput.scenario); + } + if (unbanInput.range) { + return unbanByRange(ctx, unbanInput.range, normalizedType, unbanInput.scenario); + } + + return unbanByQuery(ctx, buildUnbanQuery(unbanInput, normalizedType)); + } + } +]; diff --git a/packages/crowdsec-mcp/src/tools/index.ts b/packages/crowdsec-mcp/src/tools/index.ts new file mode 100644 index 000000000..e2e2d5175 --- /dev/null +++ b/packages/crowdsec-mcp/src/tools/index.ts @@ -0,0 +1,9 @@ +import type { ToolDefinition } from '../types.js'; +import { statusTool } from './system.js'; +import { decisionTools } from './decisions.js'; +import { alertTools } from './alerts.js'; +import { allowlistTools } from './allowlists.js'; + +export function allTools(): ToolDefinition[] { + return [statusTool, ...decisionTools, ...alertTools, ...allowlistTools]; +} diff --git a/packages/crowdsec-mcp/src/tools/system.ts b/packages/crowdsec-mcp/src/tools/system.ts new file mode 100644 index 000000000..7fb1e88c5 --- /dev/null +++ b/packages/crowdsec-mcp/src/tools/system.ts @@ -0,0 +1,39 @@ +import { z } from 'zod'; +import { redactedUrl } from '../config.js'; +import { bullets } from '../format/common.js'; +import type { ToolDefinition } from '../types.js'; + +export const statusTool: ToolDefinition = { + name: 'crowdsec_status', + title: 'CrowdSec status', + description: 'Check the connection to the CrowdSec local API and show the authentication mode, capabilities and active MCP tools.', + inputSchema: z.object({}), + kind: 'read', + annotations: { + readOnlyHint: true, + idempotentHint: true + }, + handler: (_input, ctx) => { + const machineSuffix = ctx.mode === 'watcher' && ctx.config.machineId ? ` (${ctx.config.machineId})` : ''; + const auth = ctx.mode === 'watcher' ? `watcher${machineSuffix}` : 'bouncer'; + let writeLabel = 'no'; + if (ctx.capabilities.write) { + writeLabel = ctx.config.readonly ? 'yes (disabled by read-only)' : 'yes'; + } + const lines = [ + 'CrowdSec MCP is connected.', + '', + bullets([ + `URL: ${redactedUrl(ctx.config.url)}`, + `Authentication: ${auth}`, + `Capabilities: read=yes, write=${writeLabel}, allowlists=${ctx.capabilities.allowlists ? 'yes' : 'no'}`, + `Read-only: ${ctx.config.readonly ? 'yes' : 'no'}`, + `Default result limit: ${ctx.config.maxResults}`, + `Maximum ban duration: ${ctx.config.maxBanDuration} (force=true can override)`, + `Active tools: ${(ctx.activeTools ?? []).join(', ') || 'none'}` + ]) + ]; + + return lines.join('\n'); + } +}; diff --git a/packages/crowdsec-mcp/src/transports/http.ts b/packages/crowdsec-mcp/src/transports/http.ts new file mode 100644 index 000000000..a85667df9 --- /dev/null +++ b/packages/crowdsec-mcp/src/transports/http.ts @@ -0,0 +1,272 @@ +import http from 'node:http'; +import { timingSafeEqual } from 'node:crypto'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { Config } from '../config.js'; +import { HttpRequestError } from '../http-error.js'; + +export interface RequestServer { + server: McpServer; + cleanup?: () => Promise; +} + +export type RequestServerFactory = (headers: http.IncomingHttpHeaders) => RequestServer | Promise; + +const MAX_BODY_SIZE = 4 * 1024 * 1024; + +class BodyTooLargeError extends Error {} +class RequestAbortedError extends Error {} + +export function isAuthorizedHeader(header: string | undefined, token: string): boolean { + if (!header) { + return false; + } + + const prefix = 'Bearer '; + if (!header.startsWith(prefix)) { + return false; + } + + const provided = header.slice(prefix.length); + const expectedBuf = Buffer.from(token); + const providedBuf = Buffer.from(provided); + + if (expectedBuf.length !== providedBuf.length) { + return false; + } + + return timingSafeEqual(expectedBuf, providedBuf); +} + +function readBody(req: http.IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let size = 0; + let tooLarge = false; + let settled = false; + + const fail = (error: Error): void => { + if (settled) { + return; + } + settled = true; + reject(error); + }; + + req.on('data', (chunk: Buffer) => { + // Once oversized, discard further chunks but keep draining so the socket stays usable for the 413 response. + if (tooLarge) { + return; + } + size += chunk.length; + if (size > MAX_BODY_SIZE) { + tooLarge = true; + chunks.length = 0; + return; + } + chunks.push(chunk); + }); + + req.on('end', () => { + if (settled) { + return; + } + settled = true; + if (tooLarge) { + reject(new BodyTooLargeError('Payload too large')); + return; + } + resolve(Buffer.concat(chunks).toString('utf-8')); + }); + + req.on('error', (error) => fail(error)); + req.on('aborted', () => fail(new RequestAbortedError('Request aborted'))); + req.on('close', () => fail(new RequestAbortedError('Request aborted'))); + }); +} + +function sendJson(res: http.ServerResponse, status: number, body: string, extraHeaders?: Record): void { + res.writeHead(status, { + 'Content-Type': 'application/json', + ...extraHeaders + }); + res.end(body); +} + +function sendRpcError( + res: http.ServerResponse, + status: number, + code: number, + message: string, + extraHeaders?: Record +): void { + sendJson( + res, + status, + JSON.stringify({ + jsonrpc: '2.0', + error: { code, message }, + id: null + }), + extraHeaders + ); +} + +type JsonBodyResult = { ok: true; body: unknown } | { ok: false }; + +async function readJsonBody(req: http.IncomingMessage, res: http.ServerResponse): Promise { + let raw: string; + try { + raw = await readBody(req); + } catch (error) { + if (error instanceof BodyTooLargeError) { + sendRpcError(res, 413, -32000, 'Payload too large'); + } else if (error instanceof RequestAbortedError) { + // The client is gone: there is nothing left to answer. + } else { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`crowdsec-mcp: failed to read request body: ${message}\n`); + sendRpcError(res, 500, -32603, 'Internal server error'); + } + return { ok: false }; + } + + try { + return { ok: true, body: JSON.parse(raw) }; + } catch { + sendRpcError(res, 400, -32700, 'Parse error'); + return { ok: false }; + } +} + +function closeResource(promise: Promise | undefined, label: string): void { + if (!promise) { + return; + } + promise.catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`crowdsec-mcp: ${label} failed: ${message}\n`); + }); +} + +function sendSetupError(res: http.ServerResponse, error: unknown): void { + if (error instanceof HttpRequestError) { + sendRpcError(res, error.status, error.code, error.message); + return; + } + + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`crowdsec-mcp: request setup failed: ${message}\n`); + sendRpcError(res, 500, -32603, 'Internal server error'); +} + +async function handlePost(req: http.IncomingMessage, res: http.ServerResponse, createRequestServer: RequestServerFactory): Promise { + const bodyResult = await readJsonBody(req, res); + if (!bodyResult.ok) { + return; + } + + let requestServer: RequestServer; + try { + requestServer = await createRequestServer(req.headers); + } catch (error) { + sendSetupError(res, error); + return; + } + + // Stateless mode: each request must use a fresh server and transport. + // Reusing a stateless transport causes message ID collisions and breaks + // the initialize/notifications sequence used by MCP clients. + const { server, cleanup } = requestServer; + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true + }); + + res.on('close', () => { + closeResource(transport.close(), 'transport close'); + closeResource(server.close(), 'server close'); + if (cleanup) { + closeResource(cleanup(), 'request cleanup'); + } + }); + + try { + await server.connect(transport); + await transport.handleRequest(req, res, bodyResult.body); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`crowdsec-mcp: HTTP request failed: ${message}\n`); + if (!res.headersSent) { + sendRpcError(res, 500, -32603, 'Internal server error'); + } else { + res.end(); + } + } +} + +async function handleRequest( + req: http.IncomingMessage, + res: http.ServerResponse, + createRequestServer: RequestServerFactory, + token: string | undefined +): Promise { + const { method, url } = req; + + // Optional service gate. In passthrough mode the LAPI credential can be + // the only credential; if a service token is configured, it is required + // in addition to the per-request LAPI credential. + if (token && !isAuthorizedHeader(req.headers.authorization, token)) { + sendRpcError(res, 401, -32001, 'Unauthorized', { 'WWW-Authenticate': 'Bearer' }); + return; + } + + const pathname = url?.split('?')[0]; + if (pathname !== '/mcp') { + sendRpcError(res, 404, -32000, 'Not found'); + return; + } + + if (method === 'POST') { + await handlePost(req, res, createRequestServer); + return; + } + + sendRpcError(res, 405, -32000, 'Method not allowed'); +} + +export async function startHttpServer(createRequestServer: RequestServerFactory, config: Config): Promise { + const token = config.http.token; + + const httpServer = http.createServer((req, res) => { + void handleRequest(req, res, createRequestServer, token).catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`crowdsec-mcp: unhandled HTTP request error: ${message}\n`); + try { + if (!res.headersSent) { + sendRpcError(res, 500, -32603, 'Internal server error'); + } else if (!res.writableEnded) { + res.end(); + } + } catch (sendError) { + const sendMessage = sendError instanceof Error ? sendError.message : String(sendError); + process.stderr.write(`crowdsec-mcp: failed to send error response: ${sendMessage}\n`); + res.destroy(); + } + }); + }); + + httpServer.requestTimeout = 30_000; + httpServer.headersTimeout = 10_000; + httpServer.keepAliveTimeout = 5_000; + httpServer.maxHeadersCount = 100; + + return new Promise((resolve, reject) => { + httpServer.once('error', reject); + + httpServer.listen(config.http.port, config.http.host, () => { + httpServer.removeListener('error', reject); + resolve(httpServer); + }); + }); +} diff --git a/packages/crowdsec-mcp/src/types.ts b/packages/crowdsec-mcp/src/types.ts new file mode 100644 index 000000000..617d8c276 --- /dev/null +++ b/packages/crowdsec-mcp/src/types.ts @@ -0,0 +1,46 @@ +import type { BouncerClient, WatcherClient, APITypes } from 'crowdsec-client'; +import type { CallToolResult, ToolAnnotations } from '@modelcontextprotocol/sdk/types.js'; +import type { ZodType } from 'zod'; +import type { Config } from './config.js'; + +export type AuthMode = 'bouncer' | 'watcher'; + +export interface Capabilities { + read: boolean; + write: boolean; + allowlists: boolean; +} + +export interface AllowlistsPort { + list(): Promise; + get(name: string, withContent?: boolean): Promise; + check(value: string): Promise; + bulkCheck(targets: string[]): Promise; +} + +export interface AppContext { + mode: AuthMode; + capabilities: Capabilities; + watcher?: WatcherClient; + bouncer?: BouncerClient; + allowlists: AllowlistsPort; + config: Config; + activeTools?: string[]; + ensureReady: () => Promise; + stop: () => Promise; +} + +export type ToolRequirement = 'watcher' | 'allowlists'; +export type ToolFeature = 'alert-delete'; + +export interface ToolDefinition { + name: string; + title: string; + description: string; + inputSchema: ZodType; + kind: 'read' | 'write'; + requires?: ToolRequirement[]; + feature?: ToolFeature; + annotations?: ToolAnnotations; + handler: (input: unknown, ctx: AppContext) => CallToolResult | string | Promise; +} diff --git a/packages/crowdsec-mcp/src/utils.ts b/packages/crowdsec-mcp/src/utils.ts new file mode 100644 index 000000000..87cad9167 --- /dev/null +++ b/packages/crowdsec-mcp/src/utils.ts @@ -0,0 +1,251 @@ +import { isIP } from 'node:net'; +import { parseDuration } from 'crowdsec-client'; + +export { formatDuration, parseDuration, toGoDuration } from 'crowdsec-client'; + +export function isValidIp(value: string): boolean { + return isIP(value) !== 0; +} + +export function isValidCidr(value: string): boolean { + const [address, prefix, ...rest] = value.split('/'); + if (rest.length > 0 || prefix === undefined) { + return false; + } + + const family = isIP(address); + if (family === 0) { + return false; + } + + if (!/^\d+$/.test(prefix)) { + return false; + } + + const prefixNumber = Number(prefix); + return family === 4 ? prefixNumber >= 0 && prefixNumber <= 32 : prefixNumber >= 0 && prefixNumber <= 128; +} + +/** + * Mirror cscli's scope normalization and IP/range validation. + */ +export function normalizeScope(scope?: string, ip?: string, range?: string): string { + if (range) { + if (!isValidCidr(range)) { + throw new Error(`${range} is not a valid range`); + } + return 'Range'; + } + + if (ip) { + if (!isValidIp(ip)) { + throw new Error(`${ip} is not a valid ip`); + } + return 'Ip'; + } + + if (!scope) { + return 'Ip'; + } + + const known: Record = { + ip: 'Ip', + range: 'Range', + username: 'Username', + session: 'Session', + country: 'Country', + as: 'AS' + }; + + const normalized = known[scope.toLowerCase()]; + if (normalized) { + return normalized; + } + + return scope.charAt(0).toUpperCase() + scope.slice(1); +} + +const DECISION_SCOPES = new Set(['Ip', 'Range', 'Username', 'Session', 'Country', 'AS']); +const DECISION_TYPES = new Set(['ban', 'captcha', 'throttle']); + +export function assertValidDecisionScope(scope: string): string { + const normalized = normalizeScope(scope); + if (!DECISION_SCOPES.has(normalized)) { + throw new Error(`invalid scope '${scope}' (expected Ip, Range, Username, Session, Country or AS)`); + } + return normalized; +} + +export function assertValidDecisionType(type: string): string { + const normalized = type.toLowerCase(); + if (!DECISION_TYPES.has(normalized)) { + throw new Error(`invalid decision type '${type}' (expected ban, captcha or throttle)`); + } + return normalized; +} + +const COUNTRY_CODE = /^[A-Z]{2}$/; +const AS_NUMBER = /^(?:AS)?\d+$/i; + +/** + * Validate and normalize the value for a non-IP decision scope. + * Ip/Range values are validated as IP/CIDR; Country values must be ISO-like + * two-letter codes; AS values must be numeric ASNs. + */ +export function assertValidScopeValue(scope: string, value: string): string { + const normalizedScope = assertValidDecisionScope(scope); + const trimmed = value.trim(); + + if (!trimmed) { + throw new Error('value must not be empty'); + } + + switch (normalizedScope) { + case 'Country': { + const code = trimmed.toUpperCase(); + if (!COUNTRY_CODE.test(code) || code === 'ZZ') { + throw new Error(`invalid country code '${value}': use a two-letter ISO 3166 code (e.g. FR, US)`); + } + return code; + } + case 'AS': { + if (!AS_NUMBER.test(trimmed)) { + throw new Error(`invalid AS number '${value}': use a numeric ASN (e.g. 64512)`); + } + const asn = trimmed.replace(/^AS/i, ''); + if (Number(asn) <= 0) { + throw new Error(`invalid AS number '${value}': use a positive numeric ASN (e.g. 64512)`); + } + return asn; + } + case 'Ip': + assertValidIp(trimmed); + return trimmed; + case 'Range': + assertValidCidr(trimmed); + return trimmed; + default: + return trimmed; + } +} + +export function parsePositiveId(value: string | number, name = 'id'): string { + const raw = String(value).trim(); + if (!/^\d+$/.test(raw) || Number(raw) <= 0) { + throw new Error(`${name} must be a positive integer`); + } + return raw; +} + +export interface ListWindow { + /** Number of rows to render. */ + displayLimit: number; + /** Limit to send to the LAPI. 0 means "no limit". */ + fetchLimit: number; + /** True when the LAPI response may contain more rows than it returned. */ + maybeMore: boolean; +} + +/** + * Resolve pagination for list tools. + * + * An explicit `limit` fetches all matching rows so the hidden count can be + * exact. Without an explicit limit we keep the request small and only hint + * that more rows may exist. + */ +export function resolveListWindow(limit: number | undefined, all: boolean, defaultLimit: number): ListWindow { + if (all) { + return { displayLimit: Number.MAX_SAFE_INTEGER, fetchLimit: 0, maybeMore: false }; + } + + const displayLimit = limit ?? defaultLimit; + return { + displayLimit, + fetchLimit: limit === undefined ? displayLimit : 0, + maybeMore: limit === undefined + }; +} + +export function assertValidIp(value: string, name = 'ip'): void { + if (!isValidIp(value)) { + throw new Error(`${value} is not a valid ${name}`); + } +} + +export function assertValidCidr(value: string, name = 'range'): void { + if (!isValidCidr(value)) { + throw new Error(`${value} is not a valid ${name}`); + } +} + +export function isValidTimeFilter(value: string): boolean { + try { + parseDuration(value); + return true; + } catch { + return false; + } +} + +export interface DecisionExpiry { + expires?: string; + active: boolean; +} + +export function resolveDecisionExpiry( + decision: { until?: string; duration?: string }, + base?: string | Date, + now = Date.now() +): DecisionExpiry { + if (decision.until) { + const time = Date.parse(decision.until); + if (Number.isNaN(time)) { + return { active: false }; + } + return { expires: new Date(time).toISOString(), active: time > now }; + } + + const duration = decision.duration?.trim(); + if (!duration) { + return { active: true }; + } + + if (duration.startsWith('-')) { + return { active: false }; + } + + let seconds: number; + try { + seconds = parseDuration(duration); + } catch { + return { active: false }; + } + + let baseDate: Date; + if (base instanceof Date) { + baseDate = base; + } else if (base === undefined) { + baseDate = new Date(now); + } else { + baseDate = new Date(base); + } + + if (Number.isNaN(baseDate.getTime())) { + return { active: true }; + } + + const expires = new Date(baseDate.getTime() + seconds * 1000); + return { expires: expires.toISOString(), active: expires.getTime() > now }; +} + +export function splitList(value?: string): string[] | undefined { + if (!value) { + return undefined; + } + + const items = value + .split(',') + .map((item) => item.trim()) + .filter(Boolean); + return items.length > 0 ? items : undefined; +} diff --git a/packages/crowdsec-mcp/src/version.ts b/packages/crowdsec-mcp/src/version.ts new file mode 100644 index 000000000..8768c433c --- /dev/null +++ b/packages/crowdsec-mcp/src/version.ts @@ -0,0 +1,4 @@ +import { pkg } from './pkg.js'; + +export const VERSION = pkg.version; +export const NAME = pkg.name; diff --git a/packages/crowdsec-mcp/tests/allowlists-port.test.ts b/packages/crowdsec-mcp/tests/allowlists-port.test.ts new file mode 100644 index 000000000..3486ea322 --- /dev/null +++ b/packages/crowdsec-mcp/tests/allowlists-port.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, vi } from 'vitest'; +import { createAllowlistsPort, unavailableAllowlistsPort } from '../src/allowlists-port.js'; +import type { WatcherClient } from 'crowdsec-client'; + +function makeFakeWatcher() { + return { + Allowlists: { + list: vi.fn().mockResolvedValue([{ name: 'test-list' }]), + get: vi.fn().mockResolvedValue({ name: 'test-list', items: [] }), + check: vi.fn().mockResolvedValue({ allowlisted: true, reason: 'match' }), + bulkCheck: vi.fn().mockResolvedValue({ results: [] }) + } + } as unknown as WatcherClient; +} + +describe('createAllowlistsPort', () => { + it('should delegate list to watcher.Allowlists.list', async () => { + const watcher = makeFakeWatcher(); + const port = createAllowlistsPort(watcher); + + const result = await port.list(); + + expect(watcher.Allowlists.list).toHaveBeenCalled(); + expect(result).toEqual([{ name: 'test-list' }]); + }); + + it('should delegate get to watcher.Allowlists.get', async () => { + const watcher = makeFakeWatcher(); + const port = createAllowlistsPort(watcher); + + const result = await port.get('my-list', false); + + expect(watcher.Allowlists.get).toHaveBeenCalledWith('my-list', false); + expect(result).toEqual({ name: 'test-list', items: [] }); + }); + + it('should delegate check to watcher.Allowlists.check', async () => { + const watcher = makeFakeWatcher(); + const port = createAllowlistsPort(watcher); + + const result = await port.check('1.2.3.4'); + + expect(watcher.Allowlists.check).toHaveBeenCalledWith('1.2.3.4'); + expect(result).toEqual({ allowlisted: true, reason: 'match' }); + }); + + it('should delegate bulkCheck to watcher.Allowlists.bulkCheck', async () => { + const watcher = makeFakeWatcher(); + const port = createAllowlistsPort(watcher); + + const result = await port.bulkCheck(['1.2.3.4', '5.6.7.8']); + + expect(watcher.Allowlists.bulkCheck).toHaveBeenCalledWith(['1.2.3.4', '5.6.7.8']); + expect(result).toEqual({ results: [] }); + }); +}); + +describe('unavailableAllowlistsPort', () => { + it('should throw on list', async () => { + const port = unavailableAllowlistsPort(); + await expect(port.list()).rejects.toThrow('allowlists are not available in bouncer mode'); + }); + + it('should throw on get', async () => { + const port = unavailableAllowlistsPort(); + await expect(port.get('x')).rejects.toThrow('allowlists are not available in bouncer mode'); + }); + + it('should throw on check', async () => { + const port = unavailableAllowlistsPort(); + await expect(port.check('1.2.3.4')).rejects.toThrow('allowlists are not available in bouncer mode'); + }); + + it('should throw on bulkCheck', async () => { + const port = unavailableAllowlistsPort(); + await expect(port.bulkCheck(['1.2.3.4'])).rejects.toThrow('allowlists are not available in bouncer mode'); + }); +}); diff --git a/packages/crowdsec-mcp/tests/client.test.ts b/packages/crowdsec-mcp/tests/client.test.ts new file mode 100644 index 000000000..8dd674f3e --- /dev/null +++ b/packages/crowdsec-mcp/tests/client.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { createPassthroughContext, resolvePassthroughAuth } from '../src/client.js'; +import { HttpRequestError } from '../src/http-error.js'; +import { makeConfig } from './helpers.js'; + +describe('passthrough auth', () => { + it('requires a LAPI credential', () => { + expect(() => resolvePassthroughAuth({})).toThrow(HttpRequestError); + + try { + resolvePassthroughAuth({}); + } catch (error) { + expect(error).toMatchObject({ status: 401, code: -32001 }); + } + }); + + it('rejects both credential headers', () => { + expect(() => + resolvePassthroughAuth({ + 'x-crowdsec-api-key': 'key', + 'x-crowdsec-token': 'jwt' + }) + ).toThrow(HttpRequestError); + + try { + resolvePassthroughAuth({ + 'x-crowdsec-api-key': 'key', + 'x-crowdsec-token': 'jwt' + }); + } catch (error) { + expect(error).toMatchObject({ status: 400, code: -32602 }); + } + }); + + it('resolves a bouncer API key', () => { + expect(resolvePassthroughAuth({ 'x-crowdsec-api-key': 'key' })).toEqual({ mode: 'bouncer', apiKey: 'key' }); + }); + + it('resolves a watcher token', () => { + expect(resolvePassthroughAuth({ 'x-crowdsec-token': 'jwt' })).toEqual({ mode: 'watcher', token: 'jwt' }); + }); +}); + +describe('createPassthroughContext', () => { + it('builds a read-only bouncer context', async () => { + const context = await createPassthroughContext(makeConfig({ passthrough: true }), { + 'x-crowdsec-api-key': 'key' + }); + + try { + expect(context.mode).toBe('bouncer'); + expect(context.capabilities).toEqual({ read: true, write: false, allowlists: false }); + } finally { + await context.stop(); + } + }); + + it('builds a watcher context', async () => { + const context = await createPassthroughContext(makeConfig({ passthrough: true }), { + 'x-crowdsec-token': 'jwt' + }); + + try { + expect(context.mode).toBe('watcher'); + expect(context.capabilities).toEqual({ read: true, write: true, allowlists: true }); + } finally { + await context.stop(); + } + }); +}); diff --git a/packages/crowdsec-mcp/tests/config.test.ts b/packages/crowdsec-mcp/tests/config.test.ts new file mode 100644 index 000000000..c4cca325d --- /dev/null +++ b/packages/crowdsec-mcp/tests/config.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it } from 'vitest'; +import { loadConfig, parseCliArgs } from '../src/config.js'; + +describe('config', () => { + it('auto-detects bouncer authentication from the API key', () => { + const config = loadConfig({}, { CROWDSEC_URL: 'https://crowdsec:8080', CROWDSEC_API_KEY: 'key' }); + + expect(config.auth).toBe('bouncer'); + expect(config.readonly).toBe(true); + expect(config.maxResults).toBe(25); + expect(config.transport).toBe('stdio'); + }); + + it('auto-detects watcher authentication from machine credentials', () => { + const config = loadConfig( + {}, + { CROWDSEC_URL: 'https://crowdsec:8080', CROWDSEC_MACHINE_ID: 'machine', CROWDSEC_PASSWORD: 'password' } + ); + + expect(config.auth).toBe('watcher'); + expect(config.machineId).toBe('machine'); + }); + + it('prefers bouncer authentication when both credentials are provided', () => { + const config = loadConfig( + {}, + { + CROWDSEC_URL: 'https://crowdsec:8080', + CROWDSEC_API_KEY: 'key', + CROWDSEC_MACHINE_ID: 'machine', + CROWDSEC_PASSWORD: 'password' + } + ); + + expect(config.auth).toBe('bouncer'); + }); + + it('honours explicit auth', () => { + const config = loadConfig( + {}, + { + CROWDSEC_URL: 'https://crowdsec:8080', + CROWDSEC_AUTH: 'watcher', + CROWDSEC_MACHINE_ID: 'machine', + CROWDSEC_PASSWORD: 'password' + } + ); + + expect(config.auth).toBe('watcher'); + }); + + it('rejects an invalid auth', () => { + expect(() => loadConfig({}, { CROWDSEC_URL: 'https://crowdsec:8080', CROWDSEC_AUTH: 'invalid', CROWDSEC_API_KEY: 'key' })).toThrow( + /CROWDSEC_AUTH/ + ); + }); + + it('requires the three TLS files together', () => { + expect(() => + loadConfig( + {}, + { + CROWDSEC_URL: 'https://crowdsec:8080', + CROWDSEC_API_KEY: 'key', + CROWDSEC_TLS_CERT: '/tmp/cert.pem' + } + ) + ).toThrow(/TLS/); + }); + + it('defaults to watcher authentication when only TLS is configured', () => { + const config = loadConfig( + {}, + { + CROWDSEC_URL: 'https://crowdsec:8080', + CROWDSEC_TLS_CERT: '/tmp/cert.pem', + CROWDSEC_TLS_KEY: '/tmp/key.pem', + CROWDSEC_TLS_CA: '/tmp/ca.pem' + } + ); + + expect(config.auth).toBe('watcher'); + expect(config.tls).toEqual({ cert: '/tmp/cert.pem', key: '/tmp/key.pem', ca: '/tmp/ca.pem' }); + }); + + it('keeps an explicit bouncer mode with TLS', () => { + const config = loadConfig( + {}, + { + CROWDSEC_URL: 'https://crowdsec:8080', + CROWDSEC_AUTH: 'bouncer', + CROWDSEC_TLS_CERT: '/tmp/cert.pem', + CROWDSEC_TLS_KEY: '/tmp/key.pem', + CROWDSEC_TLS_CA: '/tmp/ca.pem' + } + ); + + expect(config.auth).toBe('bouncer'); + }); + + it('requires a token for HTTP transport', () => { + expect(() => + loadConfig( + {}, + { + CROWDSEC_URL: 'https://crowdsec:8080', + CROWDSEC_API_KEY: 'key', + CROWDSEC_MCP_TRANSPORT: 'http' + } + ) + ).toThrow(/HTTP transport requires/); + }); + + it('accepts HTTP transport with a token', () => { + const config = loadConfig( + {}, + { + CROWDSEC_URL: 'https://crowdsec:8080', + CROWDSEC_API_KEY: 'key', + CROWDSEC_MCP_TRANSPORT: 'http', + CROWDSEC_MCP_HTTP_TOKEN: 'secret' + } + ); + + expect(config.transport).toBe('http'); + expect(config.http.token).toBe('secret'); + }); + + it('allows passthrough HTTP without a service token', () => { + const config = loadConfig( + {}, + { + CROWDSEC_URL: 'https://crowdsec:8080', + CROWDSEC_MCP_TRANSPORT: 'http', + CROWDSEC_MCP_PASSTHROUGH: 'true' + } + ); + + expect(config.passthrough).toBe(true); + expect(config.http.token).toBeUndefined(); + expect(config.auth).toBe('watcher'); + }); + + it('rejects passthrough over stdio', () => { + expect(() => + loadConfig( + {}, + { + CROWDSEC_URL: 'https://crowdsec:8080', + CROWDSEC_MCP_PASSTHROUGH: 'true' + } + ) + ).toThrow(/passthrough mode requires/); + }); + + it('parses the alert-delete passthrough override', () => { + const enabled = loadConfig( + {}, + { + CROWDSEC_URL: 'https://crowdsec:8080', + CROWDSEC_API_KEY: 'key', + CROWDSEC_MCP_ALLOW_ALERT_DELETE_WITHOUT_TOKEN: 'true' + } + ); + expect(enabled.allowAlertDeleteWithoutToken).toBe(true); + + const { values } = parseCliArgs(['--allow-alert-delete-without-token']); + const fromCli = loadConfig(values, { CROWDSEC_URL: 'https://crowdsec:8080', CROWDSEC_API_KEY: 'key' }); + expect(fromCli.allowAlertDeleteWithoutToken).toBe(true); + }); + + it('enables writes with --write', () => { + const { values } = parseCliArgs(['--write']); + const config = loadConfig(values, { CROWDSEC_URL: 'https://crowdsec:8080', CROWDSEC_API_KEY: 'key' }); + + expect(config.readonly).toBe(false); + }); + + it('reads comma-separated tool filters', () => { + const config = loadConfig( + {}, + { + CROWDSEC_URL: 'https://crowdsec:8080', + CROWDSEC_API_KEY: 'key', + CROWDSEC_MCP_TOOLS: 'crowdsec_status, list_alerts', + CROWDSEC_MCP_DISABLE_TOOLS: 'delete_alerts' + } + ); + + expect(config.tools).toEqual(['crowdsec_status', 'list_alerts']); + expect(config.disableTools).toEqual(['delete_alerts']); + }); + + it('keeps delete_alerts disabled unless explicitly enabled', () => { + const base = { CROWDSEC_URL: 'https://crowdsec:8080', CROWDSEC_API_KEY: 'key' }; + + expect(loadConfig({}, base).enableAlertDelete).toBe(false); + expect(loadConfig({}, { ...base, CROWDSEC_MCP_ENABLE_ALERT_DELETE: 'true' }).enableAlertDelete).toBe(true); + + const { values } = parseCliArgs(['--enable-alert-delete']); + expect(loadConfig(values, base).enableAlertDelete).toBe(true); + }); + + it('rejects an invalid max-results', () => { + expect(() => + loadConfig({}, { CROWDSEC_URL: 'https://crowdsec:8080', CROWDSEC_API_KEY: 'key', CROWDSEC_MCP_MAX_RESULTS: '0' }) + ).toThrow(/CROWDSEC_MCP_MAX_RESULTS/); + }); + + it('rejects an invalid URL', () => { + expect(() => loadConfig({}, { CROWDSEC_URL: 'crowdsec:8080', CROWDSEC_API_KEY: 'key' })).toThrow(/http/); + }); +}); diff --git a/packages/crowdsec-mcp/tests/docs.test.ts b/packages/crowdsec-mcp/tests/docs.test.ts new file mode 100644 index 000000000..40d8b647b --- /dev/null +++ b/packages/crowdsec-mcp/tests/docs.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, it } from 'vitest'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import type { PromptMessage, ReadResourceResult } from '@modelcontextprotocol/sdk/types.js'; +import { DOC_LINKS, buildInstructions, docResources } from '../src/docs.js'; +import type { DocResource } from '../src/docs.js'; +import { createServer, selectTools } from '../src/registry.js'; +import { allTools } from '../src/tools/index.js'; +import type { AppContext } from '../src/types.js'; +import { makeConfig, makeContext } from './helpers.js'; + +async function connect(ctx: AppContext): Promise<{ client: Client; close: () => Promise }> { + const server = createServer(selectTools(allTools(), ctx), ctx); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: 'crowdsec-mcp-docs-test', version: '0.0.0' }); + + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + + return { + client, + close: async () => { + await client.close(); + await server.close(); + } + }; +} + +function docResource(uri: string): DocResource { + const resource = docResources.find((entry) => entry.uri === uri); + if (!resource) { + throw new Error(`Unknown resource: ${uri}`); + } + return resource; +} + +function resourceText(contents: ReadResourceResult['contents']): string { + return contents.map((content) => ('text' in content ? content.text : '')).join(''); +} + +function promptText(message: PromptMessage | undefined): string { + const content = message?.content; + return content && 'text' in content ? content.text : ''; +} + +const watcherContext = (): AppContext => makeContext(); +const bouncerContext = (): AppContext => + makeContext({ + mode: 'bouncer', + capabilities: { read: true, write: false, allowlists: false }, + config: makeConfig({ readonly: true }) + }); + +describe('server instructions', () => { + it('describes watcher read/write mode and links upstream docs', () => { + const instructions = buildInstructions(makeContext({ config: makeConfig({ readonly: false }) })); + + expect(instructions).toContain('watcher'); + expect(instructions).toContain('read/write'); + expect(instructions).toContain(DOC_LINKS.concepts); + expect(instructions).toContain('crowdsec://docs/triage'); + }); + + it('describes bouncer read-only limitations', () => { + const instructions = buildInstructions(bouncerContext()); + + expect(instructions).toContain('bouncer (API key)'); + expect(instructions).toContain('Alerts, allowlists and write tools are unavailable'); + expect(instructions).toContain('read-only'); + expect(instructions).not.toContain('list_decisions then get_decision'); + }); + + it('hides delete_alerts in read-only mode even when it is enabled', () => { + const instructions = buildInstructions(makeContext({ config: makeConfig({ readonly: true, enableAlertDelete: true }) })); + + expect(instructions).toContain('delete_alerts: hidden'); + expect(instructions).not.toContain('delete_alerts: available'); + }); + + it('reports delete_alerts as available in watcher write mode', () => { + const instructions = buildInstructions(makeContext({ config: makeConfig({ readonly: false, enableAlertDelete: true }) })); + + expect(instructions).toContain('delete_alerts: available'); + }); +}); + +describe('doc resources', () => { + it('exposes unique, well-formed resources', () => { + const uris = docResources.map((resource) => resource.uri); + + expect(new Set(uris).size).toBe(uris.length); + for (const resource of docResources) { + expect(resource.uri).toMatch(/^crowdsec:\/\/docs\//); + expect(resource.title.length).toBeGreaterThan(0); + } + }); + + it('includes upstream links and no unresolved values', () => { + for (const resource of docResources) { + const text = resource.render(watcherContext()); + + expect(text).toContain(DOC_LINKS.concepts); + expect(text).not.toContain('undefined'); + expect(text.length).toBeGreaterThan(200); + } + }); + + it('adapts the triage resource to bouncer mode', () => { + const text = docResource('crowdsec://docs/triage').render(bouncerContext()); + + expect(text).toContain('bouncer mode'); + expect(text).not.toContain('This server runs in watcher mode'); + }); +}); + +describe('prompts', () => { + it('renders the default triage window', async () => { + const { client, close } = await connect(watcherContext()); + + try { + const prompt = await client.getPrompt({ name: 'triage_alerts' }); + const text = promptText(prompt.messages[0]); + + expect(text).toContain('last 24h'); + expect(text).toContain('list_alerts'); + expect(text).toContain('ban_ip'); + } finally { + await close(); + } + }); + + it('respects read-only mode in the IP investigation', async () => { + const { client, close } = await connect(makeContext({ config: makeConfig({ readonly: true }) })); + + try { + const prompt = await client.getPrompt({ name: 'investigate_ip', arguments: { ip: '203.0.113.9' } }); + const text = promptText(prompt.messages[0]); + + expect(text).toContain('203.0.113.9'); + expect(text).toContain('check_allowlist'); + expect(text).toContain('read-only'); + } finally { + await close(); + } + }); + + it('adapts the IP investigation to bouncer mode', async () => { + const { client, close } = await connect(bouncerContext()); + + try { + const prompt = await client.getPrompt({ name: 'investigate_ip', arguments: { ip: '203.0.113.9' } }); + const text = promptText(prompt.messages[0]); + + expect(text).toContain('watcher credentials'); + expect(text).toContain('list_decisions'); + expect(text).not.toContain('Call list_alerts'); + expect(text).not.toContain('check_allowlist'); + } finally { + await close(); + } + }); + + it('lists banned IPs from a country and normalises the country code', async () => { + const { client, close } = await connect(watcherContext()); + + try { + const prompt = await client.getPrompt({ name: 'banned_from_country', arguments: { country: 'fr' } }); + const text = promptText(prompt.messages[0]); + + expect(text).toContain('geolocated in FR'); + expect(text).toContain('Country column'); + expect(text).toContain('list_alerts'); + } finally { + await close(); + } + }); + + it('rejects an invalid country code', async () => { + const { client, close } = await connect(watcherContext()); + + try { + await expect(client.getPrompt({ name: 'banned_from_country', arguments: { country: 'FRA' } })).rejects.toThrow(); + } finally { + await close(); + } + }); + + it('rejects an invalid IP argument', async () => { + const { client, close } = await connect(watcherContext()); + + try { + await expect(client.getPrompt({ name: 'investigate_ip', arguments: { ip: 'not-an-ip' } })).rejects.toThrow(); + } finally { + await close(); + } + }); +}); + +describe('MCP surface', () => { + it('exposes instructions, resources and prompts to a client', async () => { + const { client, close } = await connect(watcherContext()); + + try { + expect(client.getInstructions()).toContain('CrowdSec MCP exposes alerts'); + + const resources = await client.listResources(); + expect(resources.resources.map((resource) => resource.uri).sort()).toEqual([ + 'crowdsec://docs/concepts', + 'crowdsec://docs/overview', + 'crowdsec://docs/triage' + ]); + + const read = await client.readResource({ uri: 'crowdsec://docs/concepts' }); + expect(read.contents).toHaveLength(1); + expect(read.contents[0]).toMatchObject({ mimeType: 'text/markdown' }); + expect(resourceText(read.contents)).toContain('alert'); + + const prompts = await client.listPrompts(); + expect(prompts.prompts.map((prompt) => prompt.name).sort()).toEqual(['banned_from_country', 'investigate_ip', 'triage_alerts']); + + const prompt = await client.getPrompt({ name: 'investigate_ip', arguments: { ip: '198.51.100.7' } }); + expect(prompt.messages[0]?.role).toBe('user'); + expect(promptText(prompt.messages[0])).toContain('198.51.100.7'); + + const tools = await client.listTools(); + expect(tools.tools.map((tool) => tool.name)).toContain('crowdsec_status'); + } finally { + await close(); + } + }); + + it('keeps the resources available in bouncer mode', async () => { + const { client, close } = await connect(bouncerContext()); + + try { + const resources = await client.listResources(); + expect(resources.resources).toHaveLength(3); + + const read = await client.readResource({ uri: 'crowdsec://docs/overview' }); + expect(resourceText(read.contents)).toContain('bouncer (API key)'); + + const prompts = await client.listPrompts(); + expect(prompts.prompts.map((prompt) => prompt.name)).toEqual(['investigate_ip']); + } finally { + await close(); + } + }); +}); diff --git a/packages/crowdsec-mcp/tests/e2e/http/acquis/nginx.yaml b/packages/crowdsec-mcp/tests/e2e/http/acquis/nginx.yaml new file mode 100644 index 000000000..3ce102c89 --- /dev/null +++ b/packages/crowdsec-mcp/tests/e2e/http/acquis/nginx.yaml @@ -0,0 +1,4 @@ +filenames: + - /var/log/nginx/access.log +labels: + type: nginx diff --git a/packages/crowdsec-mcp/tests/e2e/http/compose.yaml b/packages/crowdsec-mcp/tests/e2e/http/compose.yaml new file mode 100644 index 000000000..611ce5588 --- /dev/null +++ b/packages/crowdsec-mcp/tests/e2e/http/compose.yaml @@ -0,0 +1,75 @@ +name: crowdsec-mcp-e2e + +services: + crowdsec: + image: crowdsecurity/crowdsec:latest + container_name: crowdsec-mcp-e2e-lapi + ports: + - '${E2E_LAPI_PORT:-28080}:8080' + - '${E2E_MCP_PORT:-23939}:3939' + - '${E2E_MCP_OPEN_PORT:-23940}:3940' + volumes: + - crowdsec-data:/var/lib/crowdsec/data + - nginx-logs:/var/log/nginx + - ./acquis/nginx.yaml:/etc/crowdsec/acquis.d/nginx.yaml:ro + restart: 'no' + + mcp: + image: node:24-alpine + container_name: crowdsec-mcp-e2e-mcp + network_mode: 'service:crowdsec' + depends_on: + - crowdsec + volumes: + - ${REPO_ROOT}:/app + working_dir: /app/packages/crowdsec-mcp + command: ['node', 'dist/index.js'] + environment: + CROWDSEC_URL: http://127.0.0.1:8080 + CROWDSEC_AUTH: watcher + CROWDSEC_MCP_TRANSPORT: http + CROWDSEC_MCP_HTTP_HOST: 0.0.0.0 + CROWDSEC_MCP_HTTP_PORT: 3939 + CROWDSEC_MCP_HTTP_TOKEN: ${E2E_MCP_TOKEN} + CROWDSEC_MCP_PASSTHROUGH: 'true' + CROWDSEC_MCP_READONLY: 'false' + CROWDSEC_MCP_ENABLE_ALERT_DELETE: 'true' + CROWDSEC_MCP_MAX_BAN_DURATION: 24h + restart: 'no' + + mcp-open: + image: node:24-alpine + container_name: crowdsec-mcp-e2e-mcp-open + network_mode: 'service:crowdsec' + depends_on: + - crowdsec + volumes: + - ${REPO_ROOT}:/app + working_dir: /app/packages/crowdsec-mcp + command: ['node', 'dist/index.js'] + environment: + CROWDSEC_URL: http://127.0.0.1:8080 + CROWDSEC_AUTH: watcher + CROWDSEC_MCP_TRANSPORT: http + CROWDSEC_MCP_HTTP_HOST: 0.0.0.0 + CROWDSEC_MCP_HTTP_PORT: 3940 + CROWDSEC_MCP_HTTP_TOKEN: '' + CROWDSEC_MCP_PASSTHROUGH: 'true' + CROWDSEC_MCP_READONLY: 'false' + CROWDSEC_MCP_ENABLE_ALERT_DELETE: 'true' + CROWDSEC_MCP_ALLOW_ALERT_DELETE_WITHOUT_TOKEN: 'true' + CROWDSEC_MCP_MAX_BAN_DURATION: 24h + restart: 'no' + + nginx-logs: + image: node:24-alpine + container_name: crowdsec-mcp-e2e-nginx-logs + volumes: + - nginx-logs:/var/log/nginx + - ./generate-nginx-logs.mjs:/generate-nginx-logs.mjs:ro + command: ['node', '/generate-nginx-logs.mjs'] + restart: 'no' + +volumes: + crowdsec-data: + nginx-logs: diff --git a/packages/crowdsec-mcp/tests/e2e/http/generate-nginx-logs.mjs b/packages/crowdsec-mcp/tests/e2e/http/generate-nginx-logs.mjs new file mode 100644 index 000000000..e7c067d49 --- /dev/null +++ b/packages/crowdsec-mcp/tests/e2e/http/generate-nginx-logs.mjs @@ -0,0 +1,88 @@ +import { appendFileSync, mkdirSync, statSync, truncateSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; + +const logPath = '/var/log/nginx/access.log'; +const maxSize = 20 * 1024 * 1024; + +const attackers = ['203.0.113.66']; +const benignIps = ['198.51.100.23', '192.0.2.44', '203.0.113.10', '192.0.2.88']; +const scanPaths = [ + '/.env', + '/wp-login.php', + '/phpmyadmin/', + '/admin', + '/server-status', + '/.git/config', + '/actuator/env', + '/vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php', + '/config.json', + '/backup.zip', + '/xmlrpc.php', + '/cgi-bin/luci', + '/solr/admin/info/system', + '/console/login/LoginForm.jsp', + '/owa/auth/logon.aspx' +]; +const normalPaths = ['/', '/index.html', '/api/health', '/assets/app.css', '/robots.txt', '/favicon.ico']; +const userAgents = [ + 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)', + 'curl/8.5.0', + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/140.0 Safari/537.36', + 'sqlmap/1.8.7#stable (https://sqlmap.org)' +]; +const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + +mkdirSync(dirname(logPath), { recursive: true }); +writeFileSync(logPath, ''); + +function pick(values) { + return values[Math.floor(Math.random() * values.length)]; +} + +function timestamp(date) { + const day = String(date.getUTCDate()).padStart(2, '0'); + const month = months[date.getUTCMonth()]; + const year = date.getUTCFullYear(); + const hours = String(date.getUTCHours()).padStart(2, '0'); + const minutes = String(date.getUTCMinutes()).padStart(2, '0'); + const seconds = String(date.getUTCSeconds()).padStart(2, '0'); + return `${day}/${month}/${year}:${hours}:${minutes}:${seconds} +0000`; +} + +function accessLine(ip, path, status) { + const bytes = status === 200 ? 512 + Math.floor(Math.random() * 4096) : 162 + Math.floor(Math.random() * 300); + return `${ip} - - [${timestamp(new Date())}] "GET ${path} HTTP/1.1" ${status} ${bytes} "-" "${pick(userAgents)}"\n`; +} + +function rotateIfNeeded() { + try { + if (statSync(logPath).size > maxSize) { + truncateSync(logPath, 0); + } + } catch { + // The file may not exist yet. + } +} + +let lines = 0; + +setInterval(() => { + rotateIfNeeded(); + + let batch = ''; + for (let i = 0; i < 12; i++) { + batch += accessLine(pick(attackers), pick(scanPaths), 404); + } + for (let i = 0; i < 8; i++) { + batch += accessLine(pick(benignIps), pick(normalPaths), 200); + } + + appendFileSync(logPath, batch); + lines += 20; + + if (lines % 200 === 0) { + console.log(`[nginx-logs] ${lines} lines written to ${logPath}`); + } +}, 1000); + +console.log(`[nginx-logs] writing realistic nginx access logs to ${logPath}`); diff --git a/packages/crowdsec-mcp/tests/e2e/http/live.test.ts b/packages/crowdsec-mcp/tests/e2e/http/live.test.ts new file mode 100644 index 000000000..f04c0b136 --- /dev/null +++ b/packages/crowdsec-mcp/tests/e2e/http/live.test.ts @@ -0,0 +1,242 @@ +import { setTimeout as delay } from 'node:timers/promises'; +import { describe, expect, it } from 'vitest'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; + +const enabled = process.env.CROWDSEC_MCP_HTTP_E2E === '1'; +const mcpUrl = process.env.CROWDSEC_MCP_HTTP_URL; +const openMcpUrl = process.env.CROWDSEC_MCP_HTTP_OPEN_URL; +const bouncerKey = process.env.CROWDSEC_BOUNCER_KEY; +const watcherToken = process.env.CROWDSEC_WATCHER_TOKEN ?? ''; +const serviceToken = process.env.CROWDSEC_MCP_HTTP_TOKEN; +const expectedIp = process.env.CROWDSEC_MCP_HTTP_E2E_IP ?? '203.0.113.66'; +const testIp = '198.51.100.42'; +const testIp2 = '198.51.100.44'; +const testIp3 = '198.51.100.45'; + +type Headers = Record; + +function textOf(result: Awaited>): string { + return result.content.map((item) => ('text' in item ? item.text : '')).join('\n'); +} + +async function post(url: string, headers: Headers): Promise { + return fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + ...headers + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { name: 'crowdsec-mcp-http-e2e', version: '0.0.0' } + } + }) + }); +} + +async function withClient(url: string, headers: Headers, callback: (client: Client) => Promise): Promise { + const transport = new StreamableHTTPClientTransport(new URL(url), { + requestInit: { headers } + }); + const client = new Client({ name: 'crowdsec-mcp-http-e2e', version: '0.0.0' }); + + await client.connect(transport); + try { + await callback(client); + } finally { + await client.close(); + } +} + +async function waitForAlert(client: Client, ip: string, timeoutMs = 90000): Promise { + const deadline = Date.now() + timeoutMs; + let last = ''; + + while (Date.now() < deadline) { + last = textOf(await client.callTool({ name: 'list_alerts', arguments: { ip, limit: 10 } })); + if (last.includes(ip)) { + return last; + } + await delay(2000); + } + + throw new Error(`no generated alert for ${ip} within ${timeoutMs}ms; last output:\n${last}`); +} + +async function waitForDecision(client: Client, ip: string, timeoutMs = 30000): Promise { + const deadline = Date.now() + timeoutMs; + let last = ''; + + while (Date.now() < deadline) { + last = textOf(await client.callTool({ name: 'list_decisions', arguments: { ip, limit: 10 } })); + if (last.includes(ip)) { + return last; + } + await delay(1000); + } + + throw new Error(`no decision for ${ip} within ${timeoutMs}ms; last output:\n${last}`); +} + +describe.skipIf(!enabled || !mcpUrl || !openMcpUrl || !bouncerKey || !watcherToken || !serviceToken)( + 'HTTP MCP passthrough end-to-end', + () => { + it('rejects missing LAPI credentials', async () => { + const response = await post(mcpUrl!, {}); + expect(response.status).toBe(401); + await response.text(); + }); + + it('requires the optional service token when configured', async () => { + const response = await post(mcpUrl!, { 'x-crowdsec-token': watcherToken }); + expect(response.status).toBe(401); + await response.text(); + }); + + it('rejects both bouncer and watcher credentials', async () => { + const response = await post(mcpUrl!, { + authorization: `Bearer ${serviceToken}`, + 'x-crowdsec-api-key': bouncerKey!, + 'x-crowdsec-token': watcherToken + }); + expect(response.status).toBe(400); + await response.text(); + }); + + it('exposes bouncer tools and reads decisions', async () => { + await withClient(mcpUrl!, { authorization: `Bearer ${serviceToken}`, 'x-crowdsec-api-key': bouncerKey! }, async (client) => { + const names = (await client.listTools()).tools.map((tool) => tool.name); + expect(names).toContain('crowdsec_status'); + expect(names).toContain('list_decisions'); + expect(names).not.toContain('list_alerts'); + expect(names).not.toContain('ban_ip'); + + const status = textOf(await client.callTool({ name: 'crowdsec_status', arguments: {} })); + expect(status).toContain('Authentication: bouncer'); + + await client.callTool({ name: 'list_decisions', arguments: { limit: 5 } }); + }); + }); + + it('exposes watcher tools and receives alerts generated by nginx logs', async () => { + await withClient(mcpUrl!, { authorization: `Bearer ${serviceToken}`, 'x-crowdsec-token': watcherToken }, async (client) => { + const names = (await client.listTools()).tools.map((tool) => tool.name); + expect(names).toContain('list_alerts'); + expect(names).toContain('get_alert'); + expect(names).toContain('list_allowlists'); + expect(names).toContain('ban_ip'); + expect(names).toContain('delete_alerts'); + + const alerts = await waitForAlert(client, expectedIp); + expect(alerts).toContain(expectedIp); + + const status = textOf(await client.callTool({ name: 'crowdsec_status', arguments: {} })); + expect(status).toContain('Authentication: watcher'); + }); + }, 120000); + + it('bans, deletes the alert and verifies local-only deletion', async () => { + await withClient(mcpUrl!, { authorization: `Bearer ${serviceToken}`, 'x-crowdsec-token': watcherToken }, async (client) => { + try { + const ban = textOf( + await client.callTool({ + name: 'ban_ip', + arguments: { ip: testIp, duration: '5m', reason: 'http e2e' } + }) + ); + expect(ban).toContain('Decision added successfully'); + expect(ban).toContain(testIp); + + const decisions = await waitForDecision(client, testIp); + expect(decisions).toContain(testIp); + expect(decisions).not.toContain(expectedIp); + + const filteredAlerts = textOf(await client.callTool({ name: 'list_alerts', arguments: { ip: testIp, limit: 10 } })); + expect(filteredAlerts).toContain(testIp); + expect(filteredAlerts).not.toContain(expectedIp); + + const deleted = textOf(await client.callTool({ name: 'delete_alerts', arguments: { ip: testIp } })); + expect(deleted).toMatch(/Deleted \d+ alert/); + + const alertsAfter = textOf(await client.callTool({ name: 'list_alerts', arguments: { ip: testIp, limit: 10 } })); + expect(alertsAfter).not.toContain(testIp); + + const decisionsAfter = textOf(await client.callTool({ name: 'list_decisions', arguments: { ip: testIp, limit: 10 } })); + expect(decisionsAfter).not.toContain(testIp); + } finally { + await client.callTool({ name: 'unban_ip', arguments: { ip: testIp } }).catch(() => undefined); + } + }); + }, 60000); + + it('filters decisions by CIDR and unbans a range without a timeout', async () => { + await withClient(mcpUrl!, { authorization: `Bearer ${serviceToken}`, 'x-crowdsec-token': watcherToken }, async (client) => { + try { + const ban2 = textOf( + await client.callTool({ + name: 'ban_ip', + arguments: { ip: testIp2, duration: '5m', reason: 'http e2e range' } + }) + ); + const ban3 = textOf( + await client.callTool({ + name: 'ban_ip', + arguments: { ip: testIp3, duration: '5m', reason: 'http e2e range' } + }) + ); + expect(ban2).toContain('Decision added successfully'); + expect(ban3).toContain('Decision added successfully'); + + await waitForDecision(client, testIp2); + await waitForDecision(client, testIp3); + + const cidr = '198.51.100.44/31'; + const byIp = textOf(await client.callTool({ name: 'list_decisions', arguments: { ip: cidr, limit: 10 } })); + expect(byIp).toContain(testIp2); + expect(byIp).toContain(testIp3); + expect(byIp).not.toContain(expectedIp); + + const byRange = textOf(await client.callTool({ name: 'list_decisions', arguments: { range: cidr, limit: 10 } })); + expect(byRange).toContain(testIp2); + expect(byRange).toContain(testIp3); + expect(byRange).not.toContain(expectedIp); + + const unban = textOf(await client.callTool({ name: 'unban_ip', arguments: { range: cidr } })); + expect(unban).toMatch(/2 decision\(s\) deleted/); + + const after = textOf(await client.callTool({ name: 'list_decisions', arguments: { ip: cidr, limit: 10 } })); + expect(after).not.toContain(testIp2); + expect(after).not.toContain(testIp3); + } finally { + await client.callTool({ name: 'delete_alerts', arguments: { ip: '198.51.100.44/31' } }).catch(() => undefined); + await Promise.all( + [testIp2, testIp3].map((ip) => client.callTool({ name: 'unban_ip', arguments: { ip } }).catch(() => undefined)) + ); + } + }); + }, 60000); + + it('works without a service token when passthrough credentials are provided', async () => { + const noAuth = await post(openMcpUrl!, {}); + expect(noAuth.status).toBe(401); + await noAuth.text(); + + await withClient(openMcpUrl!, { 'x-crowdsec-token': watcherToken }, async (client) => { + const names = (await client.listTools()).tools.map((tool) => tool.name); + expect(names).toContain('list_alerts'); + expect(names).toContain('ban_ip'); + expect(names).toContain('delete_alerts'); + + const status = textOf(await client.callTool({ name: 'crowdsec_status', arguments: {} })); + expect(status).toContain('Authentication: watcher'); + }); + }); + } +); diff --git a/packages/crowdsec-mcp/tests/e2e/stdio.test.ts b/packages/crowdsec-mcp/tests/e2e/stdio.test.ts new file mode 100644 index 000000000..3ab09ed33 --- /dev/null +++ b/packages/crowdsec-mcp/tests/e2e/stdio.test.ts @@ -0,0 +1,99 @@ +import http from 'node:http'; +import { execFileSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; + +const packageRoot = fileURLToPath(new URL('../..', import.meta.url)); +const entrypoint = fileURLToPath(new URL('../../dist/index.js', import.meta.url)); + +let api: http.Server; +let apiUrl: string; +let client: Client; + +function cleanEnv(overrides: Record): Record { + const env = Object.fromEntries(Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined)); + return { ...env, ...overrides }; +} + +describe('stdio end-to-end', () => { + beforeAll(async () => { + if (!existsSync(entrypoint)) { + execFileSync('npm', ['run', 'build'], { cwd: packageRoot, stdio: 'ignore' }); + } + + api = http.createServer((request, response) => { + if (request.method === 'HEAD' && request.url === '/v1/decisions') { + response.writeHead(200); + response.end(); + return; + } + + if (request.method === 'GET' && request.url?.startsWith('/v1/decisions')) { + response.setHeader('content-type', 'application/json'); + response.end('[]'); + return; + } + + response.writeHead(404); + response.end(); + }); + + await new Promise((resolve) => api.listen(0, '127.0.0.1', resolve)); + const address = api.address(); + if (!address || typeof address === 'string') { + throw new Error('failed to bind fake CrowdSec API'); + } + apiUrl = `http://127.0.0.1:${address.port}`; + + const transport = new StdioClientTransport({ + command: process.execPath, + args: [entrypoint], + env: cleanEnv({ + CROWDSEC_URL: apiUrl, + CROWDSEC_API_KEY: 'test-key', + CROWDSEC_MCP_READONLY: 'true' + }), + stderr: 'pipe' + }); + + client = new Client({ name: 'crowdsec-mcp-e2e', version: '0.0.0' }); + await client.connect(transport); + }, 60000); + + afterAll(async () => { + await client?.close(); + await new Promise((resolve, reject) => { + api.close((error) => (error ? reject(error) : resolve())); + }); + }); + + it('lists the read-only tools for a bouncer', async () => { + const result = await client.listTools(); + const names = result.tools.map((tool) => tool.name); + + expect(names).toContain('crowdsec_status'); + expect(names).toContain('list_decisions'); + expect(names).not.toContain('ban_ip'); + expect(names).not.toContain('list_alerts'); + expect(names).not.toContain('check_allowlist'); + }); + + it('calls crowdsec_status over stdio', async () => { + const result = await client.callTool({ name: 'crowdsec_status', arguments: {} }); + const text = result.content.map((item) => ('text' in item ? item.text : '')).join('\n'); + + expect(text).toContain('CrowdSec MCP is connected.'); + expect(text).toContain('Authentication: bouncer'); + expect(text).toContain('Read-only: yes'); + }); + + it('calls list_decisions over stdio', async () => { + const result = await client.callTool({ name: 'list_decisions', arguments: {} }); + const text = result.content.map((item) => ('text' in item ? item.text : '')).join('\n'); + + expect(text).toContain('0 decisions shown'); + }); +}); diff --git a/packages/crowdsec-mcp/tests/e2e/watcher.test.ts b/packages/crowdsec-mcp/tests/e2e/watcher.test.ts new file mode 100644 index 000000000..b5ecfbbf9 --- /dev/null +++ b/packages/crowdsec-mcp/tests/e2e/watcher.test.ts @@ -0,0 +1,154 @@ +import http from 'node:http'; +import { execFileSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; + +const packageRoot = fileURLToPath(new URL('../..', import.meta.url)); +const entrypoint = fileURLToPath(new URL('../../dist/index.js', import.meta.url)); + +let api: http.Server; +let client: Client; + +function cleanEnv(overrides: Record): Record { + const env = Object.fromEntries(Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined)); + return { ...env, ...overrides }; +} + +function textOf(result: Awaited>): string { + return result.content.map((item) => ('text' in item ? item.text : '')).join('\n'); +} + +describe('watcher stdio end-to-end', () => { + beforeAll(async () => { + if (!existsSync(entrypoint)) { + execFileSync('npm', ['run', 'build'], { cwd: packageRoot, stdio: 'ignore' }); + } + + api = http.createServer((request, response) => { + if (request.method === 'POST' && request.url === '/v1/watchers/login') { + response.setHeader('content-type', 'application/json'); + response.end( + JSON.stringify({ + token: 'test-token', + expire: new Date(Date.now() + 60 * 60 * 1000).toISOString() + }) + ); + return; + } + + if (request.headers.authorization !== 'Bearer test-token') { + response.writeHead(401); + response.end(); + return; + } + + if (request.method === 'HEAD' && request.url === '/v1/alerts') { + response.writeHead(200); + response.end(); + return; + } + + if (request.method === 'GET' && request.url?.startsWith('/v1/alerts')) { + response.setHeader('content-type', 'application/json'); + response.end('[]'); + return; + } + + if (request.method === 'GET' && request.url?.startsWith('/v1/allowlists/check/')) { + response.setHeader('content-type', 'application/json'); + response.end(JSON.stringify({ allowlisted: false })); + return; + } + + if (request.method === 'POST' && request.url === '/v1/allowlists/check') { + request.resume(); + response.setHeader('content-type', 'application/json'); + response.end(JSON.stringify({ results: [] })); + return; + } + + if (request.method === 'GET' && request.url?.startsWith('/v1/allowlists')) { + response.setHeader('content-type', 'application/json'); + response.end('[]'); + return; + } + + response.writeHead(404); + response.end(); + }); + + await new Promise((resolve) => api.listen(0, '127.0.0.1', resolve)); + const address = api.address(); + if (!address || typeof address === 'string') { + throw new Error('failed to bind fake CrowdSec API'); + } + + const transport = new StdioClientTransport({ + command: process.execPath, + args: [entrypoint], + env: cleanEnv({ + CROWDSEC_URL: `http://127.0.0.1:${address.port}`, + CROWDSEC_MACHINE_ID: 'machine', + CROWDSEC_PASSWORD: 'password', + CROWDSEC_MCP_READONLY: 'false' + }), + stderr: 'pipe' + }); + + client = new Client({ name: 'crowdsec-mcp-watcher-e2e', version: '0.0.0' }); + await client.connect(transport); + }, 60000); + + afterAll(async () => { + await client?.close(); + await new Promise((resolve, reject) => { + api.close((error) => (error ? reject(error) : resolve())); + }); + }); + + it('lists all watcher tools including writes and allowlists', async () => { + const result = await client.listTools(); + const names = result.tools.map((tool) => tool.name); + + expect(names).toContain('crowdsec_status'); + expect(names).toContain('get_decision'); + expect(names).toContain('list_alerts'); + expect(names).toContain('get_alert'); + expect(names).not.toContain('delete_alerts'); + expect(names).toContain('list_allowlists'); + expect(names).toContain('get_allowlist'); + expect(names).toContain('check_allowlist'); + expect(names).toContain('ban_ip'); + expect(names).toContain('unban_ip'); + }); + + it('authenticates as a watcher and sends the bearer token', async () => { + const result = await client.callTool({ name: 'crowdsec_status', arguments: {} }); + const text = textOf(result); + + expect(text).toContain('Authentication: watcher (machine)'); + expect(text).toContain('Capabilities: read=yes, write=yes, allowlists=yes'); + expect(text).toContain('Read-only: no'); + }); + + it('reads alerts through the watcher API', async () => { + const result = await client.callTool({ name: 'list_alerts', arguments: {} }); + + expect(textOf(result)).toContain('_No alerts found._'); + }); + + it('reads allowlists through the watcher API', async () => { + const result = await client.callTool({ name: 'list_allowlists', arguments: {} }); + + expect(textOf(result)).toContain('0 allowlists'); + }); + + it('checks an allowlist value', async () => { + const result = await client.callTool({ name: 'check_allowlist', arguments: { value: '203.0.113.10' } }); + + expect(textOf(result)).toContain('203.0.113.10: not allowlisted'); + }); +}); diff --git a/packages/crowdsec-mcp/tests/format.test.ts b/packages/crowdsec-mcp/tests/format.test.ts new file mode 100644 index 000000000..53541cdd2 --- /dev/null +++ b/packages/crowdsec-mcp/tests/format.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { escapeCell, expiry, formatDate, markdownTable, moreHint, plural, relativeTime, truncateRows } from '../src/format/common.js'; + +describe('format', () => { + it('escapes markdown table cells', () => { + expect(escapeCell('a|b\nc')).toBe('a\\|b c'); + expect(escapeCell('a\\b')).toBe('a\\\\b'); + expect(escapeCell(undefined)).toBe('-'); + }); + + it('renders a markdown table', () => { + const table = markdownTable(['Name', 'Count'], [['ban', '2']]); + + expect(table).toContain('| Name | Count |'); + expect(table).toContain('| ban | 2 |'); + }); + + it('renders an empty table', () => { + expect(markdownTable(['Name'], [])).toBe('_No results._'); + }); + + it('formats dates and relative times', () => { + const now = Date.parse('2026-01-01T12:00:00Z'); + + expect(formatDate('2026-01-01T12:00:00.000Z')).toBe('2026-01-01T12:00:00Z'); + expect(relativeTime('2026-01-01T13:30:00Z', now)).toBe('in 1h 30m'); + expect(relativeTime('2026-01-01T10:30:00Z', now)).toBe('1h 30m ago'); + expect(relativeTime(undefined)).toBe('-'); + }); + + it('formats expirations', () => { + const now = Date.parse('2026-01-01T12:00:00Z'); + + expect(expiry(undefined)).toBe('never'); + expect(expiry('0001-01-01T00:00:00Z', now)).toBe('never'); + expect(expiry('2026-01-01T11:00:00Z', now)).toBe('expired'); + expect(expiry('2026-01-01T13:00:00Z', now)).toBe('in 1h'); + }); + + it('truncates rows', () => { + expect(truncateRows([1, 2, 3], 2)).toEqual({ rows: [1, 2], hidden: 1 }); + expect(truncateRows([1], 2)).toEqual({ rows: [1], hidden: 0 }); + expect(moreHint(3, 'list_alerts')).toContain('3 more'); + expect(moreHint(0, 'list_alerts')).toBe(''); + }); + + it('pluralizes counts', () => { + expect(plural(1, 'decision')).toBe('1 decision'); + expect(plural(2, 'decision')).toBe('2 decisions'); + }); +}); diff --git a/packages/crowdsec-mcp/tests/helpers.ts b/packages/crowdsec-mcp/tests/helpers.ts new file mode 100644 index 000000000..8f0940b46 --- /dev/null +++ b/packages/crowdsec-mcp/tests/helpers.ts @@ -0,0 +1,60 @@ +import type { Config } from '../src/config.js'; +import type { AppContext } from '../src/types.js'; + +export function makeConfig(overrides: Partial = {}): Config { + return { + url: 'http://127.0.0.1:8080', + auth: 'watcher', + machineId: 'machine', + password: 'password', + strictSSL: true, + timeout: 10000, + transport: 'stdio', + http: { + host: '127.0.0.1', + port: 3939, + token: 'token' + }, + readonly: true, + enableAlertDelete: false, + allowAlertDeleteWithoutToken: false, + passthrough: false, + disableTools: [], + maxResults: 25, + maxBanDuration: '24h', + maxBanDurationSeconds: 86400, + ...overrides + }; +} + +export function makeContext(overrides: Partial = {}): AppContext { + let readyCalls = 0; + const context: AppContext = { + mode: 'watcher', + capabilities: { + read: true, + write: true, + allowlists: true + }, + allowlists: { + list: () => Promise.resolve([]), + get: () => Promise.resolve({}), + check: () => Promise.resolve({ allowlisted: false }), + bulkCheck: () => Promise.resolve({ results: [] }) + }, + config: makeConfig(), + ensureReady: () => { + readyCalls++; + return Promise.resolve(); + }, + stop: () => Promise.resolve(), + ...overrides + }; + + Object.defineProperty(context, 'readyCalls', { + get: () => readyCalls, + enumerable: false + }); + + return context; +} diff --git a/packages/crowdsec-mcp/tests/integration/live.test.ts b/packages/crowdsec-mcp/tests/integration/live.test.ts new file mode 100644 index 000000000..016db0a98 --- /dev/null +++ b/packages/crowdsec-mcp/tests/integration/live.test.ts @@ -0,0 +1,191 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; + +const enabled = process.env.CROWDSEC_INTEGRATION === '1'; +const url = process.env.CROWDSEC_URL; +const apiKey = process.env.CROWDSEC_API_KEY; +const machineId = process.env.CROWDSEC_MACHINE_ID; +const password = process.env.CROWDSEC_PASSWORD; +const writeEnabled = process.env.CROWDSEC_INTEGRATION_WRITE === '1'; + +const expectedDecision = process.env.CROWDSEC_INTEGRATION_DECISION ?? '203.0.113.10'; +const allowlistName = process.env.CROWDSEC_INTEGRATION_ALLOWLIST ?? 'test-allowlist'; +const allowlistIp = process.env.CROWDSEC_INTEGRATION_ALLOWLIST_IP ?? '192.0.2.10'; + +const packageRoot = fileURLToPath(new URL('../..', import.meta.url)); +const entrypoint = fileURLToPath(new URL('../../dist/index.js', import.meta.url)); + +function cleanEnv(overrides: Record): Record { + const env = Object.fromEntries(Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined)); + + return { + ...env, + CROWDSEC_URL: url!, + CROWDSEC_MCP_TRANSPORT: 'stdio', + CROWDSEC_MCP_TOOLS: '', + CROWDSEC_MCP_DISABLE_TOOLS: '', + CROWDSEC_TLS_CERT: '', + CROWDSEC_TLS_KEY: '', + CROWDSEC_TLS_CA: '', + ...overrides + }; +} + +async function withClient(overrides: Record, callback: (client: Client) => Promise): Promise { + const transport = new StdioClientTransport({ + command: process.execPath, + args: [entrypoint], + env: cleanEnv(overrides), + stderr: 'pipe' + }); + + const client = new Client({ name: 'crowdsec-mcp-integration', version: '0.0.0' }); + await client.connect(transport); + + try { + await callback(client); + } finally { + await client.close(); + } +} + +function textOf(result: Awaited>): string { + return result.content.map((item) => ('text' in item ? item.text : '')).join('\n'); +} + +function alertIdFor(text: string, value: string): string { + const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = text.match(new RegExp(`\\|\\s*(\\d+)\\s*\\|\\s*(?:Ip|Range)\\s*\\|\\s*${escaped}\\s*\\|`)); + if (!match) { + throw new Error(`could not find alert id for ${value} in:\n${text}`); + } + + return match[1]; +} + +describe.skipIf(!enabled || !url)('live CrowdSec LAPI integration', () => { + it.skipIf(!apiKey)( + 'bouncer reads active decisions', + async () => { + await withClient( + { + CROWDSEC_AUTH: 'bouncer', + CROWDSEC_API_KEY: apiKey!, + CROWDSEC_MCP_READONLY: 'true' + }, + async (client) => { + const tools = await client.listTools(); + const names = tools.tools.map((tool) => tool.name); + + expect(names).toContain('crowdsec_status'); + expect(names).toContain('list_decisions'); + expect(names).not.toContain('list_alerts'); + expect(names).not.toContain('ban_ip'); + + const status = textOf(await client.callTool({ name: 'crowdsec_status', arguments: {} })); + expect(status).toContain('Authentication: bouncer'); + + const decisions = textOf(await client.callTool({ name: 'list_decisions', arguments: {} })); + expect(decisions).toContain(expectedDecision); + } + ); + }, + 30000 + ); + + it.skipIf(!machineId || !password)( + 'watcher reads decisions, alerts and allowlists', + async () => { + await withClient( + { + CROWDSEC_AUTH: 'watcher', + CROWDSEC_MACHINE_ID: machineId!, + CROWDSEC_PASSWORD: password!, + CROWDSEC_MCP_READONLY: 'true' + }, + async (client) => { + const tools = await client.listTools(); + const names = tools.tools.map((tool) => tool.name); + + expect(names).toContain('list_alerts'); + expect(names).toContain('list_allowlists'); + expect(names).toContain('check_allowlist'); + expect(names).not.toContain('ban_ip'); + + const status = textOf(await client.callTool({ name: 'crowdsec_status', arguments: {} })); + expect(status).toContain('Authentication: watcher'); + + const decisions = textOf(await client.callTool({ name: 'list_decisions', arguments: {} })); + expect(decisions).toContain(expectedDecision); + + const alerts = textOf(await client.callTool({ name: 'list_alerts', arguments: {} })); + expect(alerts).toContain(expectedDecision); + + const alertId = alertIdFor(alerts, expectedDecision); + const alert = textOf(await client.callTool({ name: 'get_alert', arguments: { id: alertId } })); + expect(alert).toContain(expectedDecision); + expect(alert).toContain('Decisions'); + + const allowlists = textOf(await client.callTool({ name: 'list_allowlists', arguments: {} })); + expect(allowlists).toContain(allowlistName); + + const allowlist = textOf(await client.callTool({ name: 'get_allowlist', arguments: { name: allowlistName } })); + expect(allowlist).toContain(allowlistIp); + + const check = textOf(await client.callTool({ name: 'check_allowlist', arguments: { value: allowlistIp } })); + expect(check).toContain(`${allowlistIp}: allowlisted`); + } + ); + }, + 30000 + ); + + it.skipIf(!writeEnabled || !machineId || !password)( + 'watcher writes: allowlist guard, ban, unban and delete alert', + async () => { + const testIp = '198.51.100.10'; + + await withClient( + { + CROWDSEC_AUTH: 'watcher', + CROWDSEC_MACHINE_ID: machineId!, + CROWDSEC_PASSWORD: password!, + CROWDSEC_MCP_READONLY: 'false', + CROWDSEC_MCP_MAX_BAN_DURATION: '1h' + }, + async (client) => { + const blocked = await client.callTool({ name: 'ban_ip', arguments: { ip: allowlistIp } }); + expect(blocked.isError).toBe(true); + expect(textOf(blocked)).toContain('allowlisted'); + + const ban = textOf( + await client.callTool({ + name: 'ban_ip', + arguments: { ip: testIp, duration: '5m', reason: 'mcp integration write' } + }) + ); + expect(ban).toContain('Decision added successfully'); + expect(ban).toContain(testIp); + + try { + const decisions = textOf(await client.callTool({ name: 'list_decisions', arguments: {} })); + expect(decisions).toContain(testIp); + + const unban = textOf(await client.callTool({ name: 'unban_ip', arguments: { ip: testIp } })); + expect(unban).toMatch(/decision\(s\) deleted/); + + const afterUnban = textOf(await client.callTool({ name: 'list_decisions', arguments: {} })); + expect(afterUnban).not.toContain(testIp); + } finally { + await client.callTool({ name: 'unban_ip', arguments: { ip: testIp } }).catch(() => undefined); + } + } + ); + }, + 45000 + ); +}); diff --git a/packages/crowdsec-mcp/tests/registry-watcher.test.ts b/packages/crowdsec-mcp/tests/registry-watcher.test.ts new file mode 100644 index 000000000..724bbc2d4 --- /dev/null +++ b/packages/crowdsec-mcp/tests/registry-watcher.test.ts @@ -0,0 +1,112 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { createServer, selectTools } from '../src/registry.js'; +import { allTools } from '../src/tools/index.js'; +import { makeConfig, makeContext } from './helpers.js'; + +let client: Client | undefined; +let server: ReturnType | undefined; + +afterEach(async () => { + await client?.close(); + await server?.close(); + client = undefined; + server = undefined; +}); + +describe('watcher registry', () => { + it('registers every read and write tool and exposes valid input schemas', async () => { + const context = makeContext({ config: makeConfig({ readonly: false }) }); + const tools = selectTools(allTools(), context); + + expect(tools.map((tool) => tool.name)).toEqual([ + 'crowdsec_status', + 'list_decisions', + 'get_decision', + 'ban_ip', + 'unban_ip', + 'list_alerts', + 'get_alert', + 'list_allowlists', + 'get_allowlist', + 'check_allowlist' + ]); + + server = createServer(tools, context); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + + client = new Client({ name: 'registry-test', version: '0.0.0' }); + await client.connect(clientTransport); + + const listed = await client.listTools(); + expect(listed.tools.map((tool) => tool.name)).toEqual(tools.map((tool) => tool.name)); + expect(listed.tools.find((tool) => tool.name === 'check_allowlist')?.inputSchema).toBeTruthy(); + + const byName = new Map(listed.tools.map((tool) => [tool.name, tool])); + const unbanSchema = byName.get('unban_ip')?.inputSchema as { properties?: Record }; + const decisionsSchema = byName.get('list_decisions')?.inputSchema as { properties?: Record }; + + expect(unbanSchema?.properties?.id).toBeDefined(); + expect(decisionsSchema?.properties?.no_simulated).toBeDefined(); + }); + + it('hides delete_alerts in passthrough without a service token unless forced', () => { + const base = makeConfig({ + readonly: false, + enableAlertDelete: true, + transport: 'http', + passthrough: true, + http: { host: '127.0.0.1', port: 0, token: undefined } + }); + + const hidden = selectTools(allTools(), makeContext({ config: base })); + expect(hidden.map((tool) => tool.name)).not.toContain('delete_alerts'); + + const forced = selectTools( + allTools(), + makeContext({ + config: { ...base, allowAlertDeleteWithoutToken: true } + }) + ); + expect(forced.map((tool) => tool.name)).toContain('delete_alerts'); + + const withServiceToken = selectTools( + allTools(), + makeContext({ + config: { ...base, http: { ...base.http, token: 'service-token' } } + }) + ); + expect(withServiceToken.map((tool) => tool.name)).toContain('delete_alerts'); + }); + + it('hides delete_alerts over HTTP without a service token unless explicitly allowed', () => { + const tokenlessHttp = makeConfig({ + readonly: false, + enableAlertDelete: true, + transport: 'http', + http: { host: '127.0.0.1', port: 0, token: undefined } + }); + + const hidden = selectTools(allTools(), makeContext({ config: tokenlessHttp })); + expect(hidden.map((tool) => tool.name)).not.toContain('delete_alerts'); + + const forced = selectTools(allTools(), makeContext({ config: { ...tokenlessHttp, allowAlertDeleteWithoutToken: true } })); + expect(forced.map((tool) => tool.name)).toContain('delete_alerts'); + + const withToken = selectTools( + allTools(), + makeContext({ config: { ...tokenlessHttp, http: { ...tokenlessHttp.http, token: 'service-token' } } }) + ); + expect(withToken.map((tool) => tool.name)).toContain('delete_alerts'); + }); + + it('exposes delete_alerts only when explicitly enabled', () => { + const disabled = selectTools(allTools(), makeContext({ config: makeConfig({ readonly: false }) })); + const enabled = selectTools(allTools(), makeContext({ config: makeConfig({ readonly: false, enableAlertDelete: true }) })); + + expect(disabled.map((tool) => tool.name)).not.toContain('delete_alerts'); + expect(enabled.map((tool) => tool.name)).toContain('delete_alerts'); + }); +}); diff --git a/packages/crowdsec-mcp/tests/registry.test.ts b/packages/crowdsec-mcp/tests/registry.test.ts new file mode 100644 index 000000000..0811d642c --- /dev/null +++ b/packages/crowdsec-mcp/tests/registry.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { cleanErrorMessage, errorResult, selectTools } from '../src/registry.js'; +import type { ToolDefinition } from '../src/types.js'; +import { makeConfig, makeContext } from './helpers.js'; + +function readTool(name: string): ToolDefinition { + return { + name, + title: name, + description: name, + inputSchema: z.object({}), + kind: 'read', + handler: async () => 'ok' + }; +} + +function writeTool(name: string): ToolDefinition { + return { + ...readTool(name), + kind: 'write' + }; +} + +const tools = [ + readTool('list_decisions'), + readTool('list_alerts'), + writeTool('ban_ip'), + { ...readTool('check_allowlist'), requires: ['watcher', 'allowlists'] as const } +]; + +describe('registry', () => { + it('hides write tools in read-only mode', () => { + const selected = selectTools(tools, makeContext()); + + expect(selected.map((tool) => tool.name)).toEqual(['list_decisions', 'list_alerts', 'check_allowlist']); + }); + + it('includes write tools when read-only is disabled', () => { + const selected = selectTools(tools, makeContext({ config: makeConfig({ readonly: false }) })); + + expect(selected.map((tool) => tool.name)).toContain('ban_ip'); + }); + + it('hides watcher-only tools in bouncer mode', () => { + const selected = selectTools(tools, makeContext({ mode: 'bouncer' })); + + expect(selected.map((tool) => tool.name)).not.toContain('check_allowlist'); + }); + + it('hides allowlist tools when the capability is unavailable', () => { + const selected = selectTools( + tools, + makeContext({ + capabilities: { read: true, write: true, allowlists: false } + }) + ); + + expect(selected.map((tool) => tool.name)).not.toContain('check_allowlist'); + }); + + it('supports an allowlist of tools', () => { + const selected = selectTools(tools, makeContext({ config: makeConfig({ tools: ['list_alerts'] }) })); + + expect(selected.map((tool) => tool.name)).toEqual(['list_alerts']); + }); + + it('supports a denylist of tools', () => { + const selected = selectTools(tools, makeContext({ config: makeConfig({ disableTools: ['list_alerts'] }) })); + + expect(selected.map((tool) => tool.name)).toEqual(['list_decisions', 'check_allowlist']); + }); + + it('rejects unknown tool names', () => { + expect(() => selectTools(tools, makeContext({ config: makeConfig({ tools: ['does_not_exist'] }) }))).toThrow(/unknown tool/); + }); +}); + +describe('errorResult', () => { + it('keeps only the first line to avoid leaking stack traces', () => { + const result = errorResult(new Error('boom\n at /app/packages/internal.ts:1:1')); + + expect(result.isError).toBe(true); + expect(result.content[0]).toMatchObject({ type: 'text', text: 'Error: boom' }); + }); +}); + +describe('cleanErrorMessage', () => { + it('maps CrowdSec internals to business messages', () => { + expect(cleanErrorMessage(new Error('CrowdSecError (404) : object not found'))).toBe('Resource not found'); + expect(cleanErrorMessage(new Error("CrowdSecError (500) : allowlist 'nope' not found"))).toBe("Allowlist 'nope' not found"); + expect(cleanErrorMessage(new Error("CrowdSecError (500) : decision with id '999999' doesn't exist: unable to delete"))).toBe( + 'Decision 999999 not found' + ); + expect(cleanErrorMessage(new Error('CrowdSecError (500) : ent: alert not found'))).toBe('Alert not found'); + expect(cleanErrorMessage(new Error('CrowdSecError (500) : while parsing duration: nope'))).toBe('Invalid duration'); + expect(cleanErrorMessage(new Error('CrowdSecError (403) : access forbidden from this IP (1.2.3.4)'))).toBe( + 'Access forbidden from this IP (alert deletion is only allowed from the LAPI host)' + ); + }); +}); diff --git a/packages/crowdsec-mcp/tests/system.test.ts b/packages/crowdsec-mcp/tests/system.test.ts new file mode 100644 index 000000000..7ebbbb6a6 --- /dev/null +++ b/packages/crowdsec-mcp/tests/system.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { statusTool } from '../src/tools/system.js'; +import { makeConfig, makeContext } from './helpers.js'; + +describe('crowdsec_status', () => { + it('renders a human-readable status', async () => { + const context = makeContext(); + context.activeTools = ['crowdsec_status']; + + const result = await statusTool.handler({}, context); + + expect(result).toContain('CrowdSec MCP is connected.'); + expect(result).toContain('Authentication: watcher (machine)'); + expect(result).toContain('Active tools: crowdsec_status'); + }); + + it('marks write as disabled when read-only', async () => { + const context = makeContext(); + + const result = await statusTool.handler({}, context); + + expect(result).toContain('write=yes (disabled by read-only)'); + }); + + it('shows write=yes when read-only is disabled', async () => { + const context = makeContext({ config: makeConfig({ readonly: false }) }); + + const result = await statusTool.handler({}, context); + + expect(result).toContain('write=yes,'); + }); +}); diff --git a/packages/crowdsec-mcp/tests/tools/alerts.test.ts b/packages/crowdsec-mcp/tests/tools/alerts.test.ts new file mode 100644 index 000000000..2b4903a62 --- /dev/null +++ b/packages/crowdsec-mcp/tests/tools/alerts.test.ts @@ -0,0 +1,682 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { alertTools } from '../../src/tools/alerts.js'; + +const CREATED_AT = new Date(Date.now() - 60 * 60 * 1000).toISOString(); +const UNTIL = new Date(Date.now() + 60 * 60 * 1000).toISOString(); + +function createMockWatcher(alerts: any[], detailAlert?: any) { + return { + Alerts: { + search: vi.fn().mockResolvedValue(alerts), + getById: vi.fn().mockResolvedValue(detailAlert ?? alerts[0]), + delete: vi.fn().mockResolvedValue({ nbDeleted: '5' }), + deleteById: vi.fn().mockResolvedValue({ nbDeleted: '1' }) + } + }; +} + +function createMockContext(watcher: any, maxResults = 25) { + return { + watcher, + config: { maxResults, readonly: false, maxBanDuration: '24h', maxBanDurationSeconds: 86400 }, + mode: 'watcher' as const, + capabilities: { read: true, write: true, allowlists: true }, + ensureReady: vi.fn().mockResolvedValue(undefined), + stop: vi.fn().mockResolvedValue(undefined), + activeTools: [], + allowlists: {} + }; +} + +describe('alertTools', () => { + let mockWatcher: ReturnType; + let mockCtx: ReturnType; + + beforeEach(() => { + mockWatcher = createMockWatcher([ + { + id: 1, + created_at: CREATED_AT, + machine_id: 'machine-1', + simulated: false, + remediation: true, + kind: 'crowdsec', + message: 'CrowdSecurity/ssh-bf', + events_count: 10, + start_at: '2024-01-15T10:00:00Z', + stop_at: '2024-01-15T10:30:00Z', + uuid: 'alert-uuid-1', + source: { + scope: 'Ip', + value: '192.168.1.100', + cn: 'US', + as_number: 'AS12345', + as_name: 'Example ISP' + }, + decisions: [ + { + type: 'ban', + scope: 'Ip', + value: '192.168.1.100', + origin: 'crowdsec', + duration: '4h', + until: UNTIL, + simulated: false + }, + { + type: 'captcha', + scope: 'Ip', + value: '192.168.1.100', + origin: 'crowdsec', + duration: '1h', + until: UNTIL, + simulated: true + } + ], + meta: [ + { key: 'source_ip', value: '192.168.1.100' }, + { key: 'usernames', value: '["admin","root"]' } + ], + events: [ + { timestamp: '2024-01-15T10:00:00Z', meta: { source_ip: '192.168.1.100', http_path: '/login' } }, + { timestamp: '2024-01-15T10:05:00Z', meta: { source_ip: '192.168.1.100', http_path: '/login' } } + ] + } + ]); + mockCtx = createMockContext(mockWatcher, 25); + }); + + describe('list_alerts', () => { + it('should call search with correct filters', async () => { + const tool = alertTools.find((t) => t.name === 'list_alerts')!; + await tool.handler( + { + ip: '192.168.1.100', + scenario: 'crowdsecurity/ssh-bf', + type: 'ban', + has_active_decision: true, + limit: 10 + }, + mockCtx + ); + + expect(mockWatcher.Alerts.search).toHaveBeenCalledWith( + expect.objectContaining({ + ip: '192.168.1.100', + scenario: 'crowdsecurity/ssh-bf', + decision_type: 'ban', + has_active_decision: true, + limit: 0 + }) + ); + }); + + it('should report exact hidden counts for an explicit limit', async () => { + const manyAlerts = Array.from({ length: 40 }, (_, i) => ({ + id: i + 1, + created_at: CREATED_AT, + source: { scope: 'Ip', value: `198.51.100.${i + 1}` }, + decisions: [] + })); + const watcher = createMockWatcher(manyAlerts); + const ctx = createMockContext(watcher, 25); + const tool = alertTools.find((t) => t.name === 'list_alerts')!; + + const limit40 = await tool.handler({ limit: 40 }, ctx); + expect(limit40).toContain('40 alerts:'); + expect(limit40).not.toContain('more result(s) hidden'); + + const limit26 = await tool.handler({ limit: 26 }, ctx); + expect(limit26).toContain('26 alerts:'); + expect(limit26).toContain('14 more result(s) hidden'); + + const limit25 = await tool.handler({ limit: 25 }, ctx); + expect(limit25).toContain('25 alerts:'); + expect(limit25).toContain('15 more result(s) hidden'); + + const limit1000 = await tool.handler({ limit: 1000 }, ctx); + expect(limit1000).toContain('40 alerts:'); + expect(limit1000).not.toContain('more result(s) hidden'); + + expect(watcher.Alerts.search).toHaveBeenCalledWith(expect.objectContaining({ limit: 0 })); + }); + + it('should use config.maxResults as default limit', async () => { + const tool = alertTools.find((t) => t.name === 'list_alerts')!; + await tool.handler({}, mockCtx); + + expect(mockWatcher.Alerts.search).toHaveBeenCalledWith( + expect.objectContaining({ + limit: 25 + }) + ); + }); + + it('should hint that more results may exist when the default limit is reached', async () => { + const manyAlerts = Array.from({ length: 25 }, (_, i) => ({ + id: i + 1, + created_at: CREATED_AT, + source: { scope: 'Ip', value: `198.51.100.${i + 1}` }, + decisions: [] + })); + const watcher = createMockWatcher(manyAlerts); + const ctx = createMockContext(watcher, 25); + const tool = alertTools.find((t) => t.name === 'list_alerts')!; + + const result = await tool.handler({}, ctx); + + expect(result).toContain('25 alerts:'); + expect(result).toContain('More results may be available'); + }); + + it('should use limit=0 when all=true', async () => { + const tool = alertTools.find((t) => t.name === 'list_alerts')!; + await tool.handler({ all: true }, mockCtx); + + expect(mockWatcher.Alerts.search).toHaveBeenCalledWith( + expect.objectContaining({ + limit: 0 + }) + ); + }); + + it('should filter by kind in the LAPI query', async () => { + mockWatcher.Alerts.search.mockResolvedValue([ + { id: 1, created_at: CREATED_AT, kind: 'waf', source: { scope: 'Ip', value: '1.1.1.1' }, decisions: [] } + ]); + const tool = alertTools.find((t) => t.name === 'list_alerts')!; + const result = await tool.handler({ kind: 'waf' }, mockCtx); + + expect(mockWatcher.Alerts.search).toHaveBeenCalledWith(expect.objectContaining({ kind: 'waf' })); + expect(result).toContain('1.1.1.1'); + }); + + it('should format output as markdown table with summary', async () => { + const tool = alertTools.find((t) => t.name === 'list_alerts')!; + const result = await tool.handler({}, mockCtx); + + expect(result).toContain('1 alert:'); + const headerLine = result.split('\n').find((line) => line.includes('ID') && line.includes('Scope')); + expect(headerLine).toBeDefined(); + for (const col of ['ID', 'Scope', 'Value', 'Reason', 'Country', 'AS', 'Decisions', 'Created at', 'Kind']) { + expect(headerLine).toContain(col); + } + expect(result).toContain('192.168.1.100'); + expect(result).toContain('ban:1 (simul)captcha:1'); + }); + + it('should not count expired decisions in the summary', async () => { + mockWatcher.Alerts.search.mockResolvedValue([ + { + id: 99, + created_at: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), + decisions: [ + { + type: 'ban', + scope: 'Ip', + value: '1.2.3.4', + origin: 'cscli', + duration: '-20s', + simulated: false + } + ], + source: { scope: 'Ip', value: '1.2.3.4' }, + kind: 'crowdsec' + } + ]); + + const tool = alertTools.find((t) => t.name === 'list_alerts')!; + const result = await tool.handler({}, mockCtx); + + expect(result).not.toMatch(/ban:\d/); + }); + + it('should map a missing alert to a business error', async () => { + mockWatcher.Alerts.getById.mockRejectedValue(new Error('CrowdSecError (404) : object not found')); + const getAlert = alertTools.find((t) => t.name === 'get_alert')!; + + await expect(getAlert.handler({ id: 999999 }, mockCtx)).rejects.toThrow('Alert 999999 not found'); + }); + + it('should reject invalid alert ids and invalid time filters', async () => { + const tool = alertTools.find((t) => t.name === 'list_alerts')!; + const getAlert = alertTools.find((t) => t.name === 'get_alert')!; + const deleteAlerts = alertTools.find((t) => t.name === 'delete_alerts')!; + + await expect(tool.handler({ since: 'banana' }, mockCtx)).rejects.toThrow(/invalid since/); + await expect(tool.handler({ since: '10y' }, mockCtx)).rejects.toThrow(/use h, m, s or d/); + await expect(tool.handler({ since: '2026-01-01T00:00:00Z' }, mockCtx)).rejects.toThrow(/use h, m, s or d/); + await expect(tool.handler({ scope: 'Country', value: 'ZZ' }, mockCtx)).rejects.toThrow(/invalid country code/); + await expect(getAlert.handler({ id: 'abc' }, mockCtx)).rejects.toThrow(/alert id must be a positive integer/); + await expect(deleteAlerts.handler({ all: true, confirm: true, ip: '1.2.3.4' }, mockCtx)).rejects.toThrow( + /Cannot combine all=true/ + ); + }); + + it('should use CIDR containment for list_alerts', async () => { + mockWatcher.Alerts.search.mockResolvedValue([]); + const tool = alertTools.find((t) => t.name === 'list_alerts')!; + + await tool.handler({ ip: '198.51.100.0/24' }, mockCtx); + + expect(mockWatcher.Alerts.search).toHaveBeenCalledWith( + expect.objectContaining({ + ip: '198.51.100.0/24', + contains: false + }) + ); + }); + + it('should normalize scope and value before querying the LAPI', async () => { + mockWatcher.Alerts.search.mockResolvedValue([]); + const tool = alertTools.find((t) => t.name === 'list_alerts')!; + + await tool.handler({ scope: 'as', value: 'AS64512' }, mockCtx); + + expect(mockWatcher.Alerts.search).toHaveBeenCalledWith( + expect.objectContaining({ + scope: 'AS', + value: '64512' + }) + ); + }); + + it('should map a missing alert deletion to a business error', async () => { + mockWatcher.Alerts.deleteById.mockRejectedValue(new Error('CrowdSecError (500) : ent: alert not found')); + const deleteAlerts = alertTools.find((t) => t.name === 'delete_alerts')!; + + await expect(deleteAlerts.handler({ id: 999999 }, mockCtx)).rejects.toThrow('Alert 999999 not found'); + }); + + it('should show moreHint when results exceed maxResults', async () => { + const manyAlerts = Array.from({ length: 30 }, (_, i) => ({ + id: i + 1, + created_at: CREATED_AT, + machine_id: 'machine-1', + simulated: false, + remediation: true, + kind: 'crowdsec', + message: `Scenario ${i}`, + events_count: 10, + start_at: '2024-01-15T10:00:00Z', + stop_at: '2024-01-15T10:30:00Z', + uuid: `alert-uuid-${i}`, + source: { scope: 'Ip', value: `192.168.1.${i}`, cn: 'US', as_number: 'AS12345', as_name: 'Example ISP' }, + decisions: [] + })); + + const watcher = createMockWatcher(manyAlerts); + const ctx = createMockContext(watcher, 25); + const tool = alertTools.find((t) => t.name === 'list_alerts')!; + const result = await tool.handler({}, ctx); + + expect(result).toContain('5 more result(s) hidden'); + }); + + it('should show all alerts when all=true', async () => { + const manyAlerts = Array.from({ length: 30 }, (_, i) => ({ + id: i + 1, + created_at: CREATED_AT, + source: { scope: 'Ip', value: `192.168.1.${i}` }, + decisions: [] + })); + + const watcher = createMockWatcher(manyAlerts); + const ctx = createMockContext(watcher, 25); + const tool = alertTools.find((t) => t.name === 'list_alerts')!; + const result = await tool.handler({ all: true }, ctx); + + expect(result).toContain('30 alerts:'); + expect(result).not.toContain('more result(s) hidden'); + }); + }); + + describe('get_alert', () => { + it('should call getById with string ID', async () => { + const tool = alertTools.find((t) => t.name === 'get_alert')!; + await tool.handler({ id: '42' }, mockCtx); + + expect(mockWatcher.Alerts.getById).toHaveBeenCalledWith('42'); + }); + + it('should call getById with numeric ID converted to string', async () => { + const tool = alertTools.find((t) => t.name === 'get_alert')!; + await tool.handler({ id: 42 }, mockCtx); + + expect(mockWatcher.Alerts.getById).toHaveBeenCalledWith('42'); + }); + + it('should format alert detail with all sections', async () => { + const tool = alertTools.find((t) => t.name === 'get_alert')!; + const result = await tool.handler({ id: '1' }, mockCtx); + + expect(result).toContain('### Alert #1'); + expect(result).toContain('Date:'); + expect(result).toContain('Machine:'); + expect(result).toContain('Simulation:'); + expect(result).toContain('Remediation:'); + expect(result).toContain('Kind:'); + expect(result).toContain('Reason:'); + expect(result).toContain('Events count:'); + expect(result).toContain('Scope:Value:'); + expect(result).toContain('Country:'); + expect(result).toContain('AS:'); + expect(result).toContain('Begin:'); + expect(result).toContain('End:'); + expect(result).toContain('UUID:'); + + // Decisions table + expect(result).toContain('### Decisions'); + const decisionHeader = result.split('\n').find((line) => line.includes('Type') && line.includes('Simulated')); + expect(decisionHeader).toBeDefined(); + for (const col of ['Type', 'Scope', 'Value', 'Origin', 'Remaining', 'Until', 'Simulated']) { + expect(decisionHeader).toContain(col); + } + expect(result).toContain('ban'); + expect(result).toContain('captcha'); + expect(result).toContain('yes'); + expect(result).toContain('no'); + + // Meta table + expect(result).toContain('### Meta'); + expect(result).toContain('source_ip'); + expect(result).toContain('usernames'); + expect(result).toContain('admin'); + expect(result).toContain('root'); + + // Events table + expect(result).toContain('### Events'); + expect(result).toContain('2 event'); + expect(result).toContain('http_path'); + }); + + it('should handle alert without decisions', async () => { + const watcher = createMockWatcher([ + { + id: 2, + created_at: CREATED_AT, + machine_id: 'machine-1', + simulated: false, + remediation: false, + kind: 'crowdsec', + message: 'Test', + events_count: 0, + start_at: '2024-01-15T10:00:00Z', + stop_at: '2024-01-15T10:30:00Z', + uuid: 'alert-uuid-2', + source: { scope: 'Ip', value: '10.0.0.1', cn: 'FR', as_number: 'AS999', as_name: 'Test' }, + decisions: [], + meta: [], + events: [] + } + ]); + const ctx = createMockContext(watcher); + const tool = alertTools.find((t) => t.name === 'get_alert')!; + const result = await tool.handler({ id: '2' }, ctx); + + expect(result).toContain('### Alert #2'); + expect(result).not.toContain('### Decisions'); + }); + + it('should mark expired decisions explicitly', async () => { + const watcher = createMockWatcher([ + { + id: 4, + created_at: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), + machine_id: 'machine-1', + simulated: false, + remediation: true, + kind: 'crowdsec', + message: 'Test', + events_count: 1, + start_at: '2024-01-15T10:00:00Z', + stop_at: '2024-01-15T10:30:00Z', + uuid: 'alert-uuid-4', + source: { scope: 'Ip', value: '1.2.3.4' }, + decisions: [ + { + id: 1, + type: 'ban', + scope: 'Ip', + value: '1.2.3.4', + origin: 'cscli', + duration: '-1m25s', + simulated: false + } + ], + meta: [], + events: [] + } + ]); + const ctx = createMockContext(watcher); + const tool = alertTools.find((t) => t.name === 'get_alert')!; + + const result = await tool.handler({ id: '4' }, ctx); + + expect(result).toContain('_No active decisions._'); + expect(result).toContain('1 inactive decision(s) hidden.'); + expect(result).toContain('Active decisions:** 0'); + expect(result).not.toContain('-1m25s'); + }); + + it('should handle alert without meta', async () => { + const watcher = createMockWatcher([ + { + id: 3, + created_at: CREATED_AT, + machine_id: 'machine-1', + simulated: false, + remediation: false, + kind: 'crowdsec', + message: 'Test', + events_count: 0, + start_at: '2024-01-15T10:00:00Z', + stop_at: '2024-01-15T10:30:00Z', + uuid: 'alert-uuid-3', + source: { scope: 'Ip', value: '10.0.0.1', cn: 'FR', as_number: 'AS999', as_name: 'Test' }, + decisions: [], + meta: undefined, + events: [] + } + ]); + const ctx = createMockContext(watcher); + const tool = alertTools.find((t) => t.name === 'get_alert')!; + const result = await tool.handler({ id: '3' }, ctx); + + expect(result).not.toContain('### Meta'); + }); + }); + + describe('delete_alerts', () => { + it('should delete by ID when id is provided', async () => { + const tool = alertTools.find((t) => t.name === 'delete_alerts')!; + const result = await tool.handler({ id: '42' }, mockCtx); + + expect(mockWatcher.Alerts.deleteById).toHaveBeenCalledWith('42'); + expect(result).toContain('Deleted 1 alert(s).'); + }); + + it('should delete by filters when no id provided', async () => { + const tool = alertTools.find((t) => t.name === 'delete_alerts')!; + const result = await tool.handler({ ip: '192.168.1.100', scenario: 'ssh-bf' }, mockCtx); + + expect(mockWatcher.Alerts.delete).toHaveBeenCalledWith( + expect.objectContaining({ + ip: '192.168.1.100', + scenario: 'ssh-bf' + }) + ); + expect(result).toContain('Deleted 5 alert(s).'); + }); + + it('should reject scope without value', async () => { + const tool = alertTools.find((t) => t.name === 'delete_alerts')!; + + await expect(tool.handler({ scope: 'Range' }, mockCtx)).rejects.toThrow(/scope and value must be provided together/); + expect(mockWatcher.Alerts.search).not.toHaveBeenCalled(); + expect(mockWatcher.Alerts.deleteById).not.toHaveBeenCalled(); + }); + + it('should require confirm for origin-only deletes', async () => { + const tool = alertTools.find((t) => t.name === 'delete_alerts')!; + + await expect(tool.handler({ origin: 'cscli' }, mockCtx)).rejects.toThrow(/requires confirm=true/); + expect(mockWatcher.Alerts.delete).not.toHaveBeenCalled(); + expect(mockWatcher.Alerts.search).not.toHaveBeenCalled(); + expect(mockWatcher.Alerts.deleteById).not.toHaveBeenCalled(); + }); + + it('should delete alerts by origin with a direct delete when confirmed', async () => { + const tool = alertTools.find((t) => t.name === 'delete_alerts')!; + const result = await tool.handler({ origin: 'cscli', confirm: true }, mockCtx); + + expect(mockWatcher.Alerts.delete).toHaveBeenCalledWith( + expect.objectContaining({ + alert_source: 'cscli' + }) + ); + expect(mockWatcher.Alerts.search).not.toHaveBeenCalled(); + expect(result).toContain('Deleted 5 alert(s).'); + }); + + it('should require confirm for type-only deletes', async () => { + const tool = alertTools.find((t) => t.name === 'delete_alerts')!; + + await expect(tool.handler({ type: 'Ban' }, mockCtx)).rejects.toThrow(/requires confirm=true/); + expect(mockWatcher.Alerts.delete).not.toHaveBeenCalled(); + expect(mockWatcher.Alerts.search).not.toHaveBeenCalled(); + expect(mockWatcher.Alerts.deleteById).not.toHaveBeenCalled(); + }); + + it('should delete alerts by decision type and time filters through a search when confirmed', async () => { + const tool = alertTools.find((t) => t.name === 'delete_alerts')!; + mockWatcher.Alerts.search.mockResolvedValue([{ id: 31 }]); + mockWatcher.Alerts.deleteById.mockResolvedValue({ nbDeleted: '1' }); + + const result = await tool.handler({ type: 'Ban', since: '4h', until: '1h', confirm: true }, mockCtx); + + expect(mockWatcher.Alerts.search).toHaveBeenCalledWith( + expect.objectContaining({ + decision_type: 'ban', + since: '4h', + until: '1h', + limit: 0 + }) + ); + expect(result).toContain('Deleted 1 alert(s).'); + }); + + it('should require confirm for time-only deletes', async () => { + const tool = alertTools.find((t) => t.name === 'delete_alerts')!; + + await expect(tool.handler({ since: '24h', until: '1h' }, mockCtx)).rejects.toThrow(/requires confirm=true/); + expect(mockWatcher.Alerts.delete).not.toHaveBeenCalled(); + expect(mockWatcher.Alerts.search).not.toHaveBeenCalled(); + expect(mockWatcher.Alerts.deleteById).not.toHaveBeenCalled(); + }); + + it('should send since and until to the delete endpoint as durations when confirmed', async () => { + const tool = alertTools.find((t) => t.name === 'delete_alerts')!; + + const result = await tool.handler({ since: '24h', until: '1h', confirm: true }, mockCtx); + + expect(mockWatcher.Alerts.delete).toHaveBeenCalledWith(expect.objectContaining({ since: '24h', until: '1h' })); + expect(mockWatcher.Alerts.search).not.toHaveBeenCalled(); + expect(result).toContain('Deleted 5 alert(s).'); + }); + + it('should require confirm for has_active_decision-only deletes', async () => { + const tool = alertTools.find((t) => t.name === 'delete_alerts')!; + + await expect(tool.handler({ has_active_decision: true }, mockCtx)).rejects.toThrow(/requires confirm=true/); + expect(mockWatcher.Alerts.delete).not.toHaveBeenCalled(); + expect(mockWatcher.Alerts.search).not.toHaveBeenCalled(); + expect(mockWatcher.Alerts.deleteById).not.toHaveBeenCalled(); + }); + + it('should delete alerts with active decisions with a direct delete when confirmed', async () => { + const tool = alertTools.find((t) => t.name === 'delete_alerts')!; + const result = await tool.handler({ has_active_decision: true, confirm: true }, mockCtx); + + expect(mockWatcher.Alerts.delete).toHaveBeenCalledWith( + expect.objectContaining({ + has_active_decision: true + }) + ); + expect(mockWatcher.Alerts.search).not.toHaveBeenCalled(); + expect(result).toContain('Deleted 5 alert(s).'); + }); + + it('should reject unknown parameters', async () => { + const tool = alertTools.find((t) => t.name === 'delete_alerts')!; + + await expect(tool.handler({ unknown: true }, mockCtx)).rejects.toThrow(); + }); + + it('should reject invalid scope values', async () => { + const tool = alertTools.find((t) => t.name === 'delete_alerts')!; + + await expect(tool.handler({ scope: 'Country', value: 'ZZ' }, mockCtx)).rejects.toThrow(/invalid country code/); + }); + + it('should delete alerts contained in a CIDR through a search', async () => { + const tool = alertTools.find((t) => t.name === 'delete_alerts')!; + mockWatcher.Alerts.search.mockResolvedValue([{ id: 101 }, { id: 102 }]); + mockWatcher.Alerts.deleteById.mockResolvedValue({ nbDeleted: '1' }); + + const result = await tool.handler({ range: '198.51.100.0/24' }, mockCtx); + + expect(mockWatcher.Alerts.search).toHaveBeenCalledWith( + expect.objectContaining({ + ip: '198.51.100.0/24', + contains: false, + limit: 0 + }) + ); + expect(mockWatcher.Alerts.deleteById).toHaveBeenCalledTimes(2); + expect(result).toContain('Deleted 2 alert(s).'); + }); + + it('should throw when all=true without confirm=true', async () => { + const tool = alertTools.find((t) => t.name === 'delete_alerts')!; + + await expect(tool.handler({ all: true }, mockCtx)).rejects.toThrow('Deleting all alerts requires confirm=true'); + }); + + it('should delete all when all=true and confirm=true', async () => { + const tool = alertTools.find((t) => t.name === 'delete_alerts')!; + const result = await tool.handler({ all: true, confirm: true }, mockCtx); + + expect(mockWatcher.Alerts.delete).toHaveBeenCalledWith({}); + expect(result).toContain('Deleted 5 alert(s).'); + }); + + it('should throw when no filters and no id and no all', async () => { + const tool = alertTools.find((t) => t.name === 'delete_alerts')!; + + await expect(tool.handler({}, mockCtx)).rejects.toThrow( + 'At least one filter (ip, range, scope, value, scenario, origin, type, since, until, has_active_decision) or all=true is required' + ); + }); + + it('should require confirm when only scenario is provided', async () => { + const tool = alertTools.find((t) => t.name === 'delete_alerts')!; + + await expect(tool.handler({ scenario: 'ssh-bf' }, mockCtx)).rejects.toThrow(/requires confirm=true/); + expect(mockWatcher.Alerts.delete).not.toHaveBeenCalled(); + expect(mockWatcher.Alerts.search).not.toHaveBeenCalled(); + expect(mockWatcher.Alerts.deleteById).not.toHaveBeenCalled(); + }); + + it('should delete by scenario when confirmed', async () => { + const tool = alertTools.find((t) => t.name === 'delete_alerts')!; + const result = await tool.handler({ scenario: 'ssh-bf', confirm: true }, mockCtx); + + expect(mockWatcher.Alerts.delete).toHaveBeenCalledWith( + expect.objectContaining({ + scenario: 'ssh-bf' + }) + ); + }); + }); +}); diff --git a/packages/crowdsec-mcp/tests/tools/allowlists.test.ts b/packages/crowdsec-mcp/tests/tools/allowlists.test.ts new file mode 100644 index 000000000..75c85634d --- /dev/null +++ b/packages/crowdsec-mcp/tests/tools/allowlists.test.ts @@ -0,0 +1,402 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { APITypes } from 'crowdsec-client'; +import type { AppContext } from '../../src/types.js'; +import { allowlistTools } from '../../src/tools/allowlists.js'; + +function createMockAllowlists() { + return { + list: vi.fn(), + get: vi.fn(), + check: vi.fn(), + bulkCheck: vi.fn() + }; +} + +type MockAllowlists = ReturnType; + +function createMockContext(allowlists: MockAllowlists, maxResults = 50): AppContext { + return { + mode: 'watcher', + capabilities: { read: true, write: false, allowlists: true }, + allowlists, + config: { maxResults } as AppContext['config'], + ensureReady: vi.fn().mockResolvedValue(undefined), + stop: vi.fn().mockResolvedValue(undefined), + activeTools: [] + } as AppContext; +} + +function makeAllowlist(index: number, overrides: Partial = {}): APITypes.GetAllowlistResponse { + return { + name: `allowlist${index}`, + description: `Description ${index}`, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + console_managed: false, + items: [], + ...overrides + }; +} + +describe('allowlistTools', () => { + let mockAllowlists: MockAllowlists; + let mockCtx: AppContext; + + beforeEach(() => { + mockAllowlists = createMockAllowlists(); + mockCtx = createMockContext(mockAllowlists); + vi.clearAllMocks(); + }); + + const findTool = (name: string) => { + const tool = allowlistTools.find((t) => t.name === name); + if (!tool) { + throw new Error(`${name} tool not found`); + } + return tool; + }; + + describe('list_allowlists', () => { + it('should render Name/Description/Created/Updated/Console/Size columns with a count summary', async () => { + mockAllowlists.list.mockResolvedValue([ + makeAllowlist(1, { + name: 'allowlist1', + description: 'First allowlist', + console_managed: false, + items: [{ value: '1.2.3.4' }, { value: '5.6.7.8' }] + }), + makeAllowlist(2, { + name: 'allowlist2', + description: 'Second allowlist', + console_managed: true, + items: [{ value: '10.0.0.0/8' }] + }) + ]); + + const result = (await findTool('list_allowlists').handler({ limit: 10 }, mockCtx)) as string; + + expect(mockAllowlists.list).toHaveBeenCalledWith(); + expect(result).toContain('| Name'); + expect(result).toContain('Description'); + expect(result).toContain('Created at'); + expect(result).toContain('Updated at'); + expect(result).toContain('Managed by Console'); + expect(result).toContain('Size'); + expect(result).toContain('allowlist1'); + expect(result).toContain('First allowlist'); + expect(result).toContain('no'); + expect(result).toContain('allowlist2'); + expect(result).toContain('yes'); + expect(result).toContain('2 allowlists'); + expect(result).not.toContain('"items"'); + }); + + it('should use with_content=true by default via the port', async () => { + mockAllowlists.list.mockResolvedValue([makeAllowlist(0, { name: 'al1', description: 'd' })]); + + await findTool('list_allowlists').handler({}, mockCtx); + + expect(mockAllowlists.list).toHaveBeenCalledWith(); + }); + + it('should truncate rows to the requested limit and hint at hidden rows', async () => { + mockAllowlists.list.mockResolvedValue(Array.from({ length: 10 }, (_, i) => makeAllowlist(i))); + + const result = (await findTool('list_allowlists').handler({ limit: 3 }, mockCtx)) as string; + + expect(result).toContain('allowlist0'); + expect(result).toContain('allowlist2'); + expect(result).not.toContain('allowlist3'); + expect(result).toContain('7 more result(s) hidden'); + expect(result).toContain('10 allowlists'); + }); + + it('should render markdown for an empty list', async () => { + mockAllowlists.list.mockResolvedValue([]); + + const result = (await findTool('list_allowlists').handler({}, mockCtx)) as string; + + expect(result).toContain('_No results._'); + expect(result).toContain('0 allowlists'); + }); + }); + + describe('get_allowlist', () => { + it('should show info bullets and a Value/Comment/Expiration/Created items table when with_content=true', async () => { + mockAllowlists.get.mockResolvedValue({ + name: 'test-allowlist', + description: 'Test description', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-02T00:00:00Z', + console_managed: false, + items: [ + { + value: '192.168.1.1', + description: 'Local IP', + created_at: '2024-01-01T00:00:00Z', + expiration: '2030-01-01T00:00:00Z' + }, + { value: '10.0.0.0/8', description: 'Private range', created_at: '2024-01-02T00:00:00Z', expiration: undefined } + ] + } satisfies APITypes.GetAllowlistResponse); + + const result = (await findTool('get_allowlist').handler( + { name: 'test-allowlist', with_content: true, limit: 10 }, + mockCtx + )) as string; + + expect(mockAllowlists.get).toHaveBeenCalledWith('test-allowlist', true); + expect(result).toContain('**test-allowlist**'); + expect(result).toContain('Description: Test description'); + expect(result).toContain('Created at: 2024-01-01T00:00:00Z'); + expect(result).toContain('Updated at: 2024-01-02T00:00:00Z'); + expect(result).toContain('Managed by Console: no'); + expect(result).toContain('| Value'); + expect(result).toContain('Comment'); + expect(result).toContain('Expiration'); + expect(result).toContain('192.168.1.1'); + expect(result).toContain('Local IP'); + expect(result).toContain('10.0.0.0/8'); + expect(result).toContain('Private range'); + expect(result).not.toContain('"items"'); + }); + + it('should render the year-1 sentinel as never', async () => { + mockAllowlists.get.mockResolvedValue({ + name: 'no-expiry', + description: 'No expiry', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + console_managed: false, + items: [ + { + value: '1.2.3.4', + description: 'Permanent entry', + created_at: '2024-01-01T00:00:00Z', + expiration: '0001-01-01T00:00:00Z' + } + ] + } satisfies APITypes.GetAllowlistResponse); + + const result = (await findTool('get_allowlist').handler({ name: 'no-expiry' }, mockCtx)) as string; + + expect(result).toContain('never'); + expect(result).not.toContain('0001-01-01'); + }); + + it('should trim allowlist names before calling the LAPI', async () => { + mockAllowlists.get.mockResolvedValue(makeAllowlist(0, { name: 'test-allowlist' })); + + await findTool('get_allowlist').handler({ name: ' test-allowlist ' }, mockCtx); + + expect(mockAllowlists.get).toHaveBeenCalledWith('test-allowlist', true); + await expect(findTool('get_allowlist').handler({ name: ' ' }, mockCtx)).rejects.toThrow(/Allowlist name is required/); + }); + + it('should map a missing allowlist to a business error', async () => { + mockAllowlists.get.mockRejectedValue(new Error("CrowdSecError (500) : allowlist 'nope' not found")); + + await expect(findTool('get_allowlist').handler({ name: 'nope' }, mockCtx)).rejects.toThrow("Allowlist 'nope' not found"); + }); + + it('should show only info and _Content hidden when with_content=false even if items exist', async () => { + mockAllowlists.get.mockResolvedValue({ + name: 'test-allowlist', + description: 'Test', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + console_managed: false, + items: [{ value: '1.2.3.4', description: 'IP', created_at: '2024-01-01T00:00:00Z', expiration: undefined }] + } satisfies APITypes.GetAllowlistResponse); + + const result = (await findTool('get_allowlist').handler({ name: 'test-allowlist', with_content: false }, mockCtx)) as string; + + expect(mockAllowlists.get).toHaveBeenCalledWith('test-allowlist', false); + expect(result).toContain('**test-allowlist**'); + expect(result).toContain('_Content hidden (with_content=false)._'); + expect(result).not.toContain('Value | Comment'); + }); + + it('should show _No items._ for an empty items array', async () => { + mockAllowlists.get.mockResolvedValue( + makeAllowlist(0, { name: 'empty-allowlist', description: 'Empty', console_managed: true, items: [] }) + ); + + const result = (await findTool('get_allowlist').handler({ name: 'empty-allowlist', with_content: true }, mockCtx)) as string; + + expect(result).toContain('_No items._'); + expect(result).not.toContain('| Value'); + }); + + it('should show _No items._ when items are undefined', async () => { + mockAllowlists.get.mockResolvedValue({ + name: 'no-content-allowlist', + description: 'No content', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + console_managed: false, + items: undefined + } satisfies APITypes.GetAllowlistResponse); + + const result = (await findTool('get_allowlist').handler( + { name: 'no-content-allowlist', with_content: true }, + mockCtx + )) as string; + + expect(result).toContain('**no-content-allowlist**'); + expect(result).toContain('_No items._'); + }); + + it('should truncate items when limit is exceeded', async () => { + const manyItems: APITypes.AllowlistItem[] = Array.from({ length: 10 }, (_, i) => ({ + value: `192.168.1.${i}`, + description: `Item ${i}`, + created_at: '2024-01-01T00:00:00Z', + expiration: undefined + })); + mockAllowlists.get.mockResolvedValue(makeAllowlist(0, { name: 'test-allowlist', description: 'Test', items: manyItems })); + + const result = (await findTool('get_allowlist').handler( + { name: 'test-allowlist', with_content: true, limit: 3 }, + mockCtx + )) as string; + + expect(result).toContain('192.168.1.0'); + expect(result).toContain('192.168.1.2'); + expect(result).not.toContain('192.168.1.3'); + expect(result).toContain('7 more result(s) hidden'); + }); + }); + + describe('check_allowlist', () => { + it('should check a single allowlisted value via bulkCheck()', async () => { + mockAllowlists.bulkCheck.mockResolvedValue({ + results: [{ target: '192.168.1.1', allowlists: ['test-allowlist: Local network'] }] + } satisfies APITypes.BulkCheckAllowlistResponse); + + const result = (await findTool('check_allowlist').handler({ value: '192.168.1.1' }, mockCtx)) as string; + + expect(mockAllowlists.bulkCheck).toHaveBeenCalledWith(['192.168.1.1']); + expect(mockAllowlists.check).not.toHaveBeenCalled(); + expect(result).toContain('192.168.1.1: allowlisted by test-allowlist: Local network'); + expect(result).toContain('1 target of 1 target matched an allowlist.'); + }); + + it('should treat a single target missing from results as not allowlisted', async () => { + mockAllowlists.bulkCheck.mockResolvedValue({ results: [] } satisfies APITypes.BulkCheckAllowlistResponse); + + const result = (await findTool('check_allowlist').handler({ value: '192.168.1.1' }, mockCtx)) as string; + + expect(result).toContain('192.168.1.1: not allowlisted'); + expect(result).toContain('0 targets of 1 target matched an allowlist.'); + }); + + it('should check a single value that is not allowlisted', async () => { + mockAllowlists.bulkCheck.mockResolvedValue({ + results: [{ target: '8.8.8.8', allowlists: [] }] + } satisfies APITypes.BulkCheckAllowlistResponse); + + const result = (await findTool('check_allowlist').handler({ value: '8.8.8.8' }, mockCtx)) as string; + + expect(result).toContain('8.8.8.8: not allowlisted'); + expect(result).toContain('0 targets of 1 target matched an allowlist.'); + }); + + it('should distinguish a CIDR match from an exact IP match', async () => { + mockAllowlists.bulkCheck.mockResolvedValue({ + results: [{ target: '192.0.2.0/24', allowlists: ['192.0.2.10'] }] + } satisfies APITypes.BulkCheckAllowlistResponse); + + const result = (await findTool('check_allowlist').handler({ value: '192.0.2.0/24' }, mockCtx)) as string; + + expect(result).toContain('192.0.2.0/24: CIDR matches allowlist entry/entries 192.0.2.10'); + }); + + it('should trim checked values before calling the LAPI', async () => { + mockAllowlists.bulkCheck.mockResolvedValue({ results: [] } satisfies APITypes.BulkCheckAllowlistResponse); + + await findTool('check_allowlist').handler({ value: ' 192.168.1.1 ' }, mockCtx); + + expect(mockAllowlists.bulkCheck).toHaveBeenCalledWith(['192.168.1.1']); + }); + + it('should treat bulk targets missing from results as not allowlisted', async () => { + mockAllowlists.bulkCheck.mockResolvedValue({ + results: [ + { target: '192.168.1.1', allowlists: ['allowlist1: Local'] }, + { target: '10.0.0.0/8', allowlists: ['allowlist1: Private', 'allowlist2: RFC1918'] } + ] + } satisfies APITypes.BulkCheckAllowlistResponse); + + const result = (await findTool('check_allowlist').handler( + { values: ['192.168.1.1', '10.0.0.0/8', '8.8.8.8'] }, + mockCtx + )) as string; + + expect(mockAllowlists.bulkCheck).toHaveBeenCalledWith(['192.168.1.1', '10.0.0.0/8', '8.8.8.8']); + expect(result).toContain('192.168.1.1: allowlisted by allowlist1: Local'); + expect(result).toContain('10.0.0.0/8: CIDR matches allowlist entry/entries allowlist1: Private, allowlist2: RFC1918'); + expect(result).toContain('8.8.8.8: not allowlisted'); + expect(result).toContain('2 targets of 3 targets matched an allowlist.'); + }); + + it('should preserve input order of bulk targets in output', async () => { + mockAllowlists.bulkCheck.mockResolvedValue({ + results: [ + { target: '10.0.0.1', allowlists: ['al1'] }, + { target: '192.168.1.1', allowlists: ['al2'] }, + { target: '172.16.0.1', allowlists: [] } + ] + } satisfies APITypes.BulkCheckAllowlistResponse); + + const result = (await findTool('check_allowlist').handler( + { values: ['192.168.1.1', '10.0.0.1', '172.16.0.1'] }, + mockCtx + )) as string; + + const lines = result.split('\n').filter((line) => line.startsWith('-')); + expect(lines[0]).toContain('192.168.1.1'); + expect(lines[1]).toContain('10.0.0.1'); + expect(lines[2]).toContain('172.16.0.1'); + }); + + it('should deduplicate bulk targets', async () => { + mockAllowlists.bulkCheck.mockResolvedValue({ + results: [{ target: '192.0.2.10', allowlists: ['test'] }] + } satisfies APITypes.BulkCheckAllowlistResponse); + + const result = (await findTool('check_allowlist').handler( + { values: ['192.0.2.10', '192.0.2.10', '8.8.8.8'] }, + mockCtx + )) as string; + + expect(mockAllowlists.bulkCheck).toHaveBeenCalledWith(['192.0.2.10', '8.8.8.8']); + expect(result.split('\n').filter((line) => line.startsWith('-'))).toHaveLength(2); + expect(result).toContain('1 target of 2 targets matched an allowlist'); + }); + + it('should reject value and values together', async () => { + await expect(findTool('check_allowlist').handler({ value: '192.0.2.10', values: ['192.0.2.11'] }, mockCtx)).rejects.toThrow(); + expect(mockAllowlists.check).not.toHaveBeenCalled(); + expect(mockAllowlists.bulkCheck).not.toHaveBeenCalled(); + }); + + it('should reject an empty values array', async () => { + await expect(findTool('check_allowlist').handler({ values: [] }, mockCtx)).rejects.toThrow(); + expect(mockAllowlists.check).not.toHaveBeenCalled(); + expect(mockAllowlists.bulkCheck).not.toHaveBeenCalled(); + }); + + it('should reject when neither value nor values is provided', async () => { + await expect(findTool('check_allowlist').handler({}, mockCtx)).rejects.toThrow(); + expect(mockAllowlists.check).not.toHaveBeenCalled(); + expect(mockAllowlists.bulkCheck).not.toHaveBeenCalled(); + }); + + it('should reject an empty string value', async () => { + await expect(findTool('check_allowlist').handler({ value: '' }, mockCtx)).rejects.toThrow(); + expect(mockAllowlists.check).not.toHaveBeenCalled(); + expect(mockAllowlists.bulkCheck).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/crowdsec-mcp/tests/tools/decisions.test.ts b/packages/crowdsec-mcp/tests/tools/decisions.test.ts new file mode 100644 index 000000000..62d583538 --- /dev/null +++ b/packages/crowdsec-mcp/tests/tools/decisions.test.ts @@ -0,0 +1,965 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { decisionTools } from '../../src/tools/decisions.js'; + +describe('decisionTools', () => { + const createMockWatcher = () => ({ + Alerts: { + search: vi.fn(), + pushAlerts: vi.fn() + }, + Decisions: { + deleteById: vi.fn(), + delete: vi.fn() + } + }); + + const createMockBouncer = () => ({ + Decisions: { + search: vi.fn() + } + }); + + const createMockAllowlists = () => ({ + check: vi.fn() + }); + + const createMockConfig = (overrides = {}) => ({ + maxResults: 100, + maxBanDurationSeconds: 86400, // 24h + ...overrides + }); + + const createMockContext = (overrides = {}) => ({ + mode: 'watcher' as const, + capabilities: { read: true, write: true, allowlists: true }, + watcher: createMockWatcher(), + bouncer: undefined, + allowlists: createMockAllowlists(), + config: createMockConfig(), + ensureReady: vi.fn().mockResolvedValue(undefined), + stop: vi.fn().mockResolvedValue(undefined), + ...overrides + }); + + const makeDecisionAlerts = (count: number) => + Array.from({ length: count }, (_, i) => ({ + id: i + 1, + created_at: new Date().toISOString(), + decisions: [ + { + id: i + 1, + type: 'ban', + scope: 'Ip', + value: `198.51.100.${i + 1}`, + origin: 'cscli', + scenario: 'test', + duration: '4h', + until: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + simulated: false + } + ] + })); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + const findTool = (name: string) => decisionTools.find((t) => t.name === name); + + describe('list_decisions', () => { + it('should call watcher Alerts.search in watcher mode', async () => { + const mockAlerts = [ + { + id: 1, + decisions: [ + { + id: 1, + type: 'ban', + scope: 'Ip', + value: '1.2.3.4', + origin: 'cscli', + scenario: 'manual ban', + duration: '4h', + until: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + simulated: false + } + ] + } + ]; + + const ctx = createMockContext(); + ctx.watcher!.Alerts.search.mockResolvedValue(mockAlerts); + + const tool = findTool('list_decisions'); + const result = await tool!.handler({ limit: 10 }, ctx as any); + + expect(ctx.watcher!.Alerts.search).toHaveBeenCalledWith( + expect.objectContaining({ + limit: 0, + has_active_decision: true + }) + ); + expect(result).toContain('ban'); + expect(result).toContain('1.2.3.4'); + expect(result).toContain('Decision ID'); + expect(result).not.toContain('never'); + }); + + it('should call bouncer Decisions.search in bouncer mode', async () => { + const mockDecisions = [ + { + id: 1, + type: 'ban', + scope: 'Ip', + value: '1.2.3.4', + origin: 'cscli', + scenario: 'manual ban', + duration: '4h', + until: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + simulated: false + } + ]; + + const ctx = createMockContext({ + mode: 'bouncer', + watcher: undefined, + bouncer: createMockBouncer() + }); + ctx.bouncer!.Decisions.search.mockResolvedValue(mockDecisions); + + const tool = findTool('list_decisions'); + const result = await tool!.handler({ limit: 10 }, ctx as any); + + expect(ctx.bouncer!.Decisions.search).toHaveBeenCalledWith( + expect.not.objectContaining({ + limit: expect.anything() + }) + ); + expect(result).toContain('ban'); + expect(result).toContain('1.2.3.4'); + expect(result).not.toContain('never'); + }); + + it('should normalize scope and value before querying the LAPI', async () => { + const ctx = createMockContext(); + ctx.watcher!.Alerts.search.mockResolvedValue([]); + + await findTool('list_decisions')!.handler({ scope: 'ip', value: '1.2.3.4' }, ctx as any); + + expect(ctx.watcher!.Alerts.search).toHaveBeenCalledWith( + expect.objectContaining({ + scope: 'Ip', + value: '1.2.3.4' + }) + ); + }); + + it('should hide expired decisions', async () => { + const ctx = createMockContext(); + ctx.watcher!.Alerts.search.mockResolvedValue([ + { + id: 1, + created_at: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), + decisions: [ + { + id: 1, + type: 'ban', + scope: 'Ip', + value: '1.2.3.4', + origin: 'cscli', + scenario: 'test', + duration: '-20s', + simulated: false + } + ] + } + ]); + + const result = await findTool('list_decisions')!.handler({}, ctx as any); + + expect(result).toContain('0 decisions shown'); + expect(result).not.toContain('1.2.3.4'); + }); + + it('should reject invalid IP/CIDR and time filters', async () => { + const ctx = createMockContext(); + const tool = findTool('list_decisions')!; + + await expect(tool.handler({ ip: '198.51.100' }, ctx as any)).rejects.toThrow(/valid ip or CIDR/); + await expect(tool.handler({ since: 'banana' }, ctx as any)).rejects.toThrow(/invalid since/); + await expect(tool.handler({ since: '10y' }, ctx as any)).rejects.toThrow(/use h, m, s or d/); + await expect(tool.handler({ since: '2026-01-01T00:00:00Z' }, ctx as any)).rejects.toThrow(/use h, m, s or d/); + }); + + it('should allow CIDR in ip for containment', async () => { + const ctx = createMockContext(); + ctx.watcher!.Alerts.search.mockResolvedValue([]); + const tool = findTool('list_decisions')!; + + await tool.handler({ ip: '198.51.100.0/24' }, ctx as any); + + expect(ctx.watcher!.Alerts.search).toHaveBeenCalledWith( + expect.objectContaining({ + ip: '198.51.100.0/24', + contains: false + }) + ); + }); + + it('should deduplicate decisions by simulated:type:scope:value', async () => { + const mockAlerts = [ + { + id: 1, + decisions: [ + { + id: 1, + type: 'ban', + scope: 'Ip', + value: '1.2.3.4', + origin: 'cscli', + scenario: 'test', + duration: '4h', + until: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + simulated: false + }, + { + id: 2, + type: 'ban', + scope: 'Ip', + value: '1.2.3.4', + origin: 'cscli', + scenario: 'test', + duration: '4h', + until: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + simulated: false + } + ] + }, + { + id: 2, + decisions: [ + { + id: 3, + type: 'ban', + scope: 'Ip', + value: '1.2.3.4', + origin: 'cscli', + scenario: 'test', + duration: '4h', + until: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + simulated: false + } + ] + } + ]; + + const ctx = createMockContext(); + ctx.watcher!.Alerts.search.mockResolvedValue(mockAlerts); + + const tool = findTool('list_decisions'); + const result = await tool!.handler({}, ctx as any); + + // Distinct decision IDs must all be visible + expect(result).toContain('3 decisions shown'); + expect(result.match(/1\.2\.3\.4/g)?.length).toBe(3); + }); + + it('should respect maxResults limit and show more hint', async () => { + const mockAlerts = Array.from({ length: 150 }, (_, i) => ({ + id: i + 1, + decisions: [ + { + id: i + 1, + type: 'ban', + scope: 'Ip', + value: `1.2.3.${i}`, + origin: 'cscli', + scenario: 'test', + duration: '4h', + until: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + simulated: false + } + ] + })); + + const ctx = createMockContext({ config: createMockConfig({ maxResults: 100 }) }); + ctx.watcher!.Alerts.search.mockResolvedValue(mockAlerts); + + const tool = findTool('list_decisions'); + const result = await tool!.handler({}, ctx as any); + + expect(result).toContain('50 more result(s) hidden'); + }); + + it('should report exact hidden counts for an explicit limit', async () => { + const alerts = makeDecisionAlerts(40); + const ctx = createMockContext(); + ctx.watcher!.Alerts.search.mockResolvedValue(alerts); + const tool = findTool('list_decisions')!; + + const limit40 = await tool.handler({ limit: 40 }, ctx as any); + expect(limit40).toContain('40 decisions shown'); + expect(limit40).not.toContain('more result(s) hidden'); + + const limit26 = await tool.handler({ limit: 26 }, ctx as any); + expect(limit26).toContain('26 decisions shown'); + expect(limit26).toContain('14 more result(s) hidden'); + + const limit25 = await tool.handler({ limit: 25 }, ctx as any); + expect(limit25).toContain('25 decisions shown'); + expect(limit25).toContain('15 more result(s) hidden'); + + const limit1000 = await tool.handler({ limit: 1000 }, ctx as any); + expect(limit1000).toContain('40 decisions shown'); + expect(limit1000).not.toContain('more result(s) hidden'); + + expect(ctx.watcher!.Alerts.search).toHaveBeenCalledWith(expect.objectContaining({ limit: 0 })); + }); + + it('should hint that more results may exist when the default limit is reached', async () => { + const ctx = createMockContext({ config: createMockConfig({ maxResults: 25 }) }); + ctx.watcher!.Alerts.search.mockResolvedValue(makeDecisionAlerts(25)); + + const result = await findTool('list_decisions')!.handler({}, ctx as any); + + expect(result).toContain('25 decisions shown'); + expect(result).toContain('More results may be available'); + }); + + it('should show all decisions when all=true', async () => { + const mockAlerts = Array.from({ length: 150 }, (_, i) => ({ + id: i + 1, + decisions: [ + { + id: i + 1, + type: 'ban', + scope: 'Ip', + value: `1.2.3.${i}`, + origin: 'cscli', + scenario: 'test', + duration: '4h', + until: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + simulated: false + } + ] + })); + + const ctx = createMockContext({ config: createMockConfig({ maxResults: 25 }) }); + ctx.watcher!.Alerts.search.mockResolvedValue(mockAlerts); + + const result = await findTool('list_decisions')!.handler({ all: true }, ctx as any); + + expect(result).toContain('150 decisions shown'); + expect(result).not.toContain('more result(s) hidden'); + }); + + it('should pass through all filter parameters', async () => { + const ctx = createMockContext(); + ctx.watcher!.Alerts.search.mockResolvedValue([]); + + const tool = findTool('list_decisions'); + await tool!.handler( + { + ip: '1.2.3.4', + range: '192.168.1.0/24', + scope: 'Ip', + value: '1.2.3.4', + type: 'Ban', + origin: 'cscli', + scenario: 'test', + since: '24h', + until: '1h', + contains: true, + no_simulated: true + }, + ctx as any + ); + + expect(ctx.watcher!.Alerts.search).toHaveBeenCalledWith( + expect.objectContaining({ + ip: '1.2.3.4', + range: '192.168.1.0/24', + scope: 'Ip', + value: '1.2.3.4', + scenario: 'test', + decision_type: 'ban', + origin: 'cscli', + since: '24h', + until: '1h', + contains: true, + simulated: false, + has_active_decision: true + }) + ); + }); + }); + + describe('get_decision', () => { + it('should return an active decision with alert context', async () => { + const ctx = createMockContext(); + ctx.watcher!.Alerts.search.mockResolvedValue([ + { + id: 10, + created_at: new Date().toISOString(), + message: 'test alert', + source: { scope: 'Ip', value: '1.2.3.4' }, + decisions: [ + { + id: 123, + type: 'ban', + scope: 'Ip', + value: '1.2.3.4', + origin: 'cscli', + scenario: 'test', + duration: '1h', + simulated: false + } + ] + } + ]); + + const result = await findTool('get_decision')!.handler({ id: 123 }, ctx as any); + + expect(result).toContain('### Decision #123'); + expect(result).toContain('1.2.3.4'); + expect(result).toContain('Alert #10'); + }); + + it('should reject unknown decision ids', async () => { + const ctx = createMockContext(); + ctx.watcher!.Alerts.search.mockResolvedValue([]); + + await expect(findTool('get_decision')!.handler({ id: 999 }, ctx as any)).rejects.toThrow('Decision 999 not found'); + }); + + it('should reject invalid decision ids', async () => { + const ctx = createMockContext(); + + await expect(findTool('get_decision')!.handler({ id: 'abc' }, ctx as any)).rejects.toThrow( + 'decision id must be a positive integer' + ); + }); + }); + + describe('ban_ip', () => { + it('should reject when no target identifier provided', async () => { + const ctx = createMockContext(); + const tool = findTool('ban_ip'); + + await expect(tool!.handler({}, ctx as any)).rejects.toThrow('Must provide ip, range, or scope+value'); + }); + + it('should use ip as scope Ip when provided', async () => { + const ctx = createMockContext(); + ctx.watcher!.Alerts.pushAlerts.mockResolvedValue(['alert-123']); + ctx.allowlists.check.mockResolvedValue({ allowlisted: false }); + + const tool = findTool('ban_ip'); + const result = await tool!.handler({ ip: '1.2.3.4' }, ctx as any); + + expect(result).toContain('1.2.3.4'); + expect(result).toContain('Ip'); + expect(ctx.watcher!.Alerts.pushAlerts).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + decisions: expect.arrayContaining([ + expect.objectContaining({ + scope: 'Ip', + value: '1.2.3.4', + type: 'ban', + duration: '4h' + }) + ]) + }) + ]) + ); + }); + + it('should use range as scope Range when provided', async () => { + const ctx = createMockContext(); + ctx.watcher!.Alerts.pushAlerts.mockResolvedValue(['alert-123']); + ctx.allowlists.check.mockResolvedValue({ allowlisted: false }); + + const tool = findTool('ban_ip'); + const result = await tool!.handler({ range: '192.168.1.0/24' }, ctx as any); + + expect(result).toContain('192.168.1.0/24'); + expect(result).toContain('Range'); + expect(ctx.watcher!.Alerts.pushAlerts).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + decisions: expect.arrayContaining([ + expect.objectContaining({ + scope: 'Range', + value: '192.168.1.0/24' + }) + ]) + }) + ]) + ); + }); + + it('should reject multiple target forms', async () => { + const ctx = createMockContext(); + const tool = findTool('ban_ip'); + + await expect( + tool!.handler({ ip: '1.2.3.4', range: '10.0.0.0/8', value: '5.6.7.8', scope: 'Range' }, ctx as any) + ).rejects.toThrow('Provide only one of ip, range, or scope+value'); + }); + + it('should reject an invalid scope and decision type', async () => { + const ctx = createMockContext(); + const tool = findTool('ban_ip'); + + await expect(tool!.handler({ scope: 'Bogus', value: 'x' }, ctx as any)).rejects.toThrow(/invalid scope/); + await expect(tool!.handler({ ip: '1.2.3.4', type: 'Foo' }, ctx as any)).rejects.toThrow(/invalid decision type/); + }); + + it('should reject invalid IP', async () => { + const ctx = createMockContext(); + const tool = findTool('ban_ip'); + + await expect(tool!.handler({ ip: 'not-an-ip' }, ctx as any)).rejects.toThrow('not-an-ip is not a valid ip'); + }); + + it('should reject invalid CIDR', async () => { + const ctx = createMockContext(); + const tool = findTool('ban_ip'); + + await expect(tool!.handler({ range: 'not-a-cidr' }, ctx as any)).rejects.toThrow('not-a-cidr is not a valid range'); + }); + + it('should reject string durations without a unit before calling the LAPI', async () => { + const ctx = createMockContext(); + const tool = findTool('ban_ip'); + + await expect(tool!.handler({ ip: '1.2.3.4', duration: '300' }, ctx as any)).rejects.toThrow( + "invalid duration '300': use 30m, 4h or 7d" + ); + expect(ctx.watcher!.Alerts.pushAlerts).not.toHaveBeenCalled(); + expect(ctx.allowlists.check).not.toHaveBeenCalled(); + }); + + it('should interpret numeric durations as seconds', async () => { + const ctx = createMockContext(); + ctx.watcher!.Alerts.pushAlerts.mockResolvedValue(['alert-123']); + ctx.allowlists.check.mockResolvedValue({ allowlisted: false }); + const tool = findTool('ban_ip'); + + const result = await tool!.handler({ ip: '1.2.3.4', duration: 300 }, ctx as any); + + expect(result).toContain('5m'); + expect(ctx.watcher!.Alerts.pushAlerts).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + decisions: [expect.objectContaining({ duration: '5m' })] + }) + ]) + ); + }); + + it('should reject invalid Country and AS scope values', async () => { + const ctx = createMockContext(); + const tool = findTool('ban_ip'); + + await expect(tool!.handler({ scope: 'Country', value: 'ZZ' }, ctx as any)).rejects.toThrow(/invalid country code/); + await expect(tool!.handler({ scope: 'AS', value: 'not-a-number' }, ctx as any)).rejects.toThrow(/invalid AS number/); + expect(ctx.watcher!.Alerts.pushAlerts).not.toHaveBeenCalled(); + }); + + it('should normalize Country and AS scope values', async () => { + const countryCtx = createMockContext(); + countryCtx.watcher!.Alerts.pushAlerts.mockResolvedValue(['alert-country']); + await findTool('ban_ip')!.handler({ scope: 'Country', value: 'de' }, countryCtx as any); + expect(countryCtx.watcher!.Alerts.pushAlerts).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + decisions: [expect.objectContaining({ scope: 'Country', value: 'DE' })] + }) + ]) + ); + + const asCtx = createMockContext(); + asCtx.watcher!.Alerts.pushAlerts.mockResolvedValue(['alert-as']); + await findTool('ban_ip')!.handler({ scope: 'AS', value: 'AS64512' }, asCtx as any); + expect(asCtx.watcher!.Alerts.pushAlerts).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + decisions: [expect.objectContaining({ scope: 'AS', value: '64512' })] + }) + ]) + ); + }); + + it('should check allowlist for Ip scope', async () => { + const ctx = createMockContext(); + ctx.allowlists.check.mockResolvedValue({ allowlisted: true, reason: 'admin-allowlist' }); + const tool = findTool('ban_ip'); + + await expect(tool!.handler({ ip: '1.2.3.4' }, ctx as any)).rejects.toThrow( + '1.2.3.4 is allowlisted by item admin-allowlist, use force=true to add the decision anyway' + ); + expect(ctx.allowlists.check).toHaveBeenCalledWith('1.2.3.4'); + }); + + it('should reject an allowlisted target even without a reason', async () => { + const ctx = createMockContext(); + ctx.allowlists.check.mockResolvedValue({ allowlisted: true }); + const tool = findTool('ban_ip'); + + await expect(tool!.handler({ ip: '1.2.3.4' }, ctx as any)).rejects.toThrow(/is allowlisted/); + expect(ctx.watcher!.Alerts.pushAlerts).not.toHaveBeenCalled(); + }); + + it('should check allowlist for Range scope', async () => { + const ctx = createMockContext(); + ctx.allowlists.check.mockResolvedValue({ allowlisted: true, reason: 'net-allowlist' }); + const tool = findTool('ban_ip'); + + await expect(tool!.handler({ range: '192.168.1.0/24' }, ctx as any)).rejects.toThrow( + '192.168.1.0/24 is allowlisted by item net-allowlist, use force=true to add the decision anyway' + ); + expect(ctx.allowlists.check).toHaveBeenCalledWith('192.168.1.0/24'); + }); + + it('should bypass allowlist check with force=true', async () => { + const ctx = createMockContext(); + ctx.watcher!.Alerts.pushAlerts.mockResolvedValue(['alert-123']); + ctx.allowlists.check.mockResolvedValue({ allowlisted: true, reason: 'admin-allowlist' }); + const tool = findTool('ban_ip'); + + const result = await tool!.handler({ ip: '1.2.3.4', force: true }, ctx as any); + + expect(result).toContain('1.2.3.4'); + expect(ctx.allowlists.check).not.toHaveBeenCalled(); + }); + + it('should skip allowlist check for non-Ip/Range scopes', async () => { + const ctx = createMockContext(); + ctx.watcher!.Alerts.pushAlerts.mockResolvedValue(['alert-123']); + ctx.allowlists.check.mockResolvedValue({ allowlisted: true, reason: 'admin-allowlist' }); + const tool = findTool('ban_ip'); + + const result = await tool!.handler({ scope: 'Username', value: 'admin' }, ctx as any); + + expect(result).toContain('admin'); + expect(ctx.allowlists.check).not.toHaveBeenCalled(); + }); + + it('should build a cscli-compatible alert payload', async () => { + const ctx = createMockContext(); + ctx.watcher!.Alerts.pushAlerts.mockResolvedValue(['alert-123']); + ctx.allowlists.check.mockResolvedValue({ allowlisted: false }); + const tool = findTool('ban_ip'); + + await tool!.handler({ ip: '1.2.3.4', duration: '4h', type: 'ban', reason: 'test reason' }, ctx as any); + + expect(ctx.watcher!.Alerts.pushAlerts).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + decisions: [ + expect.objectContaining({ + origin: 'cscli', + type: 'ban', + scope: 'Ip', + value: '1.2.3.4', + duration: '4h', + scenario: 'test reason' + }) + ], + events: [], + events_count: 1, + leakspeed: '0', + message: 'test reason', + scenario: 'test reason', + scenario_hash: '', + scenario_version: '', + simulated: false, + source: expect.objectContaining({ + scope: 'Ip', + value: '1.2.3.4', + ip: '1.2.3.4', + range: '' + }), + remediation: true, + kind: 'cscli' + }) + ]) + ); + }); + + it('should reject duration exceeding maxBanDurationSeconds', async () => { + const ctx = createMockContext({ config: createMockConfig({ maxBanDurationSeconds: 3600 }) }); // 1h + ctx.allowlists.check.mockResolvedValue({ allowlisted: false }); + const tool = findTool('ban_ip'); + + await expect(tool!.handler({ ip: '1.2.3.4', duration: '2h' }, ctx as any)).rejects.toThrow('exceeds maximum allowed 1h'); + }); + + it('should report the allowlist before the max duration guard', async () => { + const ctx = createMockContext({ config: createMockConfig({ maxBanDurationSeconds: 3600 }) }); + ctx.allowlists.check.mockResolvedValue({ allowlisted: true, reason: 'admin-allowlist' }); + const tool = findTool('ban_ip'); + + await expect(tool!.handler({ ip: '1.2.3.4', duration: '2h' }, ctx as any)).rejects.toThrow('allowlisted'); + }); + + it('should allow duration exceeding max with force=true', async () => { + const ctx = createMockContext({ config: createMockConfig({ maxBanDurationSeconds: 3600 }) }); + ctx.watcher!.Alerts.pushAlerts.mockResolvedValue(['alert-123']); + ctx.allowlists.check.mockResolvedValue({ allowlisted: false }); + const tool = findTool('ban_ip'); + + const result = await tool!.handler({ ip: '1.2.3.4', duration: '2h', force: true }, ctx as any); + + expect(result).toContain('2h'); + }); + + it.each([ + ['duration', '4h'], + ['type', 'ban'], + ['reason', "manual 'ban' from 'crowdsec-mcp'"] + ])('should use default %s when not provided', async (_label, expected) => { + const ctx = createMockContext(); + ctx.watcher!.Alerts.pushAlerts.mockResolvedValue(['alert-123']); + ctx.allowlists.check.mockResolvedValue({ allowlisted: false }); + const tool = findTool('ban_ip'); + + const result = await tool!.handler({ ip: '1.2.3.4' }, ctx as any); + + expect(result).toContain(expected); + }); + + it('should pass custom reason to alert', async () => { + const ctx = createMockContext(); + ctx.watcher!.Alerts.pushAlerts.mockResolvedValue(['alert-123']); + ctx.allowlists.check.mockResolvedValue({ allowlisted: false }); + const tool = findTool('ban_ip'); + + const result = await tool!.handler({ ip: '1.2.3.4', reason: 'custom reason' }, ctx as any); + + expect(result).toContain('custom reason'); + expect(ctx.watcher!.Alerts.pushAlerts).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + decisions: expect.arrayContaining([ + expect.objectContaining({ + scenario: 'custom reason' + }) + ]), + message: 'custom reason', + scenario: 'custom reason' + }) + ]) + ); + }); + + it('should include alert ID in result', async () => { + const ctx = createMockContext(); + ctx.watcher!.Alerts.pushAlerts.mockResolvedValue(['alert-123', 'alert-456']); + ctx.allowlists.check.mockResolvedValue({ allowlisted: false }); + const tool = findTool('ban_ip'); + + const result = await tool!.handler({ ip: '1.2.3.4' }, ctx as any); + + expect(result).toContain('alert-123'); + expect(result).toContain('alert-456'); + }); + }); + + describe('unban_ip', () => { + it('should reject all=true without confirm=true', async () => { + const ctx = createMockContext(); + const tool = findTool('unban_ip'); + + await expect(tool!.handler({ all: true }, ctx as any)).rejects.toThrow('Deleting all decisions requires confirm=true'); + }); + + it('should delete all active decisions when all=true and confirm=true', async () => { + const ctx = createMockContext(); + ctx.watcher!.Alerts.search.mockResolvedValue([ + { id: 1, decisions: [{ id: 11 }] }, + { id: 2, decisions: [{ id: 12 }] } + ]); + ctx.watcher!.Decisions.deleteById.mockResolvedValue({ nbDeleted: '1' }); + const tool = findTool('unban_ip'); + + const result = await tool!.handler({ all: true, confirm: true }, ctx as any); + + expect(result).toContain('2 decision(s) deleted'); + expect(ctx.watcher!.Decisions.deleteById).toHaveBeenCalledTimes(2); + expect(ctx.watcher!.Decisions.delete).not.toHaveBeenCalled(); + }); + + it('should reject scope without value', async () => { + const ctx = createMockContext(); + const tool = findTool('unban_ip'); + + await expect(tool!.handler({ scope: 'Range' }, ctx as any)).rejects.toThrow('scope and value must be provided together'); + expect(ctx.watcher!.Decisions.delete).not.toHaveBeenCalled(); + expect(ctx.watcher!.Alerts.search).not.toHaveBeenCalled(); + }); + + it('should reject ip combined with range', async () => { + const ctx = createMockContext(); + const tool = findTool('unban_ip'); + + await expect(tool!.handler({ ip: '1.2.3.4', range: '10.0.0.0/8' }, ctx as any)).rejects.toThrow( + 'Provide only one of ip or range' + ); + expect(ctx.watcher!.Decisions.delete).not.toHaveBeenCalled(); + }); + + it('should reject scope combined with ip', async () => { + const ctx = createMockContext(); + const tool = findTool('unban_ip'); + + await expect(tool!.handler({ ip: '1.2.3.4', scope: 'Ip' }, ctx as any)).rejects.toThrow( + 'Provide only one of ip, range, or scope+value' + ); + expect(ctx.watcher!.Decisions.delete).not.toHaveBeenCalled(); + }); + + it('should reject all=true combined with filters', async () => { + const ctx = createMockContext(); + const tool = findTool('unban_ip'); + + await expect(tool!.handler({ all: true, confirm: true, ip: '1.2.3.4' }, ctx as any)).rejects.toThrow( + 'Cannot combine all=true with id or filters' + ); + }); + + it('should delete by id when provided without scanning alerts', async () => { + const ctx = createMockContext(); + ctx.watcher!.Decisions.deleteById.mockResolvedValue({ nbDeleted: '1' }); + const tool = findTool('unban_ip'); + + const result = await tool!.handler({ id: '123' }, ctx as any); + + expect(result).toContain('1 decision(s) deleted'); + expect(ctx.watcher!.Decisions.deleteById).toHaveBeenCalledWith('123'); + expect(ctx.watcher!.Alerts.search).not.toHaveBeenCalled(); + }); + + it('should not report a replay as a successful deletion', async () => { + const ctx = createMockContext(); + ctx.watcher!.Decisions.deleteById.mockResolvedValue({ nbDeleted: '0' }); + const tool = findTool('unban_ip'); + + await expect(tool!.handler({ id: '123' }, ctx as any)).rejects.toThrow('Decision 123 not found'); + }); + + it('should delete decisions contained in a range through a search', async () => { + const ctx = createMockContext(); + ctx.watcher!.Alerts.search.mockResolvedValue([ + { id: 1, decisions: [{ id: 11 }] }, + { id: 2, decisions: [{ id: 12 }] } + ]); + ctx.watcher!.Decisions.deleteById.mockResolvedValue({ nbDeleted: '1' }); + const tool = findTool('unban_ip'); + + const result = await tool!.handler({ range: '198.51.100.0/24' }, ctx as any); + + expect(ctx.watcher!.Alerts.search).toHaveBeenCalledWith( + expect.objectContaining({ + ip: '198.51.100.0/24', + contains: false, + has_active_decision: true, + limit: 0 + }) + ); + expect(ctx.watcher!.Decisions.deleteById).toHaveBeenCalledTimes(2); + expect(result).toContain('2 decision(s) deleted'); + }); + + it('should delete by filters when provided', async () => { + const ctx = createMockContext(); + ctx.watcher!.Decisions.delete.mockResolvedValue({ nbDeleted: '3' }); + const tool = findTool('unban_ip'); + + const result = await tool!.handler({ ip: '1.2.3.4', type: 'ban' }, ctx as any); + + expect(result).toContain('3 decision(s) deleted'); + expect(ctx.watcher!.Decisions.delete).toHaveBeenCalledWith( + expect.objectContaining({ + ip: '1.2.3.4', + type: 'ban' + }) + ); + }); + + it('should reject unknown unban parameters', async () => { + const ctx = createMockContext(); + const tool = findTool('unban_ip'); + + await expect(tool!.handler({ since: '1h' }, ctx as any)).rejects.toThrow(); + }); + + it('should require confirm for type-only unbans', async () => { + const ctx = createMockContext(); + const tool = findTool('unban_ip'); + + await expect(tool!.handler({ type: 'ban' }, ctx as any)).rejects.toThrow(/requires confirm=true/); + expect(ctx.watcher!.Decisions.delete).not.toHaveBeenCalled(); + }); + + it('should require confirm for scenario-only unbans', async () => { + const ctx = createMockContext(); + const tool = findTool('unban_ip'); + + await expect(tool!.handler({ scenario: 'ssh-bf' }, ctx as any)).rejects.toThrow(/requires confirm=true/); + expect(ctx.watcher!.Decisions.delete).not.toHaveBeenCalled(); + }); + + it('should allow type-only unbans with confirm=true', async () => { + const ctx = createMockContext(); + ctx.watcher!.Decisions.delete.mockResolvedValue({ nbDeleted: '2' }); + const tool = findTool('unban_ip'); + + const result = await tool!.handler({ type: 'ban', confirm: true }, ctx as any); + + expect(result).toContain('2 decision(s) deleted'); + expect(ctx.watcher!.Decisions.delete).toHaveBeenCalledWith(expect.objectContaining({ type: 'ban' })); + }); + + it('should reject when no id, no filters, and not all', async () => { + const ctx = createMockContext(); + const tool = findTool('unban_ip'); + + await expect(tool!.handler({}, ctx as any)).rejects.toThrow( + 'Must provide id, at least one filter, or all=true with confirm=true' + ); + }); + + it('should map scope=Ip to the ip filter and never send scope', async () => { + const ctx = createMockContext(); + ctx.watcher!.Decisions.delete.mockResolvedValue({ nbDeleted: '1' }); + const tool = findTool('unban_ip'); + + await tool!.handler({ scope: 'Ip', value: '1.2.3.4', type: 'Ban', scenario: 'test' }, ctx as any); + + const query = ctx.watcher!.Decisions.delete.mock.calls[0][0]; + expect(query).toMatchObject({ ip: '1.2.3.4', type: 'ban', scenario: 'test' }); + expect(query).not.toHaveProperty('scope'); + expect(query).not.toHaveProperty('value'); + }); + + it('should delete non-IP scopes with a direct delete', async () => { + const ctx = createMockContext(); + ctx.watcher!.Decisions.delete.mockResolvedValue({ nbDeleted: '1' }); + const tool = findTool('unban_ip'); + + const result = await tool!.handler({ scope: 'Country', value: 'DE' }, ctx as any); + + expect(ctx.watcher!.Decisions.delete).toHaveBeenCalledWith( + expect.objectContaining({ + scope: 'Country', + value: 'DE' + }) + ); + expect(ctx.watcher!.Alerts.search).not.toHaveBeenCalled(); + expect(result).toContain('1 decision(s) deleted'); + }); + }); +}); diff --git a/packages/crowdsec-mcp/tests/transports/http.test.ts b/packages/crowdsec-mcp/tests/transports/http.test.ts new file mode 100644 index 000000000..29ab1e24b --- /dev/null +++ b/packages/crowdsec-mcp/tests/transports/http.test.ts @@ -0,0 +1,306 @@ +import http from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { describe, expect, it } from 'vitest'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { isAuthorizedHeader, startHttpServer } from '../../src/transports/http.js'; +import type { RequestServerFactory } from '../../src/transports/http.js'; +import { HttpRequestError } from '../../src/http-error.js'; +import { makeConfig } from '../helpers.js'; + +const TOKEN = 'test-secret-token'; + +function makeServerFactory(): RequestServerFactory { + return () => { + const server = new McpServer({ name: 'test', version: '1.0.0' }); + server.registerTool('ping', { description: 'Ping' }, async () => ({ + content: [{ type: 'text', text: 'pong' }] + })); + return { server }; + }; +} + +function portOf(server: http.Server): number { + return (server.address() as AddressInfo).port; +} + +function closeServer(server: http.Server): Promise { + return new Promise((resolve) => { + server.close(() => resolve()); + }); +} + +interface TestResponse { + status: number; + headers: http.IncomingHttpHeaders; + body: string; +} + +function request( + port: number, + options: { method?: string; path?: string; headers?: Record; body?: string } = {} +): Promise { + return new Promise((resolve, reject) => { + const req = http.request( + { + host: '127.0.0.1', + port, + method: options.method ?? 'GET', + path: options.path ?? '/mcp', + headers: options.headers + }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => { + chunks.push(chunk); + }); + res.on('end', () => { + resolve({ status: res.statusCode ?? 0, headers: res.headers, body: Buffer.concat(chunks).toString('utf-8') }); + }); + res.on('error', reject); + } + ); + req.on('error', reject); + if (options.body !== undefined) { + req.write(options.body); + } + req.end(); + }); +} + +function authHeaders(token: string): Record { + return { authorization: `Bearer ${token}` }; +} + +function initializeBody(): string { + return JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-11-25', + capabilities: {}, + clientInfo: { name: 'test-client', version: '1.0.0' } + } + }); +} + +function httpConfig() { + return makeConfig({ transport: 'http', http: { host: '127.0.0.1', port: 0, token: TOKEN } }); +} + +describe('isAuthorizedHeader', () => { + it('rejects a missing header', () => { + expect(isAuthorizedHeader(undefined, TOKEN)).toBe(false); + }); + + it('rejects a wrong scheme', () => { + expect(isAuthorizedHeader(`Basic ${TOKEN}`, TOKEN)).toBe(false); + expect(isAuthorizedHeader(TOKEN, TOKEN)).toBe(false); + expect(isAuthorizedHeader('bearer ' + TOKEN, TOKEN)).toBe(false); + }); + + it('rejects a wrong token', () => { + expect(isAuthorizedHeader('Bearer wrong-token-with-same-length!!', 'wrong-token-with-other-length!')).toBe(false); + expect(isAuthorizedHeader(authHeaders('wrong').authorization, TOKEN)).toBe(false); + }); + + it('accepts the correct token', () => { + expect(isAuthorizedHeader(`Bearer ${TOKEN}`, TOKEN)).toBe(true); + }); + + it('rejects a length mismatch without throwing', () => { + expect(isAuthorizedHeader('Bearer short', TOKEN)).toBe(false); + expect(isAuthorizedHeader(`Bearer ${TOKEN}-with-extra-suffix`, TOKEN)).toBe(false); + }); +}); + +describe('http transport authentication', () => { + it('starts without a service token and passes LAPI headers to the factory', async () => { + let received: http.IncomingHttpHeaders | undefined; + const factory = makeServerFactory(); + const server = await startHttpServer( + (headers) => { + received = headers; + return factory(); + }, + makeConfig({ http: { host: '127.0.0.1', port: 0, token: undefined } }) + ); + + try { + const res = await request(portOf(server), { + method: 'POST', + path: '/mcp', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + 'x-crowdsec-api-key': 'lapi-key' + }, + body: initializeBody() + }); + + expect(res.status).toBe(200); + expect(received?.['x-crowdsec-api-key']).toBe('lapi-key'); + } finally { + await closeServer(server); + } + }); + + it('maps request setup errors to JSON-RPC status codes', async () => { + const server = await startHttpServer(() => { + throw new HttpRequestError(401, -32001, 'Missing X-CrowdSec-Api-Key or X-CrowdSec-Token'); + }, httpConfig()); + + try { + const res = await request(portOf(server), { + method: 'POST', + path: '/mcp', + headers: { + ...authHeaders(TOKEN), + 'content-type': 'application/json', + accept: 'application/json, text/event-stream' + }, + body: initializeBody() + }); + + expect(res.status).toBe(401); + expect(JSON.parse(res.body)).toMatchObject({ error: { code: -32001 } }); + } finally { + await closeServer(server); + } + }); + + it('returns 401 without a token, even on unknown routes', async () => { + const server = await startHttpServer(makeServerFactory(), httpConfig()); + try { + const port = portOf(server); + + const noAuthMcp = await request(port, { method: 'POST', path: '/mcp', body: '{}' }); + expect(noAuthMcp.status).toBe(401); + expect(noAuthMcp.headers['www-authenticate']).toBe('Bearer'); + expect(JSON.parse(noAuthMcp.body)).toMatchObject({ jsonrpc: '2.0', error: { code: -32001 } }); + + // Auth is checked before routing: unknown route without token is 401, not 404. + const noAuthOther = await request(port, { method: 'GET', path: '/other' }); + expect(noAuthOther.status).toBe(401); + + // Auth is checked before method handling: unsupported method without token is 401, not 405. + const noAuthMethod = await request(port, { method: 'PUT', path: '/mcp' }); + expect(noAuthMethod.status).toBe(401); + } finally { + await closeServer(server); + } + }); + + it('returns 401 with a wrong token', async () => { + const server = await startHttpServer(makeServerFactory(), httpConfig()); + try { + const port = portOf(server); + + const wrong = await request(port, { method: 'POST', path: '/mcp', headers: authHeaders('wrong-token'), body: '{}' }); + expect(wrong.status).toBe(401); + expect(wrong.headers['www-authenticate']).toBe('Bearer'); + + const wrongScheme = await request(port, { method: 'POST', path: '/mcp', headers: { authorization: 'Basic abc' }, body: '{}' }); + expect(wrongScheme.status).toBe(401); + } finally { + await closeServer(server); + } + }); + + it('accepts a valid token: initialize POST is not 401 and unknown route is 404', async () => { + const server = await startHttpServer(makeServerFactory(), httpConfig()); + try { + const port = portOf(server); + + const init = await request(port, { + method: 'POST', + path: '/mcp', + headers: { ...authHeaders(TOKEN), 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: initializeBody() + }); + expect(init.status).not.toBe(401); + expect(init.status).toBe(200); + + const other = await request(port, { method: 'GET', path: '/other', headers: authHeaders(TOKEN) }); + expect(other.status).toBe(404); + } finally { + await closeServer(server); + } + }); + + it('returns 405 for unsupported methods after auth', async () => { + const server = await startHttpServer(makeServerFactory(), httpConfig()); + try { + const put = await request(portOf(server), { method: 'PUT', path: '/mcp', headers: authHeaders(TOKEN) }); + expect(put.status).toBe(405); + } finally { + await closeServer(server); + } + }); + + it('rejects invalid JSON with 400 after auth', async () => { + const server = await startHttpServer(makeServerFactory(), httpConfig()); + try { + const res = await request(portOf(server), { + method: 'POST', + path: '/mcp', + headers: { ...authHeaders(TOKEN), 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: 'not-json{{{' + }); + expect(res.status).toBe(400); + expect(JSON.parse(res.body)).toMatchObject({ jsonrpc: '2.0', error: { code: -32700 } }); + } finally { + await closeServer(server); + } + }); + + it('rejects an oversized body with 413', async () => { + const server = await startHttpServer(makeServerFactory(), httpConfig()); + try { + const res = await request(portOf(server), { + method: 'POST', + path: '/mcp', + headers: { ...authHeaders(TOKEN), 'content-type': 'application/json' }, + body: 'x'.repeat(4 * 1024 * 1024 + 1) + }); + expect(res.status).toBe(413); + expect(JSON.parse(res.body)).toMatchObject({ jsonrpc: '2.0', error: { code: -32000 } }); + } finally { + await closeServer(server); + } + }); + + it('sets explicit request timeouts', async () => { + const server = await startHttpServer(makeServerFactory(), httpConfig()); + try { + expect(server.requestTimeout).toBe(30_000); + expect(server.headersTimeout).toBe(10_000); + expect(server.keepAliveTimeout).toBe(5_000); + expect(server.maxHeadersCount).toBe(100); + } finally { + await closeServer(server); + } + }); + + it('supports the SDK client initialize/notifications/tools-list sequence', async () => { + const server = await startHttpServer(makeServerFactory(), httpConfig()); + try { + const port = portOf(server); + const transport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${port}/mcp`), { + requestInit: { + headers: { Authorization: `Bearer ${TOKEN}` } + } + }); + const client = new Client({ name: 'sdk-test', version: '1.0.0' }); + + await client.connect(transport); + const tools = await client.listTools(); + expect(Array.isArray(tools.tools)).toBe(true); + await client.close(); + } finally { + await closeServer(server); + } + }); +}); diff --git a/packages/crowdsec-mcp/tests/utils.test.ts b/packages/crowdsec-mcp/tests/utils.test.ts new file mode 100644 index 000000000..eb4843845 --- /dev/null +++ b/packages/crowdsec-mcp/tests/utils.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; +import { + assertValidScopeValue, + formatDuration, + isValidCidr, + isValidIp, + isValidTimeFilter, + normalizeScope, + parseDuration, + splitList, + toGoDuration +} from '../src/utils.js'; + +describe('utils', () => { + it('parses cscli durations', () => { + expect(parseDuration('30m')).toBe(1800); + expect(parseDuration('4h')).toBe(14400); + expect(parseDuration('7d')).toBe(604800); + expect(parseDuration('1h30m')).toBe(5400); + expect(parseDuration('2d3h4m5s')).toBe(2 * 86400 + 3 * 3600 + 4 * 60 + 5); + expect(parseDuration('2d')).toBe(172800); + }); + + it('rejects invalid durations', () => { + expect(() => parseDuration('')).toThrow(); + expect(() => parseDuration('abc')).toThrow(); + expect(() => parseDuration('4 hours')).toThrow(); + expect(() => parseDuration('0h')).toThrow(); + expect(() => parseDuration('90')).toThrow("invalid duration '90': use 30m, 4h or 7d"); + expect(() => parseDuration('300')).toThrow("invalid duration '300': use 30m, 4h or 7d"); + // CrowdSec only accepts `d` as the first unit and rejects fractional days/weeks. + expect(() => parseDuration('1.5d')).toThrow(); + expect(() => parseDuration('2d4d')).toThrow(); + expect(() => parseDuration('1h30m2d')).toThrow(); + expect(() => parseDuration('1w')).toThrow(); + }); + + it('normalizes durations to the Go wire format', () => { + expect(toGoDuration('7d')).toBe('168h'); + expect(toGoDuration('2d3h')).toBe('51h'); + expect(toGoDuration('1h30m')).toBe('1h30m'); + expect(toGoDuration('90s')).toBe('1m30s'); + expect(toGoDuration(300)).toBe('5m'); + }); + + it('validates and normalizes scope values', () => { + expect(assertValidScopeValue('Country', 'de')).toBe('DE'); + expect(assertValidScopeValue('AS', 'AS64512')).toBe('64512'); + expect(assertValidScopeValue('AS', '64512')).toBe('64512'); + expect(assertValidScopeValue('Username', ' admin ')).toBe('admin'); + expect(() => assertValidScopeValue('Country', 'ZZ')).toThrow(/invalid country code/); + expect(() => assertValidScopeValue('Country', 'FRA')).toThrow(/invalid country code/); + expect(() => assertValidScopeValue('AS', 'not-a-number')).toThrow(/invalid AS number/); + expect(() => assertValidScopeValue('AS', 'AS0')).toThrow(/positive numeric ASN/); + expect(() => assertValidScopeValue('Ip', 'nope')).toThrow(/valid ip/); + expect(() => assertValidScopeValue('Range', '10.0.0.0')).toThrow(/valid range/); + }); + + it('formats durations', () => { + expect(formatDuration(0)).toBe('0s'); + expect(formatDuration(5400)).toBe('1h 30m'); + expect(formatDuration(90000)).toBe('1d 1h'); + }); + + it('validates IPs and CIDRs', () => { + expect(isValidIp('1.2.3.4')).toBe(true); + expect(isValidIp('2001:db8::1')).toBe(true); + expect(isValidIp('not-an-ip')).toBe(false); + expect(isValidCidr('10.0.0.0/8')).toBe(true); + expect(isValidCidr('2001:db8::/32')).toBe(true); + expect(isValidCidr('10.0.0.0/33')).toBe(false); + expect(isValidCidr('10.0.0.0')).toBe(false); + }); + + it('normalizes scopes like cscli', () => { + expect(normalizeScope('ip')).toBe('Ip'); + expect(normalizeScope('RANGE')).toBe('Range'); + expect(normalizeScope('username')).toBe('Username'); + expect(normalizeScope('custom')).toBe('Custom'); + expect(normalizeScope(undefined, '1.2.3.4')).toBe('Ip'); + expect(normalizeScope(undefined, undefined, '10.0.0.0/8')).toBe('Range'); + }); + + it('rejects invalid IPs and CIDRs in normalizeScope', () => { + expect(() => normalizeScope(undefined, 'nope')).toThrow(/valid ip/); + expect(() => normalizeScope(undefined, undefined, 'nope')).toThrow(/valid range/); + }); + + it('validates time filters', () => { + expect(isValidTimeFilter('4h')).toBe(true); + expect(isValidTimeFilter('3650d')).toBe(true); + expect(isValidTimeFilter('87600h')).toBe(true); + expect(isValidTimeFilter('10y')).toBe(false); + expect(isValidTimeFilter('90')).toBe(false); + expect(isValidTimeFilter('2026-01-01T00:00:00Z')).toBe(false); + expect(isValidTimeFilter('banana')).toBe(false); + }); + + it('splits comma-separated lists', () => { + expect(splitList('a, b ,c')).toEqual(['a', 'b', 'c']); + expect(splitList('')).toBeUndefined(); + expect(splitList(undefined)).toBeUndefined(); + }); +}); diff --git a/packages/crowdsec-mcp/tsconfig.json b/packages/crowdsec-mcp/tsconfig.json new file mode 100644 index 000000000..141d55572 --- /dev/null +++ b/packages/crowdsec-mcp/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": false, + "sourceMap": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/packages/crowdsec-mcp/vitest.config.ts b/packages/crowdsec-mcp/vitest.config.ts new file mode 100644 index 000000000..0812a40df --- /dev/null +++ b/packages/crowdsec-mcp/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + clearMocks: true, + globals: true, + coverage: { + provider: 'v8', + reporter: ['clover', 'json', 'lcov', 'text', 'json-summary'], + include: ['src/**/*.ts'], + exclude: ['src/types.ts', 'src/**/*.test.ts'] + }, + include: ['tests/**/*.(test|tests|spec|specs).+(ts|tsx|js)'] + } +}); diff --git a/patch_workflows_node24.py b/patch_workflows_node24.py deleted file mode 100644 index c816497a1..000000000 --- a/patch_workflows_node24.py +++ /dev/null @@ -1,17 +0,0 @@ -import sys -import glob - -files = glob.glob(".github/workflows/*.yml") - -for file_path in files: - with open(file_path, "r") as f: - content = f.read() - - # Replace lts/* with 24 - if "node-version: lts/*" in content: - content = content.replace("node-version: lts/*", "node-version: 24") - with open(file_path, "w") as f: - f.write(content) - print(f"Patched {file_path}") - else: - print(f"No match found in {file_path}") diff --git a/patch_workflows_node24_matrix.py b/patch_workflows_node24_matrix.py deleted file mode 100644 index d0c1be58b..000000000 --- a/patch_workflows_node24_matrix.py +++ /dev/null @@ -1,14 +0,0 @@ -import sys - -file_path = ".github/workflows/CI.yml" -with open(file_path, "r") as f: - content = f.read() - -search = "node-version: [ lts/*, 24, latest ]" -replace = "node-version: [ 24, latest ]" - -if search in content: - content = content.replace(search, replace) - with open(file_path, "w") as f: - f.write(content) - print("Patch applied to matrix in CI.yml.") diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 437423180..eca43265c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -162,19 +162,22 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + qs: 6.17.0 + importers: .: devDependencies: + '@arethetypeswrong/cli': + specifier: 0.18.5 + version: 0.18.5 '@eslint/eslintrc': specifier: 3.3.7 version: 3.3.7(supports-color@7.2.0) '@eslint/js': specifier: 10.0.1 version: 10.0.1(eslint@10.13.0(jiti@2.7.0)(supports-color@7.2.0)) - '@tsconfig/node-lts': - specifier: 24.0.1 - version: 24.0.1 '@types/node': specifier: 26.6.5 version: 26.6.5 @@ -193,6 +196,9 @@ importers: prettier: specifier: 3.9.10 version: 3.9.10 + publint: + specifier: 0.3.25 + version: 0.3.25 ts-node: specifier: 10.9.2 version: 10.9.2(@types/node@26.6.5)(typescript@6.0.3) @@ -288,8 +294,8 @@ importers: packages/crowdsec-client: dependencies: axios: - specifier: ^1.20.0 - version: 1.20.0(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0) + specifier: ^1.21.0 + version: 1.21.0(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0) debug: specifier: ^4.4.3 version: 4.4.3(supports-color@7.2.0) @@ -417,8 +423,48 @@ importers: specifier: 5.0.5 version: 5.0.5(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(vite@8.2.1(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) + packages/crowdsec-mcp: + dependencies: + '@modelcontextprotocol/sdk': + specifier: 1.32.2 + version: 1.32.2(supports-color@7.2.0)(zod@4.6.7) + crowdsec-client: + specifier: workspace:^ + version: link:../crowdsec-client + debug: + specifier: ^4.4.3 + version: 4.4.3(supports-color@7.2.0) + zod: + specifier: 4.6.7 + version: 4.6.7 + devDependencies: + '@types/debug': + specifier: 4.1.13 + version: 4.1.13 + '@types/node': + specifier: 26.6.5 + version: 26.6.5 + '@vitest/coverage-v8': + specifier: 5.0.5 + version: 5.0.5(vitest@5.0.5) + esbuild: + specifier: 0.28.2 + version: 0.28.2 + rimraf: + specifier: 6.1.3 + version: 6.1.3 + typescript: + specifier: 6.0.3 + version: 6.0.3 + vitest: + specifier: 5.0.5 + version: 5.0.5(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(vite@8.2.1(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) + packages: + '@andrewbranch/untar.js@1.0.4': + resolution: {integrity: sha512-pVXSwPsLuw8IGLo2Di0EaOfsk+ntVvpkk942J/sHYIkwvtKUakEcPh7HBgZ6tuimgzKSEHgCvO4XgQ05DEbwDw==} + '@apidevtools/json-schema-ref-parser@14.0.1': resolution: {integrity: sha512-Oc96zvmxx1fqoSEdUmfmvvb59/KDOnUoJ7s2t7bISyAn0XEz57LCCw8k2Y4Pf3mwKaZLMciESALORLgfe2frCw==} engines: {node: '>= 16'} @@ -435,6 +481,15 @@ packages: peerDependencies: openapi-types: '>=7' + '@arethetypeswrong/cli@0.18.5': + resolution: {integrity: sha512-gM+8vRsQOD/Uc7EnBedUhkG5OCsDWE4uoak5QvomGpMpaky0Eh41p04nIMgrWb8EOmqZUJGc6zz9hsP6E56R7g==} + engines: {node: '>=20'} + hasBin: true + + '@arethetypeswrong/core@0.18.5': + resolution: {integrity: sha512-9ytjzGwxjm9Uz7I9avfbt5vlQt6uk9uRRESzJjqrznl6WKvI6dwYTo+vJ3U02Wrq/mR3iql/PzhvHhKdJIAjDQ==} + engines: {node: '>=20'} + '@babel/helper-string-parser@7.29.7': resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} @@ -473,12 +528,19 @@ packages: '@biomejs/wasm-nodejs@2.5.2': resolution: {integrity: sha512-B0r7jLdCmXhq4+jnx1oA0/SChLy5G283r35HJr276T+w6qgqAPAhqfnlKs+UobclAfidNDbfj9ZmcuCijaHDxQ==} + '@braidai/lang@1.1.2': + resolution: {integrity: sha512-qBcknbBufNHlui137Hft8xauQMTZDKdophmLFv05r2eNmdIv/MlPuP4TdUknHG68UdWLgVZwgxVe735HzJNIwA==} + '@cacheable/memory@2.2.0': resolution: {integrity: sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==} '@cacheable/utils@2.5.0': resolution: {integrity: sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==} + '@colors/colors@1.5.0': + resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} + engines: {node: '>=0.1.90'} + '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} @@ -691,6 +753,12 @@ packages: '@gerrit0/mini-shiki@3.23.0': resolution: {integrity: sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg==} + '@hono/node-server@2.1.5': + resolution: {integrity: sha512-MgP/I06GdvsilDspMk/qmRzpk8LOUStP6jFAbSRi40W09DhoyUn+Mj7eyKICPqeYKybOjVFLVIAQzvu1bfIa7Q==} + engines: {node: '>=20'} + peerDependencies: + hono: ^4 || ^5.0.0-0 + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -733,6 +801,19 @@ packages: '@keyv/serialize@1.1.1': resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} + '@loaderkit/resolve@1.0.6': + resolution: {integrity: sha512-G8FdIoF5CypfwmD9rl8BXod5HDn8JqB0CCNBXDTaRZ+yRYhARrrSToX1zg1zy9jX3zLqigsELwhT4gNtkdQAUg==} + + '@modelcontextprotocol/sdk@1.32.2': + resolution: {integrity: sha512-SWsVtH/0vULSQStoZ6xxH2K09Li8fXYTMC64ZeogCdpZ3XSi1eCPOByV1OpuJxuI3OcmHhosDWYyii07tpPsLw==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@oxc-project/types@0.144.0': resolution: {integrity: sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==} @@ -740,6 +821,10 @@ packages: resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} engines: {node: ^14.18.0 || >=16.0.0} + '@publint/pack@0.1.7': + resolution: {integrity: sha512-4EDEmvxWtgsCnnVeBvtFIFZtUhPPt1+bA9JrSwU4Sa//6oKtzCSlGGXYJr44OD9aGISymbieJ4mCKHUygUDU+g==} + engines: {node: '>=18'} + '@rolldown/binding-android-arm64@1.2.4': resolution: {integrity: sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -848,8 +933,9 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} - '@tsconfig/node-lts@24.0.1': - resolution: {integrity: sha512-xo+1KtV5zVb90fyyeglG1n5cqg3cQXLlCkEpLptYlZUNk28xmwhuD5GmxoXwmrVtUTvaMyclun9kAd0TK7H+Pg==} + '@sindresorhus/is@4.6.0': + resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} + engines: {node: '>=10'} '@tsconfig/node10@1.0.13': resolution: {integrity: sha512-gcLdvR9HO1ZJBypsOGqaP6TFEzb6vIta0KSTLt9NAQ6pXQO3cRgSVyCN6pzYqI9DlJgY71XKO0dpDhCf08b3pg==} @@ -1027,20 +1113,34 @@ packages: ajv: optional: true + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} + ansi-regex@6.4.0: + resolution: {integrity: sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==} + engines: {node: '>=12'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + arg@4.1.3: resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} @@ -1057,8 +1157,8 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - axios@1.20.0: - resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==} + axios@1.21.0: + resolution: {integrity: sha512-YCzXMB53Q9PZBsh7yN7wtapit4WepwcHS2fP8QhGsBxSz5YtgYobV7GGlWlAJi+RxfU77GQTQi2DSLUma42EYA==} balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -1070,8 +1170,8 @@ packages: benchmark@2.1.4: resolution: {integrity: sha512-l9MlfN4M1K/H2fbhfMy3B7vJd6AGKJVQn2h6Sg/Yx+KckoUA7ewS5Vv6TjSq18ooE1kS9hhAlQRH3AkXIh/aOQ==} - body-parser@2.3.0: - resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + body-parser@2.4.0: + resolution: {integrity: sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==} engines: {node: '>=18'} brace-expansion@1.1.21: @@ -1118,6 +1218,18 @@ packages: resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} engines: {node: '>=18'} + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + char-regex@1.0.2: + resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} + engines: {node: '>=10'} + chokidar@5.0.0: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} @@ -1125,9 +1237,24 @@ packages: citty@0.2.2: resolution: {integrity: sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==} + cjs-module-lexer@1.4.3: + resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} + class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + cli-highlight@2.1.11: + resolution: {integrity: sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==} + engines: {node: '>=8.0.0', npm: '>=5.0.0'} + hasBin: true + + cli-table3@0.6.5: + resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} + engines: {node: 10.* || >= 12.*} + + cliui@7.0.4: + resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -1147,6 +1274,10 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} + engines: {node: '>=14'} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -1161,6 +1292,10 @@ packages: resolution: {integrity: sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==} engines: {node: '>=18'} + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + content-type@2.1.0: resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} engines: {node: '>=18'} @@ -1173,6 +1308,10 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} @@ -1239,6 +1378,9 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + emojilib@2.4.0: + resolution: {integrity: sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==} + encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} @@ -1247,6 +1389,10 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -1369,10 +1515,24 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + eventsource-parser@3.1.1: + resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + expect-type@1.4.0: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} + express-rate-limit@8.7.1: + resolution: {integrity: sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + express@5.3.0: resolution: {integrity: sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==} engines: {node: '>= 18'} @@ -1395,8 +1555,8 @@ packages: fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} - fast-uri@3.1.5: - resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==} + fast-uri@3.1.9: + resolution: {integrity: sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==} fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} @@ -1407,6 +1567,9 @@ packages: picomatch: optional: true + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + file-entry-cache@11.1.5: resolution: {integrity: sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==} @@ -1424,8 +1587,8 @@ packages: flatted@3.4.4: resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} - follow-redirects@1.16.0: - resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + follow-redirects@1.16.2: + resolution: {integrity: sha512-/kVP2U8z596Ia0gwFrwyIeo74ta5aVmSeU1FfAUMAmcEP5EP7zydmag3SLiN64NaRKef3lxsq7i/g7ltJQDGow==} engines: {node: '>=4.0'} peerDependencies: debug: '*' @@ -1505,6 +1668,13 @@ packages: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} + highlight.js@10.7.3: + resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} + + hono@4.13.14: + resolution: {integrity: sha512-yhRLQ21U6qa9Hsu2N7Qcbh8tE+WKEcoErjIo56LeSGiGXGp9sA7UpE86Ui9uexb61KakiGwuHMn2QdV7w6i6kQ==} + engines: {node: '>=16.9.0'} + hookified@1.15.1: resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} @@ -1571,6 +1741,9 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jose@6.2.13: + resolution: {integrity: sha512-7TIg10ZE1x82YediN+lcrPNvGMKge9yFHQD+Qho/b8y2adfugU3AsvvHfxV0fzyFllT6l9xCjf4RWY9g/Z9PJQ==} + js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -1588,6 +1761,9 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -1702,6 +1878,17 @@ packages: resolution: {integrity: sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==} hasBin: true + marked-terminal@7.3.0: + resolution: {integrity: sha512-t4rBvPsHc57uE/2nJOLmMbZCQ4tgAccAED3ngXQqW6g+TxA488JzJ+FK3lQkzBQOI1mRV/r/Kq+1ZlJ4D0owQw==} + engines: {node: '>=16.0.0'} + peerDependencies: + marked: '>=1 <16' + + marked@9.1.6: + resolution: {integrity: sha512-jcByLnIFkd5gSXZmjNvS1TlmRhCXZjIzHYlaGkPlLIekG55JDR2Z4va9tZwCiP+/RDERiNhMOFu01xd6O5ct1Q==} + engines: {node: '>= 16'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -1752,9 +1939,16 @@ packages: resolution: {integrity: sha512-xQPoBXcNjjHiOvOraFBKtA++uNWF6aCVHL9dRKFXEov8eI3QJwtgiw3qApsonFT5SpoqsEVISUTg3HIDs2DiXw==} engines: {node: '>=10', npm: '>=6'} + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + nanoid@3.3.18: resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -1772,6 +1966,10 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} + node-emoji@2.2.0: + resolution: {integrity: sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==} + engines: {node: '>=18'} + node-fetch-h2@2.3.0: resolution: {integrity: sha512-ofRW94Ab0T4AOh5Fk8t0h8OBWrmjb0SSB20xh1H8YnPV9EJ+f5AMoYSUQ2zgJ4Iq2HAK0I2l5/Nequ8YzFS3Hg==} engines: {node: 4.x || >=6.0.0} @@ -1804,6 +2002,10 @@ packages: oas-validator@5.0.8: resolution: {integrity: sha512-cu20/HE5N5HKqVygs3dt94eYJfBi0TsZvPVXDhbXQHiEityDN+RROTleefoKRKKJ9dFAF2JBkDHgvWj0sjKGmw==} + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -1840,10 +2042,22 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + package-manager-detector@1.9.0: + resolution: {integrity: sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse5-htmlparser2-tree-adapter@6.0.1: + resolution: {integrity: sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==} + + parse5@5.1.1: + resolution: {integrity: sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==} + + parse5@6.0.1: + resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -1876,6 +2090,10 @@ packages: resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + pkg-types@2.3.1: resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} @@ -1907,6 +2125,11 @@ packages: resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} engines: {node: '>=10'} + publint@0.3.25: + resolution: {integrity: sha512-WMMbn/d7JqRlfXQL/JUI4n5skC8cJ5WVr8/wT5mmiYWkUdENkZr1I5rgs2+dQPVRgoJd+JRYtB46MTsuDylw9g==} + engines: {node: '>=18'} + hasBin: true + punycode.js@2.3.1: resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} engines: {node: '>=6'} @@ -1919,12 +2142,8 @@ packages: resolution: {integrity: sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==} engines: {node: '>=20'} - qs@6.15.3: - resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} - engines: {node: '>=0.6'} - - qs@6.16.0: - resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} + qs@6.17.0: + resolution: {integrity: sha512-5LK5BZ2XDHMvcUJaf0CQ5HL3aVgKbpklvByC7qLSxYkfE8COEEbJg5xCHWCTqzqi0Gs4Jn5Mv5v3R38W6edscA==} engines: {node: '>=0.6'} range-parser@1.3.0: @@ -1971,6 +2190,10 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} + sade@1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -2032,6 +2255,10 @@ packages: resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} engines: {node: '>= 0.4'} + skin-tone@2.0.0: + resolution: {integrity: sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==} + engines: {node: '>=8'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2066,6 +2293,10 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + supports-hyperlinks@3.2.0: + resolution: {integrity: sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==} + engines: {node: '>=14.18'} + swagger-schema-official@2.0.0-bab6bed: resolution: {integrity: sha512-rCC0NWGKr/IJhtRuPq/t37qvZHI/mH4I4sxflVM+qgVe5Z2uOCivzWaVbuioJaB61kvm5UvB7b49E+oBY0M8jA==} @@ -2089,6 +2320,13 @@ packages: tailwind-merge@3.6.0: resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + tiny-lru@13.0.0: resolution: {integrity: sha512-xDHxKKS1FdF0Tv2P+QT7IeSEg74K/8cEDzbv3Tv6UyHHUgBOjOiQiBp818MGj66dhurQus/IBcoAbwIKtSGc6Q==} engines: {node: '>=14'} @@ -2155,6 +2393,11 @@ packages: peerDependencies: typescript: 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x + typescript@5.6.1-rc: + resolution: {integrity: sha512-E3b2+1zEFu84jB0YQi9BORDjz9+jGbwwy1Zi3G0LUNw7a7cePUrHMRNy8aPh53nXpkFGVHSxIZo5vKTfYaFiBQ==} + engines: {node: '>=14.17'} + hasBin: true + typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} @@ -2166,6 +2409,10 @@ packages: undici-types@8.9.0: resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} + unicode-emoji-modifier-base@1.0.0: + resolution: {integrity: sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==} + engines: {node: '>=4'} + unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} @@ -2176,6 +2423,10 @@ packages: v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + validate-npm-package-name@5.0.1: + resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -2304,10 +2555,18 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yargs-parser@20.2.9: + resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} + engines: {node: '>=10'} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs@16.2.2: + resolution: {integrity: sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==} + engines: {node: '>=10'} + yargs@17.7.3: resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} engines: {node: '>=12'} @@ -2331,8 +2590,18 @@ packages: react: optional: true + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + + zod@4.6.7: + resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==} + snapshots: + '@andrewbranch/untar.js@1.0.4': {} + '@apidevtools/json-schema-ref-parser@14.0.1': dependencies: '@types/json-schema': 7.0.15 @@ -2352,6 +2621,27 @@ snapshots: call-me-maybe: 1.0.2 openapi-types: 12.1.3 + '@arethetypeswrong/cli@0.18.5': + dependencies: + '@arethetypeswrong/core': 0.18.5 + chalk: 4.1.2 + cli-table3: 0.6.5 + commander: 10.0.1 + marked: 9.1.6 + marked-terminal: 7.3.0(marked@9.1.6) + semver: 7.8.5 + + '@arethetypeswrong/core@0.18.5': + dependencies: + '@andrewbranch/untar.js': 1.0.4 + '@loaderkit/resolve': 1.0.6 + cjs-module-lexer: 1.4.3 + fflate: 0.8.3 + lru-cache: 11.5.3 + semver: 7.8.5 + typescript: 5.6.1-rc + validate-npm-package-name: 5.0.1 + '@babel/helper-string-parser@7.29.7': {} '@babel/helper-validator-identifier@7.29.7': {} @@ -2373,6 +2663,8 @@ snapshots: '@biomejs/wasm-nodejs@2.5.2': {} + '@braidai/lang@1.1.2': {} + '@cacheable/memory@2.2.0': dependencies: '@cacheable/utils': 2.5.0 @@ -2385,6 +2677,9 @@ snapshots: hashery: 1.5.1 keyv: 5.6.0 + '@colors/colors@1.5.0': + optional: true + '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 @@ -2527,6 +2822,10 @@ snapshots: '@shikijs/types': 3.23.0 '@shikijs/vscode-textmate': 10.0.2 + '@hono/node-server@2.1.5(hono@4.13.14)': + dependencies: + hono: 4.13.14 + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -2565,10 +2864,40 @@ snapshots: '@keyv/serialize@1.1.1': {} + '@loaderkit/resolve@1.0.6': + dependencies: + '@braidai/lang': 1.1.2 + + '@modelcontextprotocol/sdk@1.32.2(supports-color@7.2.0)(zod@4.6.7)': + dependencies: + '@hono/node-server': 2.1.5(hono@4.13.14) + ajv: 8.20.0 + ajv-formats: 3.0.1 + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.1 + express: 5.3.0(supports-color@7.2.0) + express-rate-limit: 8.7.1(express@5.3.0(supports-color@7.2.0))(supports-color@7.2.0) + hono: 4.13.14 + jose: 6.2.13 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.6.7 + zod-to-json-schema: 3.25.2(zod@4.6.7) + transitivePeerDependencies: + - supports-color + '@oxc-project/types@0.144.0': {} '@pkgr/core@0.3.6': {} + '@publint/pack@0.1.7': + dependencies: + tinyexec: 1.3.1 + '@rolldown/binding-android-arm64@1.2.4': optional: true @@ -2633,7 +2962,7 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} - '@tsconfig/node-lts@24.0.1': {} + '@sindresorhus/is@4.6.0': {} '@tsconfig/node10@1.0.13': {} @@ -2822,6 +3151,10 @@ snapshots: optionalDependencies: ajv: 8.20.0 + ajv-formats@3.0.1: + dependencies: + ajv: 8.20.0 + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 @@ -2832,16 +3165,24 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.5 + fast-uri: 3.1.9 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + ansi-regex@5.0.1: {} + ansi-regex@6.4.0: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 + any-promise@1.3.0: {} + arg@4.1.3: {} argparse@2.0.1: {} @@ -2856,9 +3197,9 @@ snapshots: asynckit@0.4.0: {} - axios@1.20.0(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0): + axios@1.21.0(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0): dependencies: - follow-redirects: 1.16.0(debug@4.4.3(supports-color@7.2.0)) + follow-redirects: 1.16.2(debug@4.4.3(supports-color@7.2.0)) form-data: 4.0.6 https-proxy-agent: 5.0.1(supports-color@7.2.0) proxy-from-env: 2.1.0 @@ -2875,7 +3216,7 @@ snapshots: lodash: 4.18.1 platform: 1.3.6 - body-parser@2.3.0(supports-color@7.2.0): + body-parser@2.4.0(supports-color@7.2.0): dependencies: bytes: 3.1.2 content-type: 2.1.0 @@ -2883,7 +3224,7 @@ snapshots: http-errors: 2.0.1 iconv-lite: 0.7.3 on-finished: 2.4.1 - qs: 6.15.3 + qs: 6.17.0 raw-body: 3.0.2 type-is: 2.1.0 transitivePeerDependencies: @@ -2943,16 +3284,48 @@ snapshots: chai@6.3.0: {} + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@5.6.2: {} + + char-regex@1.0.2: {} + chokidar@5.0.0: dependencies: readdirp: 5.1.1 citty@0.2.2: {} + cjs-module-lexer@1.4.3: {} + class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 + cli-highlight@2.1.11: + dependencies: + chalk: 4.1.2 + highlight.js: 10.7.3 + mz: 2.7.0 + parse5: 5.1.1 + parse5-htmlparser2-tree-adapter: 6.0.1 + yargs: 16.2.2 + + cli-table3@0.6.5: + dependencies: + string-width: 4.2.3 + optionalDependencies: + '@colors/colors': 1.5.0 + + cliui@7.0.4: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -2971,6 +3344,8 @@ snapshots: dependencies: delayed-stream: 1.0.0 + commander@10.0.1: {} + concat-map@0.0.1: {} confbox@0.2.4: {} @@ -2979,12 +3354,19 @@ snapshots: content-disposition@2.0.1: {} + content-type@1.0.5: {} + content-type@2.1.0: {} cookie-signature@1.2.2: {} cookie@0.7.2: {} + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + create-require@1.1.1: {} cross-spawn@7.0.6: @@ -3033,10 +3415,14 @@ snapshots: emoji-regex@8.0.0: {} + emojilib@2.4.0: {} + encodeurl@2.0.0: {} entities@4.5.0: {} + environment@1.1.0: {} + es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -3188,12 +3574,26 @@ snapshots: etag@1.8.1: {} + eventsource-parser@3.1.1: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.1 + expect-type@1.4.0: {} + express-rate-limit@8.7.1(express@5.3.0(supports-color@7.2.0))(supports-color@7.2.0): + dependencies: + debug: 4.4.3(supports-color@7.2.0) + express: 5.3.0(supports-color@7.2.0) + ip-address: 10.7.5 + transitivePeerDependencies: + - supports-color + express@5.3.0(supports-color@7.2.0): dependencies: accepts: 2.0.0 - body-parser: 2.3.0(supports-color@7.2.0) + body-parser: 2.4.0(supports-color@7.2.0) content-disposition: 2.0.1 content-type: 2.1.0 cookie: 0.7.2 @@ -3212,7 +3612,7 @@ snapshots: once: 1.4.0 parseurl: 1.3.3 proxy-addr: 2.0.8 - qs: 6.16.0 + qs: 6.17.0 range-parser: 1.3.0 router: 2.2.0(supports-color@7.2.0) send: 1.2.1(supports-color@7.2.0) @@ -3235,12 +3635,14 @@ snapshots: fast-safe-stringify@2.1.1: {} - fast-uri@3.1.5: {} + fast-uri@3.1.9: {} fdir@6.5.0(picomatch@4.0.7): optionalDependencies: picomatch: 4.0.7 + fflate@0.8.3: {} + file-entry-cache@11.1.5: dependencies: flat-cache: 6.1.23 @@ -3269,7 +3671,7 @@ snapshots: flatted@3.4.4: {} - follow-redirects@1.16.0(debug@4.4.3(supports-color@7.2.0)): + follow-redirects@1.16.2(debug@4.4.3(supports-color@7.2.0)): optionalDependencies: debug: 4.4.3(supports-color@7.2.0) @@ -3326,8 +3728,7 @@ snapshots: gopd@1.2.0: {} - has-flag@4.0.0: - optional: true + has-flag@4.0.0: {} has-symbols@1.1.0: {} @@ -3343,6 +3744,10 @@ snapshots: dependencies: function-bind: 1.1.2 + highlight.js@10.7.3: {} + + hono@4.13.14: {} + hookified@1.15.1: {} hookified@2.2.0: {} @@ -3397,6 +3802,8 @@ snapshots: jiti@2.7.0: {} + jose@6.2.13: {} + js-tokens@10.0.0: {} js-yaml@4.3.1: @@ -3411,6 +3818,8 @@ snapshots: json-schema-traverse@1.0.0: {} + json-schema-typed@8.0.2: {} + json-stable-stringify-without-jsonify@1.0.1: {} keyv@5.6.0: @@ -3506,6 +3915,19 @@ snapshots: punycode.js: 2.3.1 uc.micro: 2.1.0 + marked-terminal@7.3.0(marked@9.1.6): + dependencies: + ansi-escapes: 7.3.0 + ansi-regex: 6.4.0 + chalk: 5.6.2 + cli-highlight: 2.1.11 + cli-table3: 0.6.5 + marked: 9.1.6 + node-emoji: 2.2.0 + supports-hyperlinks: 3.2.0 + + marked@9.1.6: {} + math-intrinsics@1.1.0: {} maxmind@5.0.7: @@ -3543,8 +3965,16 @@ snapshots: mmdb-lib@3.0.3: {} + mri@1.2.0: {} + ms@2.1.3: {} + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + nanoid@3.3.18: {} nanoid@5.1.16: {} @@ -3553,6 +3983,13 @@ snapshots: negotiator@1.0.0: {} + node-emoji@2.2.0: + dependencies: + '@sindresorhus/is': 4.6.0 + char-regex: 1.0.2 + emojilib: 2.4.0 + skin-tone: 2.0.0 + node-fetch-h2@2.3.0: dependencies: http2-client: 1.3.5 @@ -3596,6 +4033,8 @@ snapshots: should: 13.2.3 yaml: 1.10.3 + object-assign@4.1.1: {} + object-inspect@1.13.4: {} obug@3.0.0: {} @@ -3631,10 +4070,20 @@ snapshots: package-json-from-dist@1.0.1: {} + package-manager-detector@1.9.0: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 + parse5-htmlparser2-tree-adapter@6.0.1: + dependencies: + parse5: 6.0.1 + + parse5@5.1.1: {} + + parse5@6.0.1: {} + parseurl@1.3.3: {} path-exists@4.0.0: {} @@ -3656,6 +4105,8 @@ snapshots: picomatch@4.0.7: {} + pkce-challenge@5.0.1: {} + pkg-types@2.3.1: dependencies: confbox: 0.2.4 @@ -3685,6 +4136,13 @@ snapshots: proxy-from-env@2.1.0: {} + publint@0.3.25: + dependencies: + '@publint/pack': 0.1.7 + package-manager-detector: 1.9.0 + picocolors: 1.1.1 + sade: 1.8.1 + punycode.js@2.3.1: {} punycode@2.3.1: {} @@ -3693,12 +4151,7 @@ snapshots: dependencies: hookified: 2.2.0 - qs@6.15.3: - dependencies: - es-define-property: 1.0.1 - side-channel: 1.1.1 - - qs@6.16.0: + qs@6.17.0: dependencies: es-define-property: 1.0.1 side-channel: 1.1.1 @@ -3762,6 +4215,10 @@ snapshots: transitivePeerDependencies: - supports-color + sade@1.8.1: + dependencies: + mri: 1.2.0 + safer-buffer@2.1.2: {} semver@7.8.5: {} @@ -3853,6 +4310,10 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + skin-tone@2.0.0: + dependencies: + unicode-emoji-modifier-base: 1.0.0 + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -3881,7 +4342,11 @@ snapshots: supports-color@7.2.0: dependencies: has-flag: 4.0.0 - optional: true + + supports-hyperlinks@3.2.0: + dependencies: + has-flag: 4.0.0 + supports-color: 7.2.0 swagger-schema-official@2.0.0-bab6bed: {} @@ -3937,6 +4402,14 @@ snapshots: tailwind-merge@3.6.0: {} + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + tiny-lru@13.0.0: {} tinybench@6.2.2: {} @@ -3999,12 +4472,16 @@ snapshots: typescript: 6.0.3 yaml: 2.9.0 + typescript@5.6.1-rc: {} + typescript@6.0.3: {} uc.micro@2.1.0: {} undici-types@8.9.0: {} + unicode-emoji-modifier-base@1.0.0: {} + unpipe@1.0.0: {} uri-js@4.4.1: @@ -4013,6 +4490,8 @@ snapshots: v8-compile-cache-lib@3.0.1: {} + validate-npm-package-name@5.0.1: {} + vary@1.1.2: {} vite@8.2.1(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0): @@ -4080,8 +4559,20 @@ snapshots: yaml@2.9.0: {} + yargs-parser@20.2.9: {} + yargs-parser@21.1.1: {} + yargs@16.2.2: + dependencies: + cliui: 7.0.4 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 20.2.9 + yargs@17.7.3: dependencies: cliui: 8.0.1 @@ -4104,3 +4595,9 @@ snapshots: dompurify: 3.4.13 nanoid: 5.1.16 tailwind-merge: 3.6.0 + + zod-to-json-schema@3.25.2(zod@4.6.7): + dependencies: + zod: 4.6.7 + + zod@4.6.7: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 353e0e29b..4adc93199 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,3 +4,8 @@ packages: - 'packages/*' allowBuilds: esbuild: true +overrides: + qs: 6.17.0 +minimumReleaseAgeExclude: + - axios@1.21.0 + - qs@6.17.0 diff --git a/readme.md b/readme.md index aa7c39b37..b4a1b2555 100644 --- a/readme.md +++ b/readme.md @@ -1,5 +1,7 @@ # node-crowdsec + [![CI](https://github.com/thib3113/node-crowdsec/actions/workflows/CI.yml/badge.svg)](https://github.com/thib3113/node-crowdsec/actions/workflows/CI.yml) +[![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/thib3113/node-crowdsec/badge)](https://securityscorecards.dev/viewer/?uri=github.com/thib3113/node-crowdsec) [![codecov](https://codecov.io/gh/thib3113/node-crowdsec/branch/main/graph/badge.svg?token=bQdlb31gcU)](https://codecov.io/gh/thib3113/node-crowdsec) [![Known Vulnerabilities](https://snyk.io/test/github/thib3113/node-crowdsec/badge.svg)](https://snyk.io/test/github/thib3113/node-crowdsec) [![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg?logo=paypal)](https://paypal.me/thib3113) @@ -8,10 +10,14 @@ ![Dependencies update - renovate](https://img.shields.io/badge/renovate-enabled-green?style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjUgNSAzNzAgMzcwIj48Y2lyY2xlIGN4PSIxODkiIGN5PSIxOTAiIHI9IjE4NCIgZmlsbD0iI2ZlMiIvPjxwYXRoIGZpbGw9IiM4YmIiIGQ9Ik0yNTEgMjU2bC0zOC0zOGExNyAxNyAwIDAxMC0yNGw1Ni01NmMyLTIgMi02IDAtN2wtMjAtMjFhNSA1IDAgMDAtNyAwbC0xMyAxMi05LTggMTMtMTNhMTcgMTcgMCAwMTI0IDBsMjEgMjFjNyA3IDcgMTcgMCAyNGwtNTYgNTdhNSA1IDAgMDAwIDdsMzggMzh6Ii8+PHBhdGggZmlsbD0iI2Q1MSIgZD0iTTMwMCAyODhsLTggOGMtNCA0LTExIDQtMTYgMGwtNDYtNDZjLTUtNS01LTEyIDAtMTZsOC04YzQtNCAxMS00IDE1IDBsNDcgNDdjNCA0IDQgMTEgMCAxNXoiLz48cGF0aCBmaWxsPSIjYjMwIiBkPSJNMjg1IDI1OGw3IDdjNCA0IDQgMTEgMCAxNWwtOCA4Yy00IDQtMTEgNC0xNiAwbC02LTdjNCA1IDExIDUgMTUgMGw4LTdjNC01IDQtMTIgMC0xNnoiLz48cGF0aCBmaWxsPSIjYTMwIiBkPSJNMjkxIDI2NGw4IDhjNCA0IDQgMTEgMCAxNmwtOCA3Yy00IDUtMTEgNS0xNSAwbC05LThjNSA1IDEyIDUgMTYgMGw4LThjNC00IDQtMTEgMC0xNXoiLz48cGF0aCBmaWxsPSIjZTYyIiBkPSJNMjYwIDIzM2wtNC00Yy02LTYtMTctNi0yMyAwLTcgNy03IDE3IDAgMjRsNCA0Yy00LTUtNC0xMSAwLTE2bDgtOGM0LTQgMTEtNCAxNSAweiIvPjxwYXRoIGZpbGw9IiNiNDAiIGQ9Ik0yODQgMzA0Yy00IDAtOC0xLTExLTRsLTQ3LTQ3Yy02LTYtNi0xNiAwLTIybDgtOGM2LTYgMTYtNiAyMiAwbDQ3IDQ2YzYgNyA2IDE3IDAgMjNsLTggOGMtMyAzLTcgNC0xMSA0em0tMzktNzZjLTEgMC0zIDAtNCAybC04IDdjLTIgMy0yIDcgMCA5bDQ3IDQ3YTYgNiAwIDAwOSAwbDctOGMzLTIgMy02IDAtOWwtNDYtNDZjLTItMi0zLTItNS0yeiIvPjxwYXRoIGZpbGw9IiMxY2MiIGQ9Ik0xNTIgMTEzbDE4LTE4IDE4IDE4LTE4IDE4em0xLTM1bDE4LTE4IDE4IDE4LTE4IDE4em0tOTAgODlsMTgtMTggMTggMTgtMTggMTh6bTM1LTM2bDE4LTE4IDE4IDE4LTE4IDE4eiIvPjxwYXRoIGZpbGw9IiMxZGQiIGQ9Ik0xMzQgMTMxbDE4LTE4IDE4IDE4LTE4IDE4em0tMzUgMzZsMTgtMTggMTggMTgtMTggMTh6Ii8+PHBhdGggZmlsbD0iIzJiYiIgZD0iTTExNiAxNDlsMTgtMTggMTggMTgtMTggMTh6bTU0LTU0bDE4LTE4IDE4IDE4LTE4IDE4em0tODkgOTBsMTgtMTggMTggMTgtMTggMTh6bTEzOS04NWwyMyAyM2M0IDQgNCAxMSAwIDE2TDE0MiAyNDBjLTQgNC0xMSA0LTE1IDBsLTI0LTI0Yy00LTQtNC0xMSAwLTE1bDEwMS0xMDFjNS01IDEyLTUgMTYgMHoiLz48cGF0aCBmaWxsPSIjM2VlIiBkPSJNMTM0IDk1bDE4LTE4IDE4IDE4LTE4IDE4em0tNTQgMThsMTgtMTcgMTggMTctMTggMTh6bTU1LTUzbDE4LTE4IDE4IDE4LTE4IDE4em05MyA0OGwtOC04Yy00LTUtMTEtNS0xNiAwTDEwMyAyMDFjLTQgNC00IDExIDAgMTVsOCA4Yy00LTQtNC0xMSAwLTE1bDEwMS0xMDFjNS00IDEyLTQgMTYgMHoiLz48cGF0aCBmaWxsPSIjOWVlIiBkPSJNMjcgMTMxbDE4LTE4IDE4IDE4LTE4IDE4em01NC01M2wxOC0xOCAxOCAxOC0xOCAxOHoiLz48cGF0aCBmaWxsPSIjMGFhIiBkPSJNMjMwIDExMGwxMyAxM2M0IDQgNCAxMSAwIDE2TDE0MiAyNDBjLTQgNC0xMSA0LTE1IDBsLTEzLTEzYzQgNCAxMSA0IDE1IDBsMTAxLTEwMWM1LTUgNS0xMSAwLTE2eiIvPjxwYXRoIGZpbGw9IiMxYWIiIGQ9Ik0xMzQgMjQ4Yy00IDAtOC0yLTExLTVsLTIzLTIzYTE2IDE2IDAgMDEwLTIzTDIwMSA5NmExNiAxNiAwIDAxMjIgMGwyNCAyNGM2IDYgNiAxNiAwIDIyTDE0NiAyNDNjLTMgMy03IDUtMTIgNXptNzgtMTQ3bC00IDItMTAxIDEwMWE2IDYgMCAwMDAgOWwyMyAyM2E2IDYgMCAwMDkgMGwxMDEtMTAxYTYgNiAwIDAwMC05bC0yNC0yMy00LTJ6Ii8+PC9zdmc+ ) +This repository hosts several packages, listed below with examples. -This repository will host multiples packages, you can found them with examples below +## Documentation -Technical documentation is available [here](https://thib3113.github.io/node-crowdsec/) +- [Guides and usage documentation](https://github.com/thib3113/node-crowdsec/wiki): authentication, bouncer/watcher setup, performance notes and the glossary. +- [API reference](https://thib3113.github.io/node-crowdsec/): generated technical documentation (Typedoc). +- [AI agent setup guide](./docs/agent-setup-guide.md): configure the HTTP middleware with an AI agent. +- [llms.txt](./llms.txt): index for agents and LLM tooling. ## Features @@ -28,6 +34,8 @@ Technical documentation is available [here](https://thib3113.github.io/node-crow so a whole country block is never banned by accident. - **Watcher scenarios** : bundled scenarios (user-agent, ip-extraction...) to detect and alert on malicious requests, with a pluggable scenario system. +- **MCP server** : expose decisions, alerts and allowlists to an LLM through the + Model Context Protocol, read-only by default. ## Packages @@ -36,7 +44,6 @@ Technical documentation is available [here](https://thib3113.github.io/node-crow [![NPM version](https://img.shields.io/npm/v/crowdsec-client.svg)](https://www.npmjs.com/package/crowdsec-client) [![Downloads](https://img.shields.io/npm/dm/crowdsec-client.svg)](https://www.npmjs.com/package/crowdsec-client) [![License](https://img.shields.io/npm/l/crowdsec-client)](https://github.com/thib3113/node-crowdsec/blob/main/LICENSE) -[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=thib3113_node-crowdsec&metric=coverage)](https://sonarcloud.io/summary/new_code?id=thib3113_node-crowdsec) [![Package Quality](https://packagequality.com/shield/crowdsec-client.svg)](https://packagequality.com/#?package=crowdsec-client) [//]: # ([![crowdsec-client-snyk](https://snyk.io/advisor/npm-package/crowdsec-client/badge.svg)](https://snyk.io/advisor/npm-package/crowdsec-client)) @@ -47,33 +54,58 @@ Technical documentation is available [here](https://thib3113.github.io/node-crow [![NPM version](https://img.shields.io/npm/v/crowdsec-http-middleware.svg)](https://www.npmjs.com/package/crowdsec-http-middleware) [![Downloads](https://img.shields.io/npm/dm/crowdsec-http-middleware.svg)](https://www.npmjs.com/package/crowdsec-http-middleware) [![License](https://img.shields.io/npm/l/crowdsec-http-middleware)](https://github.com/thib3113/node-crowdsec/blob/main/LICENSE) -[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=thib3113_crowdsec-http-middleware&metric=coverage)](https://sonarcloud.io/summary/new_code?id=thib3113_node-crowdsec) [![Package Quality](https://packagequality.com/shield/crowdsec-http-middleware.svg)](https://packagequality.com/#?package=crowdsec-http-middleware) [//]: # ([![crowdsec-http-middleware-snyk](https://snyk.io/advisor/npm-package/crowdsec-http-middleware/badge.svg)](https://snyk.io/advisor/npm-package/crowdsec-http-middleware)) [![NPM](https://nodei.co/npm/crowdsec-http-middleware.png?months=3&height=1)](https://nodei.co/npm/crowdsec-http-middleware/) -an alpha http middleware that allow to include CrowdSec to protect your http serveur +An alpha HTTP middleware that allows you to use CrowdSec to protect your HTTP server. + +AI agent setup guide: [docs/agent-setup-guide.md](./docs/agent-setup-guide.md). ### [crowdsec-client-scenarios](./packages/crowdsec-client-scenarios) [![NPM version](https://img.shields.io/npm/v/crowdsec-client-scenarios.svg)](https://www.npmjs.com/package/crowdsec-client-scenarios) [![Downloads](https://img.shields.io/npm/dm/crowdsec-client-scenarios.svg)](https://www.npmjs.com/package/crowdsec-client-scenarios) [![License](https://img.shields.io/npm/l/crowdsec-client-scenarios)](https://github.com/thib3113/node-crowdsec/blob/main/LICENSE) -[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=thib3113_crowdsec-client-scenarios&metric=coverage)](https://sonarcloud.io/summary/new_code?id=thib3113_node-crowdsec) [![Package Quality](https://packagequality.com/shield/crowdsec-client-scenarios.svg)](https://packagequality.com/#?package=crowdsec-client-scenarios) [//]: # ([![crowdsec-client-scenarios-snyk](https://snyk.io/advisor/npm-package/crowdsec-client-scenarios/badge.svg)](https://snyk.io/advisor/npm-package/crowdsec-client-scenarios)) [![NPM](https://nodei.co/npm/crowdsec-client-scenarios.png?months=3&height=1)](https://nodei.co/npm/crowdsec-client-scenarios/) -Here are some optionals scenarios available to use with the middleware +Optional scenarios to use with the middleware. + +### [crowdsec-mcp](./packages/crowdsec-mcp) + +[![CI](https://github.com/thib3113/node-crowdsec/actions/workflows/CI.yml/badge.svg)](https://github.com/thib3113/node-crowdsec/actions/workflows/CI.yml) +[![License](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/thib3113/node-crowdsec/blob/main/LICENSE) + +A [Model Context Protocol](https://modelcontextprotocol.io) server for CrowdSec: exposes decisions, alerts and allowlists to an LLM, with read-only mode by default. ## Examples - - [express-bouncer](./examples/express-bouncer) : an example of use of the [crowdsec-client](./packages/crowdsec-client) to create an express bouncer - - [express-bouncer-cjs](./examples/express-bouncer-cjs) : the same as [express-bouncer](./examples/express-bouncer), but wrote in (common) js only - - [express-web-parser](./examples/express-web-parser) : a watcher example that will ban user checking the user-agent -This library is a Node.js client to talk with crowdsec rest API . Please note that Node.js >= 24 is required. +- [express-bouncer](./examples/express-bouncer) : an example using [crowdsec-client](./packages/crowdsec-client) to create an Express bouncer +- [express-bouncer-cjs](./examples/express-bouncer-cjs) : the same example written in CommonJS +- [express-web-parser](./examples/express-web-parser) : a watcher example that reports clients using a bad user-agent + +This repository provides a Node.js client for the CrowdSec REST API, an HTTP +middleware, detection scenarios and an MCP server. Node.js >= 24 is required. + +## Security + +Report vulnerabilities privately: see [SECURITY.md](./SECURITY.md). + +## Contributing + +See [CONTRIBUTING.md](./CONTRIBUTING.md). + +## Code of Conduct + +This project follows the [Contributor Covenant](./CODE_OF_CONDUCT.md). + +## License + +[MIT](./LICENSE) ## Thanks to diff --git a/renovate.json b/renovate.json index ef99ccfb6..1496307f7 100644 --- a/renovate.json +++ b/renovate.json @@ -1,12 +1,14 @@ { "extends": [ "config:js-lib", + "docker:pinDigests", ":separateMajorReleases", ":combinePatchMinorReleases", ":prNotPending", ":disableRateLimiting", ":ignoreModulesAndTests", ":pinDevDependencies", + "helpers:pinGitHubActionDigests", ":enableVulnerabilityAlerts", "group:monorepos", "group:recommended", @@ -17,6 +19,11 @@ "matchUpdateTypes": ["minor", "patch"], "matchCurrentVersion": "!/^0/", "automerge": true + }, + { + "matchManagers": ["dockerfile"], + "matchFileNames": ["packages/crowdsec-mcp/Dockerfile"], + "automerge": true } ], "dependencyDashboard": true, diff --git a/tsconfig.json b/tsconfig.json index 526c390c5..265f1970c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,15 +1,17 @@ { "$schema": "https://json.schemastore.org/tsconfig", - "extends": "@tsconfig/node-lts", "compilerOptions": { - "esModuleInterop": true, + "lib": ["es2024", "ESNext.Array", "ESNext.Collection", "ESNext.Iterator", "ESNext.Promise"], + "target": "es2024", "module": "NodeNext", "moduleResolution": "NodeNext", + "types": ["node"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, "sourceMap": true, "declaration": true, - "strict": true, - "isolatedModules": true, - "types": ["node"] + "isolatedModules": true }, "exclude": ["node_modules", "tests", "build"] }