From 9efd1719ac203fbf62441541ac269beefb0ef289 Mon Sep 17 00:00:00 2001 From: thibaut severac Date: Mon, 26 Oct 2026 14:01:01 +0100 Subject: [PATCH 01/24] feat(mcp): add CrowdSec MCP server Add a stdio and Streamable HTTP MCP server for CrowdSec with bouncer, watcher and mTLS authentication, read-only-by-default tools, server-side filtering and Markdown output. Tools cover status, decisions (list/ban/unban), alerts (list/get/delete) and allowlists (list/get/check). HTTP always requires a bearer token. Includes unit, HTTP contract, stdio end-to-end and opt-in Docker integration tests. Alert deletion is documented as local-only because of the CrowdSec LAPI restriction. --- .gitignore | 3 + packages/crowdsec-mcp/README.md | 353 ++++++++++ packages/crowdsec-mcp/package.json | 65 ++ packages/crowdsec-mcp/src/allowlists-port.ts | 24 + packages/crowdsec-mcp/src/client.ts | 77 +++ packages/crowdsec-mcp/src/config.ts | 268 ++++++++ packages/crowdsec-mcp/src/format/alerts.ts | 187 ++++++ .../crowdsec-mcp/src/format/allowlists.ts | 78 +++ packages/crowdsec-mcp/src/format/common.ts | 96 +++ packages/crowdsec-mcp/src/format/decisions.ts | 103 +++ packages/crowdsec-mcp/src/index.ts | 68 ++ packages/crowdsec-mcp/src/registry.ts | 96 +++ packages/crowdsec-mcp/src/tools/alerts.ts | 146 +++++ packages/crowdsec-mcp/src/tools/allowlists.ts | 102 +++ packages/crowdsec-mcp/src/tools/decisions.ts | 270 ++++++++ packages/crowdsec-mcp/src/tools/index.ts | 9 + packages/crowdsec-mcp/src/tools/system.ts | 36 + packages/crowdsec-mcp/src/transports/http.ts | 163 +++++ packages/crowdsec-mcp/src/types.ts | 44 ++ packages/crowdsec-mcp/src/utils.ts | 138 ++++ packages/crowdsec-mcp/src/version.ts | 2 + .../tests/allowlists-port.test.ts | 78 +++ packages/crowdsec-mcp/tests/config.test.ts | 130 ++++ packages/crowdsec-mcp/tests/e2e/stdio.test.ts | 99 +++ .../crowdsec-mcp/tests/e2e/watcher.test.ts | 146 +++++ packages/crowdsec-mcp/tests/format.test.ts | 49 ++ packages/crowdsec-mcp/tests/helpers.ts | 56 ++ .../tests/integration/live.test.ts | 209 ++++++ .../tests/registry-watcher.test.ts | 47 ++ packages/crowdsec-mcp/tests/registry.test.ts | 77 +++ packages/crowdsec-mcp/tests/system.test.ts | 16 + .../crowdsec-mcp/tests/tools/alerts.test.ts | 356 ++++++++++ .../tests/tools/allowlists.test.ts | 324 +++++++++ .../tests/tools/decisions.test.ts | 620 ++++++++++++++++++ .../tests/transports/http.test.ts | 206 ++++++ packages/crowdsec-mcp/tests/utils.test.ts | 55 ++ packages/crowdsec-mcp/tsconfig.json | 11 + packages/crowdsec-mcp/vitest.config.ts | 15 + pnpm-lock.yaml | 168 +++++ 39 files changed, 4990 insertions(+) create mode 100644 packages/crowdsec-mcp/README.md create mode 100644 packages/crowdsec-mcp/package.json create mode 100644 packages/crowdsec-mcp/src/allowlists-port.ts create mode 100644 packages/crowdsec-mcp/src/client.ts create mode 100644 packages/crowdsec-mcp/src/config.ts create mode 100644 packages/crowdsec-mcp/src/format/alerts.ts create mode 100644 packages/crowdsec-mcp/src/format/allowlists.ts create mode 100644 packages/crowdsec-mcp/src/format/common.ts create mode 100644 packages/crowdsec-mcp/src/format/decisions.ts create mode 100644 packages/crowdsec-mcp/src/index.ts create mode 100644 packages/crowdsec-mcp/src/registry.ts create mode 100644 packages/crowdsec-mcp/src/tools/alerts.ts create mode 100644 packages/crowdsec-mcp/src/tools/allowlists.ts create mode 100644 packages/crowdsec-mcp/src/tools/decisions.ts create mode 100644 packages/crowdsec-mcp/src/tools/index.ts create mode 100644 packages/crowdsec-mcp/src/tools/system.ts create mode 100644 packages/crowdsec-mcp/src/transports/http.ts create mode 100644 packages/crowdsec-mcp/src/types.ts create mode 100644 packages/crowdsec-mcp/src/utils.ts create mode 100644 packages/crowdsec-mcp/src/version.ts create mode 100644 packages/crowdsec-mcp/tests/allowlists-port.test.ts create mode 100644 packages/crowdsec-mcp/tests/config.test.ts create mode 100644 packages/crowdsec-mcp/tests/e2e/stdio.test.ts create mode 100644 packages/crowdsec-mcp/tests/e2e/watcher.test.ts create mode 100644 packages/crowdsec-mcp/tests/format.test.ts create mode 100644 packages/crowdsec-mcp/tests/helpers.ts create mode 100644 packages/crowdsec-mcp/tests/integration/live.test.ts create mode 100644 packages/crowdsec-mcp/tests/registry-watcher.test.ts create mode 100644 packages/crowdsec-mcp/tests/registry.test.ts create mode 100644 packages/crowdsec-mcp/tests/system.test.ts create mode 100644 packages/crowdsec-mcp/tests/tools/alerts.test.ts create mode 100644 packages/crowdsec-mcp/tests/tools/allowlists.test.ts create mode 100644 packages/crowdsec-mcp/tests/tools/decisions.test.ts create mode 100644 packages/crowdsec-mcp/tests/transports/http.test.ts create mode 100644 packages/crowdsec-mcp/tests/utils.test.ts create mode 100644 packages/crowdsec-mcp/tsconfig.json create mode 100644 packages/crowdsec-mcp/vitest.config.ts 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/packages/crowdsec-mcp/README.md b/packages/crowdsec-mcp/README.md new file mode 100644 index 000000000..4d1109f5f --- /dev/null +++ b/packages/crowdsec-mcp/README.md @@ -0,0 +1,353 @@ +# crowdsec-mcp + +**Give this to your AI agent.** Copy/paste this prompt: + +> Read https://raw.githubusercontent.com/thib3113/node-crowdsec/main/packages/crowdsec-mcp/README.md 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. + +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_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 | +| `CROWDSEC_MCP_READONLY` | Hide write tools | `true` | +| `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 + +Every environment variable above has a kebab-case argument equivalent: `--url`, `--auth`, `--api-key`, `--machine-id`, `--password`, `--tls-cert`, `--tls-key`, `--tls-ca`, `--insecure`, `--timeout`, `--transport`, `--http-host`, `--http-port`, `--http-token`, `--readonly`, `--write`, `--tools`, `--disable-tools`, `--max-results`, `--max-ban-duration`. + +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` | +| `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 | Delete alerts by ID, filters, or all with confirmation | + +### 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. + +### 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. + +## 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") +``` + +- IP and CIDR inputs are validated before sending anything. +- The value is checked against allowlists first. If it is allowlisted, the call is refused unless `force=true`. +- `duration` is limited by `CROWDSEC_MCP_MAX_BAN_DURATION` unless `force=true`. +- 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` and `delete_alerts` + +Both require at least one filter, an ID, or an explicit destructive confirmation: + +```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. + +> **Alert deletion is local-only in CrowdSec.** The LAPI only allows `delete_alerts` from the LAPI host. A remote watcher receives `403 access forbidden from this IP`. `unban_ip` (decision deletion) works remotely; `delete_alerts` is intended for an MCP running on the LAPI host. + +## 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. + +## 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. + +## Security + +- Read-only mode is enabled by default. Keep it that way unless the model really needs writes. +- Keep credentials in the environment, not in command-line arguments: arguments are visible in the process list. +- HTTP always requires a bearer token, including on `127.0.0.1`. The server refuses to start in HTTP mode without one. +- `ban_ip`, `unban_ip` and `delete_alerts` are destructive. Restrict them with `CROWDSEC_MCP_TOOLS` or keep read-only mode. +- `ban_ip` creates a real CrowdSec alert and decision. It may be forwarded to CAPI depending on your CrowdSec configuration. +- Use `CROWDSEC_MCP_TOOLS` to expose the smallest useful surface to the model. + +## 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. Run the MCP on that host, or use `cscli alerts delete` locally. `unban_ip` is not affected. | +| ` 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 cscli-style durations such as `30m`, `4h`, `7d`, or `1h30m`. | +| `Duration ... exceeds maximum allowed ...` | The duration is above `CROWDSEC_MCP_MAX_BAN_DURATION`. Lower it or use `force=true`. | + +## Output + +The MCP protocol itself is JSON-RPC, but tool results are Markdown text, not JSON dumps. Lists are tables with a summary and a truncation hint: + +```text +1 decision total + +| Type | Scope | Value | Origin | Scenario | Expires | Simulated | Alert ID | +| ---- | ----- | ------------ | ------ | -------- | --------- | --------- | -------- | +| ban | Ip | 203.0.113.10 | cscli | manual | in 3h 12m | no | 42 | +``` + +Dates are shown in ISO form with a relative expiry. Large lists are truncated to `CROWDSEC_MCP_MAX_RESULTS` with an explicit hint to narrow the filters or increase `limit`. + +## 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. An opt-in integration test also runs against a real CrowdSec LAPI in Docker. + +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 +``` + +Set `CROWDSEC_INTEGRATION_LOCAL_DELETE=1` only when the MCP and LAPI share localhost and alert deletion is expected to succeed. Clean up with: + +```bash +docker rm -f crowdsec-mcp-test && docker volume rm crowdsec-mcp-data +``` + +## License + +MIT diff --git a/packages/crowdsec-mcp/package.json b/packages/crowdsec-mcp/package.json new file mode 100644 index 000000000..1c4aa9a69 --- /dev/null +++ b/packages/crowdsec-mcp/package.json @@ -0,0 +1,65 @@ +{ + "name": "crowdsec-mcp", + "version": "0.1.0", + "description": "Model Context Protocol server for CrowdSec", + "type": "module", + "main": "./dist/index.js", + "bin": { + "crowdsec-mcp": "./dist/index.js" + }, + "exports": { + ".": "./dist/index.js" + }, + "files": [ + "dist", + "README.md" + ], + "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": "https://github.com/thib3113/node-crowdsec.git" + }, + "keywords": [ + "crowdsec", + "mcp", + "model-context-protocol", + "security" + ], + "license": "MIT", + "engines": { + "node": ">=24" + }, + "scripts": { + "prepack": "npm run build", + "build": "rimraf dist && tsc -p tsconfig.json", + "dev": "tsc -p tsconfig.json --watch", + "start": "node dist/index.js", + "test": "vitest run", + "test:ci": "vitest run", + "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", + "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..5af6a1756 --- /dev/null +++ b/packages/crowdsec-mcp/src/allowlists-port.ts @@ -0,0 +1,24 @@ +import type { WatcherClient } from 'crowdsec-client'; +import type { AllowlistsPort } from './types.js'; + +const unavailable = async (): Promise => { + throw 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..30087cf27 --- /dev/null +++ b/packages/crowdsec-mcp/src/client.ts @@ -0,0 +1,77 @@ +import { readFile } from 'node:fs/promises'; +import { BouncerClient, WatcherClient } from 'crowdsec-client'; +import type { Config } from './config.js'; +import type { AppContext } from './types.js'; +import { createAllowlistsPort, unavailableAllowlistsPort } from './allowlists-port.js'; + +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 => { + if (!loginPromise) { + 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 + }; +} diff --git a/packages/crowdsec-mcp/src/config.ts b/packages/crowdsec-mcp/src/config.ts new file mode 100644 index 000000000..2da18dc60 --- /dev/null +++ b/packages/crowdsec-mcp/src/config.ts @@ -0,0 +1,268 @@ +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; + readonly: 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' }, + 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 !== ''); +} + +export function loadConfig(values: CliValues, env: NodeJS.ProcessEnv = process.env): Config { + 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}'`); + } + + 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 tlsCert = firstDefined(values['tls-cert'] as string | undefined, env.CROWDSEC_TLS_CERT); + const tlsKey = firstDefined(values['tls-key'] as string | undefined, env.CROWDSEC_TLS_KEY); + const tlsCa = firstDefined(values['tls-ca'] as string | undefined, env.CROWDSEC_TLS_CA); + + const requestedAuth = firstDefined(values.auth as string | undefined, env.CROWDSEC_AUTH)?.toLowerCase(); + if (requestedAuth !== undefined && requestedAuth !== 'bouncer' && requestedAuth !== 'watcher') { + throw new Error(`CROWDSEC_AUTH must be 'bouncer' or 'watcher', got '${requestedAuth}'`); + } + + let auth: AuthMode; + if (requestedAuth) { + auth = requestedAuth; + } else if (apiKey) { + auth = 'bouncer'; + } else if (machineId || password) { + auth = 'watcher'; + } else { + throw new Error('unable to detect authentication mode: set CROWDSEC_API_KEY or CROWDSEC_MACHINE_ID/CROWDSEC_PASSWORD'); + } + + const tlsValues = [tlsCert, tlsKey, tlsCa]; + const tlsCount = tlsValues.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'); + } + + const tls = tlsCount === 3 ? { cert: tlsCert!, key: tlsKey!, ca: tlsCa! } : undefined; + + if (auth === 'bouncer' && !apiKey && !tls) { + throw new Error('bouncer authentication requires CROWDSEC_API_KEY or TLS certificates'); + } + + if (auth === 'watcher' && !tls && (!machineId || !password)) { + throw new Error('watcher authentication requires CROWDSEC_MACHINE_ID and CROWDSEC_PASSWORD (or TLS certificates)'); + } + + 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}'`); + } + + const httpToken = firstDefined(values['http-token'] as string | undefined, env.CROWDSEC_MCP_HTTP_TOKEN); + if (transport === 'http' && !httpToken) { + throw new Error('HTTP transport requires CROWDSEC_MCP_HTTP_TOKEN (or --http-token)'); + } + + if (values.readonly === true && values.write === true) { + throw new Error('--readonly and --write cannot be used together'); + } + + const readonlyDefault = readBoolean(env.CROWDSEC_MCP_READONLY, 'CROWDSEC_MCP_READONLY', true); + const readonly = values.write === true ? false : values.readonly === true ? true : readonlyDefault; + + 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 + }, + readonly, + 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 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 + --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/format/alerts.ts b/packages/crowdsec-mcp/src/format/alerts.ts new file mode 100644 index 000000000..fb0f87df2 --- /dev/null +++ b/packages/crowdsec-mcp/src/format/alerts.ts @@ -0,0 +1,187 @@ +import type { APITypes } from 'crowdsec-client'; +import { escapeCell, formatDate, markdownTable, moreHint, plural, relativeTime, truncateRows } from './common.js'; + +function formatDecisionsSummary(decisions?: APITypes.Alert['decisions']): string { + if (!decisions || decisions.length === 0) { + return '—'; + } + + const counts = new Map(); + + for (const d of decisions) { + 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): string { + const { rows, hidden } = truncateRows(alerts, maxResults); + + if (rows.length === 0) { + return '_No alerts found._'; + } + + const headers = ['ID', 'Scope', 'Value', 'Reason', 'Country', 'AS', 'Decisions', 'Created at', 'Kind']; + const tableRows = rows.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(rows.length, 'alert'); + const hint = moreHint(hidden, 'list_alerts'); + + 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 until = decision.until ? `${formatDate(decision.until)} (${relativeTime(decision.until)})` : '—'; + const simulated = decision.simulated === true ? 'yes' : 'no'; + + return [ + 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((v) => String(v)).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'}`, + `- **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 + if (alert.decisions && alert.decisions.length > 0) { + const decisionHeaders = ['Type', 'Scope', 'Value', 'Origin', 'Duration', 'Until', 'Simulated']; + const decisionRows = alert.decisions.map(formatDecisionRow); + lines.push('', '### Decisions', '', markdownTable(decisionHeaders, decisionRows)); + } + + // Meta + lines.push(formatMeta(alert.meta)); + + // Events + lines.push(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..3e5514ea0 --- /dev/null +++ b/packages/crowdsec-mcp/src/format/allowlists.ts @@ -0,0 +1,78 @@ +import type { APITypes } from 'crowdsec-client'; +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 || !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 lines: string[] = []; + let allowlistedCount = 0; + + for (const target of targets) { + const result = results.find((r) => r.target === target); + const allowlists = result?.allowlists ?? []; + if (allowlists.length > 0) { + allowlistedCount++; + lines.push(`- ${escapeCell(target)}: allowlisted by ${escapeCell(allowlists.join(', '))}`); + } else { + lines.push(`- ${escapeCell(target)}: not allowlisted`); + } + } + + const summary = `${plural(allowlistedCount, 'target')} of ${plural(targets.length, 'target')} allowlisted.`; + 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..a20ad0f35 --- /dev/null +++ b/packages/crowdsec-mcp/src/format/common.ts @@ -0,0 +1,96 @@ +import { formatDuration } from '../utils.js'; + +export function escapeCell(value: unknown): string { + if (value === undefined || value === null || value === '') { + return '—'; + } + + return String(value).replace(/\|/g, '\\|').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); + } + + return date.getTime() <= now ? 'expired' : relativeTime(date, now); +} + +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..a577aa4ac --- /dev/null +++ b/packages/crowdsec-mcp/src/format/decisions.ts @@ -0,0 +1,103 @@ +import type { APITypes } from 'crowdsec-client'; +import { markdownTable, escapeCell, expiry, plural, truncateRows } from './common.js'; + +export interface DecisionRow { + type: string; + scope: string; + value: string; + origin: string; + scenario: string; + expires: string; + simulated: boolean; + alertId?: number; +} + +export function formatDecisionList(alerts: APITypes.GetAlertsResponse, maxResults: number): string { + const rows: DecisionRow[] = []; + const seen = new Set(); + + for (const alert of alerts) { + if (!alert.decisions || alert.decisions.length === 0) { + continue; + } + + for (const decision of alert.decisions) { + const key = `${decision.simulated ?? false}:${decision.type}:${decision.scope}:${decision.value}`; + 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: decision.until ?? 'never', + simulated: decision.simulated ?? false, + alertId: alert.id + }); + } + } + + const { rows: displayRows, hidden } = truncateRows(rows, maxResults); + + const table = markdownTable( + ['Type', 'Scope', 'Value', 'Origin', 'Scenario', 'Expires', 'Simulated', 'Alert 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 ?? '—') + ]) + ); + + const summary = `\n${plural(rows.length, 'decision')} total`; + return table + summary + (hidden > 0 ? `\n_${hidden} more result(s) hidden. Increase \`limit\` or narrow the filters._` : ''); +} + +export function formatBouncerDecisionList(decisions: APITypes.GetDecisionsResponse, maxResults: number): string { + const { rows: displayRows, hidden } = truncateRows(decisions, maxResults); + + const table = markdownTable( + ['ID', 'Type', 'Scope', 'Value', 'Origin', 'Scenario', 'Expires', 'Simulated'], + displayRows.map((d) => [ + escapeCell(d.id), + escapeCell(d.type), + escapeCell(d.scope), + escapeCell(d.value), + escapeCell(d.origin), + escapeCell(d.scenario), + escapeCell(expiry(d.until)), + escapeCell(d.simulated ? 'yes' : 'no') + ]) + ); + + const summary = `\n${plural(decisions.length, 'decision')} total`; + return table + summary + (hidden > 0 ? `\n_${hidden} more result(s) hidden. Increase \`limit\` or narrow the filters._` : ''); +} + +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 { + return `**${nbDeleted} decision(s) deleted**`; +} diff --git a/packages/crowdsec-mcp/src/index.ts b/packages/crowdsec-mcp/src/index.ts new file mode 100644 index 000000000..fa2faf764 --- /dev/null +++ b/packages/crowdsec-mcp/src/index.ts @@ -0,0 +1,68 @@ +#!/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 } from './client.js'; +import { createServer, selectTools } from './registry.js'; +import { allTools } from './tools/index.js'; +import { startHttpServer } from './transports/http.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); + const context = await createContext(config); + const tools = selectTools(allTools(), context); + const server = createServer(tools, context); + + let stopping = false; + const shutdown = async (signal: string): Promise => { + if (stopping) { + return; + } + + stopping = true; + log('received %s, shutting down', signal); + try { + await context.stop(); + await server.close(); + } finally { + process.exit(0); + } + }; + + process.once('SIGINT', () => { + void shutdown('SIGINT'); + }); + process.once('SIGTERM', () => { + void shutdown('SIGTERM'); + }); + + if (config.transport === 'http') { + await startHttpServer(server, config); + log('HTTP transport listening on %s:%d', config.http.host, config.http.port); + return; + } + + const transport = new StdioServerTransport(); + await server.connect(transport); + log('stdio transport ready'); +} + +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/registry.ts b/packages/crowdsec-mcp/src/registry.ts new file mode 100644 index 000000000..6d12cf8ef --- /dev/null +++ b/packages/crowdsec-mcp/src/registry.ts @@ -0,0 +1,96 @@ +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 { NAME, VERSION } from './version.js'; + +const log = debug('crowdsec-mcp:registry'); + +export function textResult(text: string): CallToolResult { + return { content: [{ type: 'text', text }] }; +} + +export function errorResult(error: unknown): CallToolResult { + const message = error instanceof Error ? error.message : String(error); + log('tool error: %s', message); + 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 (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 { + const server = new McpServer({ name: NAME, version: VERSION }); + ctx.activeTools = tools.map((tool) => tool.name); + + for (const tool of tools) { + const handler = async (input: unknown): Promise => { + try { + await ctx.ensureReady(); + const result = await tool.handler((input ?? {}) as Record, 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 as never, + annotations: tool.annotations + }, + handler as never + ); + } + + log('registered %d tool(s): %s', tools.length, tools.map((tool) => tool.name).join(', ')); + 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..2032641f6 --- /dev/null +++ b/packages/crowdsec-mcp/src/tools/alerts.ts @@ -0,0 +1,146 @@ +import { z } from 'zod'; +import type { ToolDefinition } from '../types.js'; +import { formatAlertDetail, formatAlertList } from '../format/alerts.js'; +import type { APITypes } from 'crowdsec-client'; + +const ListAlertsInput = z.object({ + ip: z.string().optional().describe('Filter by IP address'), + range: z.string().optional().describe('Filter by CIDR range'), + 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, 30m)'), + until: z.string().optional().describe('Filter alerts older than this duration (e.g., 4h, 30m)'), + simulated: z.boolean().optional().describe('Include simulated alerts'), + 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') +}); + +const GetAlertInput = z.object({ + id: z.union([z.string(), z.number()]).describe('Alert ID') +}); + +const DeleteAlertsInput = z.object({ + id: z.union([z.string(), z.number()]).optional().describe('Delete a specific alert by ID'), + ip: z.string().optional().describe('Filter by IP address'), + range: z.string().optional().describe('Filter by CIDR range'), + 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'), + all: z.boolean().optional().describe('Delete all matching alerts (requires confirm=true)'), + confirm: z.boolean().optional().describe('Confirm deletion when using all=true') +}); + +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) => { + await ctx.ensureReady(); + + const { ip, range, scope, value, scenario, type, origin, kind, since, until, simulated, has_active_decision, all, limit } = + input as z.infer; + + const query: APITypes.Alerts.SearchAlerts.RequestQuery & { kind?: string } = { + ip, + range, + scope, + value, + scenario, + decision_type: type, + origin, + kind, + since, + until, + simulated, + has_active_decision, + limit: all === true ? 0 : (limit ?? ctx.config.maxResults) + }; + + const alerts = await ctx.watcher!.Alerts.search(query); + + return formatAlertList(alerts, ctx.config.maxResults); + } + }, + { + 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) => { + await ctx.ensureReady(); + + const { id } = input as z.infer; + const alert = await ctx.watcher!.Alerts.getById(String(id)); + + return formatAlertDetail(alert); + } + }, + { + name: 'delete_alerts', + title: 'Delete alerts', + description: + 'Delete alerts by ID or filters. Requires at least one filter or all=true with confirm=true. CrowdSec only allows alert deletion from the LAPI host; remote watchers receive 403.', + inputSchema: DeleteAlertsInput, + kind: 'write', + requires: ['watcher'], + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false + }, + handler: async (input, ctx) => { + await ctx.ensureReady(); + + const { id, ip, range, scope, value, scenario, all, confirm } = input as z.infer; + + if (all === true) { + if (confirm !== true) { + throw new Error('Deleting all alerts requires confirm=true'); + } + } + + let result: APITypes.Alerts.DeleteAlerts.ResponseBody; + + if (id !== undefined) { + result = await ctx.watcher!.Alerts.deleteById(String(id)); + } else { + const hasFilter = ip || range || scope || value || scenario; + if (!hasFilter && all !== true) { + throw new Error('At least one filter (ip, range, scope, value, scenario) or all=true is required'); + } + + const query: APITypes.Alerts.DeleteAlerts.RequestQuery = { + ip, + range, + scope, + value, + scenario + }; + + result = await ctx.watcher!.Alerts.delete(query); + } + + return `Deleted ${result.nbDeleted ?? 0} alert(s).`; + } + } +]; diff --git a/packages/crowdsec-mcp/src/tools/allowlists.ts b/packages/crowdsec-mcp/src/tools/allowlists.ts new file mode 100644 index 000000000..4a5063cb8 --- /dev/null +++ b/packages/crowdsec-mcp/src/tools/allowlists.ts @@ -0,0 +1,102 @@ +import { z } from 'zod'; +import type { ToolDefinition } from '../types.js'; +import { formatAllowlistList, formatAllowlistDetail, formatAllowlistCheck } from '../format/allowlists.js'; + +const ListAllowlistsInput = z.object({ + limit: z.number().int().positive().optional().describe('Maximum number of allowlists to display') +}); + +const GetAllowlistInput = z.object({ + name: z.string().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') +}); + +const CheckAllowlistInput = z + .object({ + value: z.string().min(1).optional().describe('Single value (IP, range, username, etc.) to check'), + values: z.array(z.string().min(1)).optional().describe('Multiple values to check in bulk') + }) + .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' + }); + +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) => { + await ctx.ensureReady(); + + 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) => { + await ctx.ensureReady(); + + const { name, with_content: withContent, limit } = GetAllowlistInput.parse(input); + + const allowlist = await ctx.allowlists.get(name, withContent); + + return formatAllowlistDetail(allowlist, withContent, limit ?? ctx.config.maxResults); + } + }, + { + name: 'check_allowlist', + title: 'Check allowlist membership', + description: + 'Check if one or more values (IPs, ranges, usernames, etc.) are present in any allowlist. 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) => { + await ctx.ensureReady(); + + const { value, values } = CheckAllowlistInput.parse(input); + + const targets: string[] = values?.length ? values : value ? [value] : []; + + if (targets.length === 0) { + throw new Error('At least one of `value` or `values` must be provided and non-empty'); + } + + if (targets.length === 1) { + const result = await ctx.allowlists.check(targets[0]); + return formatAllowlistCheck( + targets, + result.allowlisted ? [{ target: targets[0], allowlists: [result.reason || 'allowlist'] }] : [] + ); + } + + const bulkResult = await ctx.allowlists.bulkCheck(targets); + return formatAllowlistCheck(targets, 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..ba6a27d2b --- /dev/null +++ b/packages/crowdsec-mcp/src/tools/decisions.ts @@ -0,0 +1,270 @@ +import { z } from 'zod'; +import type { APITypes } from 'crowdsec-client'; +import { parseDuration, formatDuration, normalizeScope } from '../utils.js'; +import { formatDecisionList, formatBouncerDecisionList, formatBanResult, formatUnbanResult } from '../format/decisions.js'; +import type { ToolDefinition } from '../types.js'; + +const ListDecisionsInput = z.object({ + ip: z.string().optional().describe('IP address to filter by (shorthand for scope=Ip)'), + 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)'), + until: z.string().optional().describe('Show alerts older than this duration (e.g., 24h, 7d)'), + contains: z.boolean().optional().default(false).describe('Search for decisions containing the value or contained within 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') +}); + +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.string().optional().default('4h').describe('Duration of the ban (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') +}); + +const UnbanIpInput = z.object({ + id: z.union([z.string(), z.number()]).optional().describe('Decision ID to delete'), + ip: z.string().optional().describe('IP address to filter by (shorthand for scope=Ip)'), + 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.)'), + 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') +}); + +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) => { + await ctx.ensureReady(); + + const { ip, range, scope, value, type, origin, scenario, since, until, contains, no_simulated, all, limit } = + ListDecisionsInput.parse(input); + + const effectiveLimit = all ? 0 : (limit ?? ctx.config.maxResults); + + if (ctx.mode === 'watcher' && ctx.watcher) { + const query = { + ip, + range, + scope, + value, + scenario, + decision_type: type, + origin, + since, + until, + simulated: no_simulated ? false : undefined, + contains, + has_active_decision: true, + limit: effectiveLimit + } as APITypes.Alerts.SearchAlerts.RequestQuery & { contains?: boolean }; + + const alerts = await ctx.watcher.Alerts.search(query); + + return formatDecisionList(alerts, ctx.config.maxResults); + } + + if (ctx.mode === 'bouncer' && ctx.bouncer) { + const decisions = await ctx.bouncer.Decisions.search({ + ip, + range, + scope, + value, + type, + origins: origin, + scenarios_containing: scenario, + contains + }); + + return formatBouncerDecisionList(decisions, ctx.config.maxResults); + } + + throw new Error('No client available for the current mode'); + } + }, + { + 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) => { + await ctx.ensureReady(); + + if (!ctx.watcher) { + throw new Error('Watcher client not available'); + } + + const { ip, range, scope: inputScope, value, duration, type, reason, force } = BanIpInput.parse(input); + + // Validate that we have enough to identify the target + if (!ip && !range && !(inputScope && value)) { + throw new Error('Must provide ip, range, or scope+value'); + } + + // cscli gives --ip precedence over --range, and both over --scope/--value. + let scope: string; + let targetValue: string; + + if (ip) { + scope = normalizeScope(undefined, ip); + targetValue = ip; + } else if (range) { + scope = normalizeScope(undefined, undefined, range); + targetValue = range; + } else { + scope = normalizeScope(inputScope); + targetValue = value ?? ''; + } + + if (!targetValue) { + throw new Error('Value is required for the given scope'); + } + + // Parse and validate duration + let durationSeconds: number; + try { + durationSeconds = parseDuration(duration); + } catch (e) { + throw new Error(`Invalid duration: ${(e as Error).message}`); + } + + // Check allowlist first, like cscli, then enforce the local duration guard. + if (!force && (scope === 'Ip' || scope === 'Range')) { + const checkResult = await ctx.allowlists.check(targetValue); + if (checkResult.allowlisted && checkResult.reason) { + throw new Error( + `${targetValue} is allowlisted by item ${checkResult.reason}, use force=true to add the decision anyway` + ); + } + } + + // Check max duration unless forced + if (!force && durationSeconds > ctx.config.maxBanDurationSeconds) { + const maxDuration = formatDuration(ctx.config.maxBanDurationSeconds); + throw new Error(`Duration ${duration} exceeds maximum allowed ${maxDuration}. Use force=true to override.`); + } + + // Build the alert payload exactly as cscli does + const now = new Date().toISOString(); + const defaultReason = `manual '${type}' from 'crowdsec-mcp'`; + const alertReason = reason ?? defaultReason; + + const alert = { + capacity: 0, + decisions: [ + { + origin: 'cscli', + type, + scope, + value: targetValue, + duration, + scenario: alertReason + } + ], + events: [], + events_count: 1, + leakspeed: '0', + message: alertReason, + scenario: alertReason, + 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' + }; + + const alertIds = await ctx.watcher.Alerts.pushAlerts([alert]); + + return formatBanResult(alertIds, { scope, value: targetValue, type, duration, reason: alertReason }); + } + }, + { + name: 'unban_ip', + title: 'Unban IP', + description: 'Delete decisions by ID or filters. Requires watcher mode. Mirrors `cscli decisions delete`.', + inputSchema: UnbanIpInput, + kind: 'write', + requires: ['watcher'], + annotations: { readOnlyHint: false, destructiveHint: true }, + handler: async (input, ctx) => { + await ctx.ensureReady(); + + if (!ctx.watcher) { + throw new Error('Watcher client not available'); + } + + const { id, ip, range, scope, value, type, scenario, all, confirm } = UnbanIpInput.parse(input); + + if (all) { + if (!confirm) { + throw new Error('Deleting all decisions requires confirm=true'); + } + + if (id !== undefined || ip || range || scope || value || type || scenario) { + throw new Error( + 'Cannot combine all=true with id or filters; use all=true with confirm=true alone to delete everything' + ); + } + + const result = await ctx.watcher.Decisions.delete({}); + return formatUnbanResult(result.nbDeleted ?? 0); + } + + if (id !== undefined) { + await ctx.watcher.Decisions.deleteById(String(id)); + return formatUnbanResult(1); + } + + // Require at least one filter + const hasFilter = ip || range || scope || value || type || scenario; + if (!hasFilter) { + throw new Error('Must provide id, at least one filter, or all=true with confirm=true'); + } + + const query = { + ip, + range, + scope, + value, + type, + scenario + }; + + const result = await ctx.watcher.Decisions.delete(query); + return formatUnbanResult(result.nbDeleted ?? 0); + } + } +]; 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..a41dffd7c --- /dev/null +++ b/packages/crowdsec-mcp/src/tools/system.ts @@ -0,0 +1,36 @@ +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: async (_input, ctx) => { + await ctx.ensureReady(); + + const auth = ctx.mode === 'watcher' ? `watcher${ctx.config.machineId ? ` (${ctx.config.machineId})` : ''}` : 'bouncer'; + const lines = [ + 'CrowdSec MCP is connected.', + '', + bullets([ + `URL: ${redactedUrl(ctx.config.url)}`, + `Authentication: ${auth}`, + `Capabilities: read=yes, write=${ctx.capabilities.write ? 'yes' : 'no'}, 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}`, + `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..7aca64d0c --- /dev/null +++ b/packages/crowdsec-mcp/src/transports/http.ts @@ -0,0 +1,163 @@ +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'; + +const MAX_BODY_SIZE = 4 * 1024 * 1024; + +const UNAUTHORIZED_BODY = JSON.stringify({ + jsonrpc: '2.0', + error: { code: -32001, message: 'Unauthorized' }, + id: null +}); + +const NOT_FOUND_BODY = JSON.stringify({ + jsonrpc: '2.0', + error: { code: -32000, message: 'Not found' }, + id: null +}); + +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; + + 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 (tooLarge) { + reject(new Error('Payload too large')); + return; + } + resolve(Buffer.concat(chunks).toString('utf-8')); + }); + + req.on('error', reject); + }); +} + +function sendJson(res: http.ServerResponse, status: number, body: string, extraHeaders?: Record): void { + res.writeHead(status, { + 'Content-Type': 'application/json', + ...extraHeaders + }); + res.end(body); +} + +export async function startHttpServer(server: McpServer, config: Config): Promise { + const token = config.http.token; + if (!token) { + throw new Error('HTTP transport requires a bearer token (config.http.token)'); + } + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true + }); + + await server.connect(transport); + + const httpServer = http.createServer(async (req, res) => { + const { method, url } = req; + + // Auth first: every request (including unknown routes/methods) requires a valid bearer token. + if (!isAuthorizedHeader(req.headers.authorization, token)) { + sendJson(res, 401, UNAUTHORIZED_BODY, { 'WWW-Authenticate': 'Bearer' }); + return; + } + + const pathname = url?.split('?')[0]; + if (pathname !== '/mcp') { + sendJson(res, 404, NOT_FOUND_BODY); + return; + } + + if (method === 'POST') { + let raw: string; + try { + raw = await readBody(req); + } catch { + sendJson( + res, + 413, + JSON.stringify({ + jsonrpc: '2.0', + error: { code: -32000, message: 'Payload too large' }, + id: null + }) + ); + return; + } + + let parsedBody: unknown; + try { + parsedBody = JSON.parse(raw); + } catch { + sendJson( + res, + 400, + JSON.stringify({ + jsonrpc: '2.0', + error: { code: -32700, message: 'Parse error' }, + id: null + }) + ); + return; + } + + await transport.handleRequest(req, res, parsedBody); + return; + } + + if (method === 'GET' || method === 'DELETE') { + await transport.handleRequest(req, res); + return; + } + + res.writeHead(405).end(); + }); + + 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..7443e0894 --- /dev/null +++ b/packages/crowdsec-mcp/src/types.ts @@ -0,0 +1,44 @@ +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 interface ToolDefinition { + name: string; + title: string; + description: string; + inputSchema: ZodType; + kind: 'read' | 'write'; + requires?: ToolRequirement[]; + annotations?: ToolAnnotations; + handler: (input: Record, ctx: AppContext) => Promise; +} diff --git a/packages/crowdsec-mcp/src/utils.ts b/packages/crowdsec-mcp/src/utils.ts new file mode 100644 index 000000000..c19b64df1 --- /dev/null +++ b/packages/crowdsec-mcp/src/utils.ts @@ -0,0 +1,138 @@ +import { isIP } from 'node:net'; + +const DURATION_PART = /(\d+(?:\.\d+)?)(d|h|m|s)/g; + +/** + * Parse a cscli-like duration (4h, 30m, 7d, 1h30m) into seconds. + */ +export function parseDuration(input: string): number { + const value = input.trim(); + if (value === '') { + throw new Error('duration must not be empty'); + } + + if (/^\d+$/.test(value)) { + return Number(value); + } + + let total = 0; + let consumed = 0; + for (const match of value.matchAll(DURATION_PART)) { + const amount = Number(match[1]); + const unit = match[2]; + consumed += match[0].length; + switch (unit) { + case 'd': + total += amount * 86400; + break; + case 'h': + total += amount * 3600; + break; + case 'm': + total += amount * 60; + break; + case 's': + total += amount; + break; + } + } + + if (consumed !== value.length || total <= 0) { + throw new Error(`invalid duration '${input}' (use for example 30m, 4h or 7d)`); + } + + return total; +} + +export function formatDuration(seconds: number): string { + if (seconds <= 0) { + return '0s'; + } + + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 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'; +} + +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); +} + +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..45b7dbe8c --- /dev/null +++ b/packages/crowdsec-mcp/src/version.ts @@ -0,0 +1,2 @@ +export const VERSION = '0.1.0'; +export const NAME = 'crowdsec-mcp'; 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/config.test.ts b/packages/crowdsec-mcp/tests/config.test.ts new file mode 100644 index 000000000..06b9ff6d6 --- /dev/null +++ b/packages/crowdsec-mcp/tests/config.test.ts @@ -0,0 +1,130 @@ +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('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('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('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/e2e/stdio.test.ts b/packages/crowdsec-mcp/tests/e2e/stdio.test.ts new file mode 100644 index 000000000..cb76b6011 --- /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 total'); + }); +}); 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..00ca68873 --- /dev/null +++ b/packages/crowdsec-mcp/tests/e2e/watcher.test.ts @@ -0,0 +1,146 @@ +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 === '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('list_alerts'); + expect(names).toContain('get_alert'); + expect(names).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..a89a031b6 --- /dev/null +++ b/packages/crowdsec-mcp/tests/format.test.ts @@ -0,0 +1,49 @@ +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(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('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..a7e2547aa --- /dev/null +++ b/packages/crowdsec-mcp/tests/helpers.ts @@ -0,0 +1,56 @@ +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, + 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: async () => [], + get: async () => ({}), + check: async () => ({ allowlisted: false }), + bulkCheck: async () => ({ results: [] }) + }, + config: makeConfig(), + ensureReady: async () => { + readyCalls++; + }, + stop: async () => undefined, + ...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..b1a70c38e --- /dev/null +++ b/packages/crowdsec-mcp/tests/integration/live.test.ts @@ -0,0 +1,209 @@ +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 alerts = textOf(await client.callTool({ name: 'list_alerts', arguments: {} })); + const alertId = alertIdFor(alerts, 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); + + const deletedResult = await client.callTool({ + name: 'delete_alerts', + arguments: { id: alertId } + }); + const deleted = textOf(deletedResult); + + if (process.env.CROWDSEC_INTEGRATION_LOCAL_DELETE === '1') { + expect(deletedResult.isError).not.toBe(true); + expect(deleted).toContain('Deleted'); + } else if (deletedResult.isError) { + expect(deleted).toMatch(/access forbidden from this IP|only.*local/i); + } else { + expect(deleted).toContain('Deleted'); + } + } 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..083b5a051 --- /dev/null +++ b/packages/crowdsec-mcp/tests/registry-watcher.test.ts @@ -0,0 +1,47 @@ +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', + 'ban_ip', + 'unban_ip', + 'list_alerts', + 'get_alert', + 'delete_alerts', + '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(); + }); +}); diff --git a/packages/crowdsec-mcp/tests/registry.test.ts b/packages/crowdsec-mcp/tests/registry.test.ts new file mode 100644 index 000000000..cafa1dcd1 --- /dev/null +++ b/packages/crowdsec-mcp/tests/registry.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { 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/); + }); +}); diff --git a/packages/crowdsec-mcp/tests/system.test.ts b/packages/crowdsec-mcp/tests/system.test.ts new file mode 100644 index 000000000..a667be3a0 --- /dev/null +++ b/packages/crowdsec-mcp/tests/system.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { statusTool } from '../src/tools/system.js'; +import { 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'); + }); +}); 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..ca4afccbc --- /dev/null +++ b/packages/crowdsec-mcp/tests/tools/alerts.test.ts @@ -0,0 +1,356 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { alertTools } from '../../src/tools/alerts.js'; + +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: '2024-01-15T10:30:00Z', + 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: '2024-01-15T14:30:00Z', + simulated: false + }, + { + type: 'captcha', + scope: 'Ip', + value: '192.168.1.100', + origin: 'crowdsec', + duration: '1h', + until: '2024-01-15T11:30:00Z', + 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', + simulated: true, + 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', + simulated: true, + has_active_decision: true, + limit: 10 + }) + ); + }); + + 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 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 include kind filter in search', async () => { + const tool = alertTools.find((t) => t.name === 'list_alerts')!; + await tool.handler({ kind: 'waf' }, mockCtx); + + expect(mockWatcher.Alerts.search).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'waf' + }) + ); + }); + + 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 show moreHint when results exceed maxResults', async () => { + const manyAlerts = Array.from({ length: 30 }, (_, i) => ({ + id: i + 1, + created_at: '2024-01-15T10:30:00Z', + 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'); + }); + }); + + 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', 'Duration', '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: '2024-01-15T10:30:00Z', + 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 handle alert without meta', async () => { + const watcher = createMockWatcher([ + { + id: 3, + created_at: '2024-01-15T10:30:00Z', + 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 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) or all=true is required' + ); + }); + + it('should throw when only scenario provided without id', async () => { + const tool = alertTools.find((t) => t.name === 'delete_alerts')!; + const result = await tool.handler({ scenario: 'ssh-bf' }, 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..d143bf063 --- /dev/null +++ b/packages/crowdsec-mcp/tests/tools/allowlists.test.ts @@ -0,0 +1,324 @@ +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 show only info and _No items._ 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('_No items._'); + 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 check()', async () => { + mockAllowlists.check.mockResolvedValue({ + allowlisted: true, + reason: 'test-allowlist: Local network' + } satisfies APITypes.CheckAllowlistResponse); + + const result = (await findTool('check_allowlist').handler({ value: '192.168.1.1' }, mockCtx)) as string; + + expect(mockAllowlists.check).toHaveBeenCalledWith('192.168.1.1'); + expect(mockAllowlists.bulkCheck).not.toHaveBeenCalled(); + expect(result).toContain('192.168.1.1: allowlisted by test-allowlist: Local network'); + expect(result).toContain('1 target of 1 target allowlisted.'); + }); + + it('should fall back to a generic label when a single check has no reason', async () => { + mockAllowlists.check.mockResolvedValue({ allowlisted: true, reason: undefined } satisfies APITypes.CheckAllowlistResponse); + + const result = (await findTool('check_allowlist').handler({ value: '192.168.1.1' }, mockCtx)) as string; + + expect(result).toContain('192.168.1.1: allowlisted by allowlist'); + expect(result).toContain('1 target of 1 target allowlisted.'); + }); + + it('should check a single value that is not allowlisted', async () => { + mockAllowlists.check.mockResolvedValue({ allowlisted: false, reason: undefined } satisfies APITypes.CheckAllowlistResponse); + + 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 allowlisted.'); + }); + + 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: allowlisted by allowlist1: Private, allowlist2: RFC1918'); + expect(result).toContain('8.8.8.8: not allowlisted'); + expect(result).toContain('2 targets of 3 targets allowlisted.'); + }); + + 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 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..69a280954 --- /dev/null +++ b/packages/crowdsec-mcp/tests/tools/decisions.test.ts @@ -0,0 +1,620 @@ +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 + }); + + 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: '2025-01-01T00:00:00Z', + 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: 10, + has_active_decision: true + }) + ); + expect(result).toContain('ban'); + expect(result).toContain('1.2.3.4'); + }); + + 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: '2025-01-01T00:00:00Z', + 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'); + }); + + 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: '2025-01-01T00:00:00Z', + simulated: false + }, + { + id: 2, + type: 'ban', + scope: 'Ip', + value: '1.2.3.4', + origin: 'cscli', + scenario: 'test', + duration: '4h', + until: '2025-01-01T00:00:00Z', + simulated: false + } + ] + }, + { + id: 2, + decisions: [ + { + id: 3, + type: 'ban', + scope: 'Ip', + value: '1.2.3.4', + origin: 'cscli', + scenario: 'test', + duration: '4h', + until: '2025-01-01T00:00:00Z', + simulated: false + } + ] + } + ]; + + const ctx = createMockContext(); + ctx.watcher!.Alerts.search.mockResolvedValue(mockAlerts); + + const tool = findTool('list_decisions'); + const result = await tool!.handler({}, ctx as any); + + // Should only show 1 decision due to deduplication + expect(result).toContain('1 decision total'); + expect(result.match(/1\.2\.3\.4/g)?.length).toBe(1); + }); + + 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: '2025-01-01T00:00:00Z', + 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({ all: true }, ctx as any); + + expect(result).toContain('50 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('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 give ip precedence over range and value like cscli', 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', range: '10.0.0.0/8', value: '5.6.7.8', scope: 'Range' }, ctx as any); + + expect(ctx.allowlists.check).toHaveBeenCalledWith('1.2.3.4'); + expect(ctx.watcher!.Alerts.pushAlerts).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + decisions: expect.arrayContaining([ + expect.objectContaining({ + scope: 'Ip', + value: '1.2.3.4' + }) + ]) + }) + ]) + ); + }); + + 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 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 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('should use default duration 4h when not 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('4h'); + }); + + it('should use default type ban when not 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('ban'); + }); + + it('should use default reason when not 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("manual 'ban' from 'crowdsec-mcp'"); + }); + + 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 decisions when all=true and confirm=true', async () => { + const ctx = createMockContext(); + ctx.watcher!.Decisions.delete.mockResolvedValue({ nbDeleted: '5' }); + const tool = findTool('unban_ip'); + + const result = await tool!.handler({ all: true, confirm: true }, ctx as any); + + expect(result).toContain('5 decision(s) deleted'); + expect(ctx.watcher!.Decisions.delete).toHaveBeenCalledWith({}); + }); + + 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', async () => { + const ctx = createMockContext(); + ctx.watcher!.Decisions.deleteById.mockResolvedValue({}); + 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'); + }); + + 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 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 pass all filter types to delete', async () => { + const ctx = createMockContext(); + ctx.watcher!.Decisions.delete.mockResolvedValue({ nbDeleted: '1' }); + const tool = findTool('unban_ip'); + + await tool!.handler( + { + ip: '1.2.3.4', + range: '192.168.1.0/24', + scope: 'Ip', + value: '1.2.3.4', + type: 'ban', + scenario: 'test' + }, + ctx as any + ); + + expect(ctx.watcher!.Decisions.delete).toHaveBeenCalledWith( + expect.objectContaining({ + ip: '1.2.3.4', + range: '192.168.1.0/24', + scope: 'Ip', + value: '1.2.3.4', + type: 'ban', + scenario: 'test' + }) + ); + }); + }); +}); 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..7f16c7053 --- /dev/null +++ b/packages/crowdsec-mcp/tests/transports/http.test.ts @@ -0,0 +1,206 @@ +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 { isAuthorizedHeader, startHttpServer } from '../../src/transports/http.js'; +import { makeConfig } from '../helpers.js'; + +const TOKEN = 'test-secret-token'; + +function makeServer(): McpServer { + return new McpServer({ name: 'test', version: '1.0.0' }); +} + +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' } + } + }); +} + +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('throws when no token is configured (before listening)', async () => { + await expect(startHttpServer(makeServer(), makeConfig({ http: { host: '127.0.0.1', port: 0, token: undefined } }))).rejects.toThrow( + /bearer token/i + ); + }); + + it('returns 401 without a token, even on unknown routes', async () => { + const mcp = makeServer(); + const server = await startHttpServer(mcp, makeConfig({ transport: 'http', http: { host: '127.0.0.1', port: 0, token: TOKEN } })); + 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); + await mcp.close(); + } + }); + + it('returns 401 with a wrong token', async () => { + const mcp = makeServer(); + const server = await startHttpServer(mcp, makeConfig({ transport: 'http', http: { host: '127.0.0.1', port: 0, token: TOKEN } })); + 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); + await mcp.close(); + } + }); + + it('accepts a valid token: initialize POST is not 401 and unknown route is 404', async () => { + const mcp = makeServer(); + const server = await startHttpServer(mcp, makeConfig({ transport: 'http', http: { host: '127.0.0.1', port: 0, token: TOKEN } })); + 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); + await mcp.close(); + } + }); + + it('returns 405 for unsupported methods after auth', async () => { + const mcp = makeServer(); + const server = await startHttpServer(mcp, makeConfig({ transport: 'http', http: { host: '127.0.0.1', port: 0, token: TOKEN } })); + try { + const put = await request(portOf(server), { method: 'PUT', path: '/mcp', headers: authHeaders(TOKEN) }); + expect(put.status).toBe(405); + } finally { + await closeServer(server); + await mcp.close(); + } + }); + + it('rejects invalid JSON with 400 after auth', async () => { + const mcp = makeServer(); + const server = await startHttpServer(mcp, makeConfig({ transport: 'http', http: { host: '127.0.0.1', port: 0, token: TOKEN } })); + 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); + await mcp.close(); + } + }); +}); diff --git a/packages/crowdsec-mcp/tests/utils.test.ts b/packages/crowdsec-mcp/tests/utils.test.ts new file mode 100644 index 000000000..5a16ae722 --- /dev/null +++ b/packages/crowdsec-mcp/tests/utils.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { formatDuration, isValidCidr, isValidIp, normalizeScope, parseDuration, splitList } 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('90')).toBe(90); + }); + + it('rejects invalid durations', () => { + expect(() => parseDuration('')).toThrow(); + expect(() => parseDuration('abc')).toThrow(); + expect(() => parseDuration('4 hours')).toThrow(); + expect(() => parseDuration('0h')).toThrow(); + }); + + 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('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/pnpm-lock.yaml b/pnpm-lock.yaml index 437423180..d508c7ecb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -417,6 +417,40 @@ 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) + 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: '@apidevtools/json-schema-ref-parser@14.0.1': @@ -691,6 +725,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 +773,16 @@ packages: '@keyv/serialize@1.1.1': resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} + '@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==} @@ -1027,6 +1077,9 @@ packages: ajv: optional: true + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} @@ -1161,6 +1214,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 +1230,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==} @@ -1369,10 +1430,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'} @@ -1505,6 +1580,10 @@ packages: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} + 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 +1650,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 +1670,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==} @@ -1804,6 +1889,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'} @@ -1876,6 +1965,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==} @@ -2331,6 +2424,14 @@ 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: '@apidevtools/json-schema-ref-parser@14.0.1': @@ -2527,6 +2628,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,6 +2670,28 @@ snapshots: '@keyv/serialize@1.1.1': {} + '@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': {} @@ -2822,6 +2949,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 @@ -2979,12 +3110,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: @@ -3188,8 +3326,22 @@ 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 @@ -3343,6 +3495,8 @@ snapshots: dependencies: function-bind: 1.1.2 + hono@4.13.14: {} + hookified@1.15.1: {} hookified@2.2.0: {} @@ -3397,6 +3551,8 @@ snapshots: jiti@2.7.0: {} + jose@6.2.13: {} + js-tokens@10.0.0: {} js-yaml@4.3.1: @@ -3411,6 +3567,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: @@ -3596,6 +3754,8 @@ snapshots: should: 13.2.3 yaml: 1.10.3 + object-assign@4.1.1: {} + object-inspect@1.13.4: {} obug@3.0.0: {} @@ -3656,6 +3816,8 @@ snapshots: picomatch@4.0.7: {} + pkce-challenge@5.0.1: {} + pkg-types@2.3.1: dependencies: confbox: 0.2.4 @@ -4104,3 +4266,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: {} From 6e751f823567a1f2f3ec5d1bc28134c9421bd0ae Mon Sep 17 00:00:00 2001 From: thibaut severac Date: Tue, 27 Oct 2026 01:19:10 +0100 Subject: [PATCH 02/24] feat(mcp): add Docker deployment and harden MCP tools - add Dockerfile, compose sidecar and GHCR workflow - reject invalid durations/scope values before the LAPI call - honor limit/all and report exact hidden counts in list tools - trim allowlist names and clarify CIDR matches - refresh licenses, package metadata, docs and QA TODO --- .github/CODEOWNERS | 1 + .github/FUNDING.yml | 14 +- .github/ISSUE_TEMPLATE/bug_report.yml | 66 +++ .github/ISSUE_TEMPLATE/config.yml | 8 + .github/ISSUE_TEMPLATE/feature_request.yml | 36 ++ .github/PULL_REQUEST_TEMPLATE.md | 23 + .../build_and_test/action.yml | 7 +- .github/release.yml | 24 ++ .github/workflows/CI.yml | 61 ++- .github/workflows/actions-security.yml | 33 ++ .github/workflows/context7-refresh.yml | 23 + .github/workflows/docker-mcp.yml | 95 ++++ .github/workflows/documentation.yml | 11 +- .github/workflows/mcp-scan.yml | 57 +++ .github/workflows/release.yml | 19 +- .github/workflows/scorecard.yml | 43 ++ CITATION.cff | 18 + CODE_OF_CONDUCT.md | 8 +- CONTRIBUTING.md | 67 +++ SECURITY.md | 28 +- SUPPORT.md | 30 ++ context7.json | 14 + dev/raw/package.json | 15 +- docs/adr/README.md | 5 + docs/agent-setup-guide.md | 4 +- examples/express-bouncer-cjs/package.json | 18 +- examples/express-bouncer-cjs/readme.md | 43 +- examples/express-bouncer/package.json | 17 +- examples/express-bouncer/readme.md | 44 +- examples/express-web-parser/package.json | 19 +- examples/express-web-parser/readme.md | 43 +- llms.txt | 42 ++ package.json | 24 +- packages/crowdsec-client-scenarios/.npmignore | 11 - packages/crowdsec-client-scenarios/LICENSE | 21 + .../crowdsec-client-scenarios/package.json | 19 +- packages/crowdsec-client-scenarios/readme.md | 96 +++-- packages/crowdsec-client/.npmignore | 11 - packages/crowdsec-client/LICENSE | 21 + packages/crowdsec-client/README.md | 53 ++- packages/crowdsec-client/package.json | 17 +- packages/crowdsec-http-middleware/.npmignore | 9 - packages/crowdsec-http-middleware/LICENSE | 21 + .../crowdsec-http-middleware/package.json | 20 +- packages/crowdsec-http-middleware/readme.md | 52 +-- packages/crowdsec-mcp/Dockerfile | 54 +++ packages/crowdsec-mcp/Dockerfile.dockerignore | 14 + packages/crowdsec-mcp/LICENSE | 21 + packages/crowdsec-mcp/README.md | 152 +++++-- packages/crowdsec-mcp/TODO.md | 122 ++++++ packages/crowdsec-mcp/compose.yaml | 50 +++ packages/crowdsec-mcp/package.json | 21 +- packages/crowdsec-mcp/src/client.ts | 136 ++++++ packages/crowdsec-mcp/src/config.ts | 36 +- packages/crowdsec-mcp/src/format/alerts.ts | 58 ++- .../crowdsec-mcp/src/format/allowlists.ts | 15 +- packages/crowdsec-mcp/src/format/common.ts | 5 + packages/crowdsec-mcp/src/format/decisions.ts | 127 ++++-- packages/crowdsec-mcp/src/http-error.ts | 10 + packages/crowdsec-mcp/src/index.ts | 32 +- packages/crowdsec-mcp/src/registry.ts | 49 ++- packages/crowdsec-mcp/src/tools/alerts.ts | 298 ++++++++++--- packages/crowdsec-mcp/src/tools/allowlists.ts | 68 ++- packages/crowdsec-mcp/src/tools/decisions.ts | 406 ++++++++++++++---- packages/crowdsec-mcp/src/tools/system.ts | 2 +- packages/crowdsec-mcp/src/transports/http.ts | 114 ++++- packages/crowdsec-mcp/src/types.ts | 2 + packages/crowdsec-mcp/src/utils.ts | 171 +++++++- packages/crowdsec-mcp/tests/client.test.ts | 70 +++ packages/crowdsec-mcp/tests/config.test.ts | 53 +++ packages/crowdsec-mcp/tests/e2e/stdio.test.ts | 2 +- .../crowdsec-mcp/tests/e2e/watcher.test.ts | 3 +- packages/crowdsec-mcp/tests/format.test.ts | 1 + packages/crowdsec-mcp/tests/helpers.ts | 3 + .../tests/integration/live.test.ts | 18 - .../tests/registry-watcher.test.ts | 45 +- packages/crowdsec-mcp/tests/registry.test.ts | 26 +- .../crowdsec-mcp/tests/tools/alerts.test.ts | 294 ++++++++++++- .../tests/tools/allowlists.test.ts | 69 ++- .../tests/tools/decisions.test.ts | 395 ++++++++++++++--- .../tests/transports/http.test.ts | 112 ++++- packages/crowdsec-mcp/tests/utils.test.ts | 37 +- pnpm-lock.yaml | 346 ++++++++++++++- readme.md | 51 ++- renovate.json | 7 + 85 files changed, 4206 insertions(+), 600 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md rename .github/{workflows => actions}/build_and_test/action.yml (95%) create mode 100644 .github/release.yml create mode 100644 .github/workflows/actions-security.yml create mode 100644 .github/workflows/context7-refresh.yml create mode 100644 .github/workflows/docker-mcp.yml create mode 100644 .github/workflows/mcp-scan.yml create mode 100644 .github/workflows/scorecard.yml create mode 100644 CITATION.cff create mode 100644 CONTRIBUTING.md create mode 100644 SUPPORT.md create mode 100644 context7.json create mode 100644 docs/adr/README.md create mode 100644 llms.txt delete mode 100644 packages/crowdsec-client-scenarios/.npmignore create mode 100644 packages/crowdsec-client-scenarios/LICENSE delete mode 100644 packages/crowdsec-client/.npmignore create mode 100644 packages/crowdsec-client/LICENSE delete mode 100644 packages/crowdsec-http-middleware/.npmignore create mode 100644 packages/crowdsec-http-middleware/LICENSE create mode 100644 packages/crowdsec-mcp/Dockerfile create mode 100644 packages/crowdsec-mcp/Dockerfile.dockerignore create mode 100644 packages/crowdsec-mcp/LICENSE create mode 100644 packages/crowdsec-mcp/TODO.md create mode 100644 packages/crowdsec-mcp/compose.yaml create mode 100644 packages/crowdsec-mcp/src/http-error.ts create mode 100644 packages/crowdsec-mcp/tests/client.test.ts 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..523ef8596 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +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: Documentation + url: https://thib3113.github.io/node-crowdsec/ + about: Read the API 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 95% rename from .github/workflows/build_and_test/action.yml rename to .github/actions/build_and_test/action.yml index c2127d214..3ebf94b37 100644 --- a/.github/workflows/build_and_test/action.yml +++ b/.github/actions/build_and_test/action.yml @@ -33,13 +33,13 @@ runs: 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 name: Setup pnpm cache @@ -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 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/workflows/CI.yml b/.github/workflows/CI.yml index e61f3fb4d..e1dc33101 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -34,13 +34,13 @@ jobs: 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 name: Setup pnpm cache @@ -51,7 +51,7 @@ jobs: ${{ runner.os }}-pnpm-store- - name: Install dependencies - run: pnpm install + run: pnpm install --frozen-lockfile list-packages: runs-on: ubuntu-latest @@ -67,13 +67,50 @@ jobs: 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@v7 + + - name: Install Node.js + uses: actions/setup-node@v7 + with: + node-version: lts/* + + - uses: pnpm/action-setup@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: @@ -92,7 +129,7 @@ jobs: steps: - name: Checkout uses: actions/checkout@v7 - - uses: ./.github/workflows/build_and_test + - uses: ./.github/actions/build_and_test with: node-version: ${{ matrix.node-version }} package_path: ${{ matrix.package.path }} @@ -120,7 +157,7 @@ jobs: uses: actions/checkout@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,7 +173,7 @@ jobs: security-events: write runs-on: ubuntu-latest steps: - - uses: actions/checkout@master + - uses: actions/checkout@v7 - name: Install Node.js uses: actions/setup-node@v7 @@ -147,13 +184,13 @@ jobs: 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 name: Setup pnpm cache @@ -164,7 +201,7 @@ 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 env: diff --git a/.github/workflows/actions-security.yml b/.github/workflows/actions-security.yml new file mode 100644 index 000000000..a907017d6 --- /dev/null +++ b/.github/workflows/actions-security.yml @@ -0,0 +1,33 @@ +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@v7 + + - name: Lint workflows + uses: raven-actions/actionlint@v2.2.0 + + zizmor: + runs-on: ubuntu-latest + continue-on-error: true + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Install uv + uses: astral-sh/setup-uv@v10.3.2 + + - name: Audit workflows + run: uvx zizmor --format=github .github/workflows diff --git a/.github/workflows/context7-refresh.yml b/.github/workflows/context7-refresh.yml new file mode 100644 index 000000000..eeee4be9c --- /dev/null +++ b/.github/workflows/context7-refresh.yml @@ -0,0 +1,23 @@ +name: Refresh Context7 Docs + +# The library must first be added at https://context7.com/add-library +# and CONTEXT7_API_KEY must be set as a repository secret. +# To refresh automatically after that, add: +# push: +# branches: [ main ] +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + refresh: + runs-on: ubuntu-latest + steps: + - name: Trigger Context7 refresh + run: | + curl --fail --silent --show-error -X POST https://context7.com/api/v1/refresh \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${{ secrets.CONTEXT7_API_KEY }}" \ + -d '{"libraryName": "/${{ github.repository }}"}' diff --git a/.github/workflows/docker-mcp.yml b/.github/workflows/docker-mcp.yml new file mode 100644 index 000000000..fd22a3de7 --- /dev/null +++ b/.github/workflows/docker-mcp.yml @@ -0,0 +1,95 @@ +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@v7 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@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@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' + run: docker run --rm "${{ fromJSON(steps.meta.outputs.json).tags[0] }}" --version diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index 0d252b653..5cf9683fe 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -29,13 +29,13 @@ jobs: 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 name: Setup pnpm cache @@ -47,14 +47,17 @@ 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 with: diff --git a/.github/workflows/mcp-scan.yml b/.github/workflows/mcp-scan.yml new file mode 100644 index 000000000..7ece235a9 --- /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@v7 + + - name: Install Node.js + uses: actions/setup-node@v7 + with: + node-version: lts/* + + - uses: pnpm/action-setup@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@v10.3.2 + + - name: Scan MCP server + run: uvx snyk-agent-scan@latest 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..7eb14f0a0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,13 +31,13 @@ jobs: 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 name: Setup pnpm cache @@ -48,7 +48,7 @@ jobs: ${{ runner.os }}-pnpm-store- - name: Install dependencies - run: pnpm install + run: pnpm install --frozen-lockfile list-packages: runs-on: ubuntu-latest @@ -64,11 +64,12 @@ jobs: 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: @@ -98,13 +99,13 @@ jobs: 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 name: Setup pnpm cache @@ -115,7 +116,7 @@ jobs: ${{ runner.os }}-pnpm-store- - name: Install dependencies - run: pnpm install + run: pnpm install --frozen-lockfile shell: bash - name: Compare npm with local (versions) diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 000000000..7386e8375 --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,43 @@ +name: Scorecard supply-chain security + +on: + branch_protection_rule: + schedule: + - cron: '30 1 * * 6' + push: + branches: [ main ] + +permissions: read-all + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-latest + permissions: + security-events: write + id-token: write + + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Run OpenSSF Scorecard + uses: ossf/scorecard-action@v2.5.0 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + + - name: Upload SARIF + uses: actions/upload-artifact@v7 + with: + name: scorecard-results + path: results.sarif + retention-days: 5 + + - name: Upload to code scanning + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: results.sarif 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..a6e7a7dfe --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,30 @@ +# Support + +## Documentation + +- API documentation: +- 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/context7.json b/context7.json new file mode 100644 index 000000000..4f879fa7e --- /dev/null +++ b/context7.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://context7.com/schema/context7.json", + "projectTitle": "node-crowdsec", + "description": "Node.js CrowdSec client, HTTP middleware, detection scenarios and MCP server", + "folders": ["packages", "docs", "examples"], + "excludeFolders": ["**/node_modules", "**/lib", "**/dist", "**/coverage", "**/tests", "**/bench"], + "excludeFiles": [], + "rules": [ + "Use Node.js 24 or newer.", + "Use pnpm for development in this monorepo.", + "Use crowdsec-http-middleware for HTTP servers and crowdsec-client for direct LAPI access.", + "Bouncer API keys and watcher machine credentials are different; use the authentication mode that matches your credentials." + ] +} 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/README.md b/docs/adr/README.md new file mode 100644 index 000000000..beebb919e --- /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..740e10d71 100644 --- a/docs/agent-setup-guide.md +++ b/docs/agent-setup-guide.md @@ -63,7 +63,7 @@ whether that fits : 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) | @@ -226,7 +226,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 | 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..9ef887372 --- /dev/null +++ b/llms.txt @@ -0,0 +1,42 @@ +# 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 + +- [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..7d5aa8f33 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,33 @@ { - "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", @@ -18,6 +37,7 @@ "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..d1c5e516d 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 [documentation](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/.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..0f5c5cfc7 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 [documentation](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; enable autoRenew to renew it automatically autoRenew: true, }, //use this option if you use a self signed ssl certificate @@ -143,10 +144,16 @@ 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 [documentation](https://thib3113.github.io/node-crowdsec/). ## 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..5234a9ea9 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,16 +46,21 @@ }, "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": { 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/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..dd6109dbd 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,21 +12,20 @@ [![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.** @@ -83,19 +83,10 @@ 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 - -install it - -``` -npm i crowdsec-http-middleware -``` - -and then read the documentation in the [wiki](https://github.com/thib3113/node-crowdsec/wiki) - +## Default scenarios -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 passing an empty `scenarios` configuration: ``` npm i crowdsec-http-middleware crowdsec-client-scenarios @@ -105,7 +96,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 +155,7 @@ with optional scenarios). ```` First the global options + ````typescript const middlewareOptions: ICrowdSecHTTPMiddlewareOptions = { // this is the url of the crowdsec instances @@ -184,12 +176,14 @@ 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', @@ -206,9 +200,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 +269,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-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 index 4d1109f5f..bef65fb29 100644 --- a/packages/crowdsec-mcp/README.md +++ b/packages/crowdsec-mcp/README.md @@ -1,8 +1,26 @@ # 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 https://raw.githubusercontent.com/thib3113/node-crowdsec/main/packages/crowdsec-mcp/README.md 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`. +> 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. @@ -79,10 +97,13 @@ If both a bouncer key and machine credentials are present, set `CROWDSEC_AUTH` e | `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 | | `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` | In passthrough, allow `delete_alerts` 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` | @@ -92,7 +113,7 @@ Secrets should stay in the environment. Command-line arguments are available for ### CLI arguments -Every environment variable above has a kebab-case argument equivalent: `--url`, `--auth`, `--api-key`, `--machine-id`, `--password`, `--tls-cert`, `--tls-key`, `--tls-ca`, `--insecure`, `--timeout`, `--transport`, `--http-host`, `--http-port`, `--http-token`, `--readonly`, `--write`, `--tools`, `--disable-tools`, `--max-results`, `--max-ban-duration`. +Every environment variable above has a kebab-case argument equivalent: `--url`, `--auth`, `--api-key`, `--machine-id`, `--password`, `--tls-cert`, `--tls-key`, `--tls-ca`, `--insecure`, `--timeout`, `--transport`, `--http-host`, `--http-port`, `--http-token`, `--readonly`, `--write`, `--passthrough`, `--enable-alert-delete`, `--allow-alert-delete-without-token`, `--tools`, `--disable-tools`, `--max-results`, `--max-ban-duration`. Use `--help` for the full list. @@ -104,6 +125,7 @@ Read-only tools are always available according to the authentication mode. Write | --- | --- | --- | --- | | `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 | @@ -111,7 +133,7 @@ Read-only tools are always available according to the authentication mode. Write | `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 | Delete alerts 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 @@ -122,6 +144,10 @@ 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 @@ -145,15 +171,19 @@ 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") ``` -- IP and CIDR inputs are validated before sending anything. +- 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` must include a unit (`30m`, `4h`, `7d`, `1h30m`). Bare numbers such as `300` are rejected before the LAPI call. +- `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` and `delete_alerts` +### `unban_ip` -Both require at least one filter, an ID, or an explicit destructive confirmation: +`unban_ip` deletes decisions and works from anywhere: ```text unban_ip(ip="203.0.113.10") @@ -161,9 +191,28 @@ unban_ip(id="42") unban_ip(all=true, confirm=true) ``` -`all=true` without `confirm=true` is rejected. +`all=true` without `confirm=true` is rejected. `scope` is supported for `Ip`/`Range` and for non-IP scopes (`Country`, `AS`, `Session`, `Username`) via an active-decision lookup; matching decisions are deleted by ID. + +### `delete_alerts` (opt-in) -> **Alert deletion is local-only in CrowdSec.** The LAPI only allows `delete_alerts` from the LAPI host. A remote watcher receives `403 access forbidden from this IP`. `unban_ip` (decision deletion) works remotely; `delete_alerts` is intended for an MCP running on the LAPI host. +`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. + +In passthrough mode, `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 from exposing the local-only destructive path. + +`all=true` cannot be combined with filters. `ip` and `range` accept a CIDR and perform containment. Filters `id`, `origin`, `type`, `since`, `until` and `has_active_decision` are resolved through an alert search followed by deletion by ID, because the LAPI DELETE endpoint does not support all of them directly. 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 @@ -173,7 +222,7 @@ Allowlists are read/check only. The CrowdSec local API exposes no create, update - `get_allowlist` - `check_allowlist` -`ban_ip` automatically calls `check_allowlist` for IP and range scopes, like cscli. +`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 @@ -253,11 +302,74 @@ 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 - Read-only mode is enabled by default. Keep it that way unless the model really needs writes. - Keep credentials in the environment, not in command-line arguments: arguments are visible in the process list. -- HTTP always requires a bearer token, including on `127.0.0.1`. The server refuses to start in HTTP mode without one. +- Outside passthrough mode, HTTP always requires a bearer token, including on `127.0.0.1`. The server refuses to start in HTTP mode without one. +- In passthrough mode, the service token is optional; the LAPI credential is still required on every request. Use TLS and never log request headers. - `ban_ip`, `unban_ip` and `delete_alerts` are destructive. Restrict them with `CROWDSEC_MCP_TOOLS` or keep read-only mode. - `ban_ip` creates a real CrowdSec alert and decision. It may be forwarded to CAPI depending on your CrowdSec configuration. - Use `CROWDSEC_MCP_TOOLS` to expose the smallest useful surface to the model. @@ -267,26 +379,12 @@ The MCP endpoint is `/mcp`. Do not expose this server publicly without TLS and a | 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. Run the MCP on that host, or use `cscli alerts delete` locally. `unban_ip` is not affected. | +| `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 cscli-style durations such as `30m`, `4h`, `7d`, or `1h30m`. | +| `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`. | -## Output - -The MCP protocol itself is JSON-RPC, but tool results are Markdown text, not JSON dumps. Lists are tables with a summary and a truncation hint: - -```text -1 decision total - -| Type | Scope | Value | Origin | Scenario | Expires | Simulated | Alert ID | -| ---- | ----- | ------------ | ------ | -------- | --------- | --------- | -------- | -| ban | Ip | 203.0.113.10 | cscli | manual | in 3h 12m | no | 42 | -``` - -Dates are shown in ISO form with a relative expiry. Large lists are truncated to `CROWDSEC_MCP_MAX_RESULTS` with an explicit hint to narrow the filters or increase `limit`. - ## Development ```bash @@ -342,7 +440,7 @@ CROWDSEC_PASSWORD= \ pnpm --filter crowdsec-mcp exec vitest run tests/integration/live.test.ts ``` -Set `CROWDSEC_INTEGRATION_LOCAL_DELETE=1` only when the MCP and LAPI share localhost and alert deletion is expected to succeed. Clean up with: +`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 diff --git a/packages/crowdsec-mcp/TODO.md b/packages/crowdsec-mcp/TODO.md new file mode 100644 index 000000000..df6f878c9 --- /dev/null +++ b/packages/crowdsec-mcp/TODO.md @@ -0,0 +1,122 @@ +# TODO + +## Deployment notes + +### Do not modify the CrowdSec image + +Keep `crowdsecurity/crowdsec` as-is. The MCP is a separate container (or a host +process). Do not add Node.js or the MCP into the CrowdSec image. + +### Recommended local deployment: sidecar in CrowdSec's network namespace + +`delete_alerts` is local-only on the CrowdSec LAPI. To make it work, the MCP +must reach the LAPI from `127.0.0.1`. The simplest way is to share CrowdSec's +network namespace: + +```yaml +services: + crowdsec: + image: crowdsecurity/crowdsec:latest + ports: + - "8080:8080" + - "3939:3939" # published by crowdsec because the MCP shares its netns + volumes: + - crowdsec-data:/var/lib/crowdsec/data + + crowdsec-mcp: + image: crowdsec-mcp:local # build/publish the MCP image separately + 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_HTTP_TOKEN: change-me # optional service gate + CROWDSEC_MCP_READONLY: "false" + CROWDSEC_MCP_ENABLE_ALERT_DELETE: "true" + +volumes: + crowdsec-data: +``` + +Important: + +- `ports` must be declared on the `crowdsec` service. Docker does not allow + `ports` on a service using `network_mode: "service:..."`. +- The MCP listens on `0.0.0.0:3939` inside the shared netns. +- `CROWDSEC_URL` is `http://127.0.0.1:8080` inside the shared netns. +- In passthrough mode, clients send one of: + - `X-CrowdSec-Api-Key: ` (read decisions only) + - `X-CrowdSec-Token: ` (watcher tools) +- If `CROWDSEC_MCP_HTTP_TOKEN` is set, requests must also send + `Authorization: Bearer `. +- Without a service token, `delete_alerts` is hidden unless + `CROWDSEC_MCP_ALLOW_ALERT_DELETE_WITHOUT_TOKEN=true`. +- `delete_alerts` remains local-only even with passthrough. + +### Other deployment modes + +| Mode | `delete_alerts` | CrowdSec image modified | +| --- | --- | --- | +| Sidecar sharing CrowdSec's netns | yes | no | +| MCP on the host, CrowdSec installed natively | yes | no image | +| MCP on a Docker bridge (`http://crowdsec:8080`) | no (403) | no | +| MCP and CrowdSec both with `network_mode: host` | yes | no | + +If `delete_alerts` is not needed, a regular Docker bridge network is enough. +`unban_ip` works remotely. + +## Docker image work (later) + +- [x] Add a `Dockerfile` for the MCP package. +- [x] Use a multi-stage build with `node:26-alpine`. +- [x] Build `crowdsec-client` and `crowdsec-mcp` in the build stage. +- [x] Produce a small runtime image with only production dependencies. +- [x] Run as a non-root user. +- [x] Add a `HEALTHCHECK` (or document an external one). +- [x] Add a production `compose.yaml` based on the sidecar pattern above. +- [x] Add a `docker-mcp.yml` workflow publishing + `ghcr.io/thib3113/crowdsec-mcp` (on dependency changes and tags). +- [x] Keep the CrowdSec image unmodified. +- [ ] Verify `delete_alerts` from the sidecar deployment. +- [ ] Verify passthrough with bouncer keys and watcher JWTs. +- [ ] Verify the service-token gate and the + `ALLOW_ALERT_DELETE_WITHOUT_TOKEN` override. + +## Release prerequisites + +- [ ] Publish `crowdsec-client@1.3.0` (allowlists, delete query params, + `getById` typing). It is currently not on npm. +- [ ] Resolve the `follow-redirects` Snyk finding before the client release. +- [ ] Decide whether to wait for `axios@1.21.0` on npm or use a temporary + override. +- [ ] Publish `crowdsec-mcp` (only a placeholder `0.0.1-alpha.0` exists). +- [ ] Ensure the release workflow rewrites `workspace:^` correctly. `npm pack` + keeps `workspace:^`; use `pnpm publish` (or replace the dependency with a + published semver range). +- [ ] Configure npm trusted publishing (OIDC) after the first publish. +- [ ] Confirm Node.js `>=24` is acceptable for the published package. + +## Remaining QA items + +- [ ] `origin` / `kind` values with no match on list tools still return an + empty result (not an error). +- [ ] Allowlist write tools are impossible through the LAPI (use `cscli` or the + Console). +- [ ] `list_bouncers`, LAPI health/version/metrics, raw API passthrough and + simulated-object creation are not exposed. +- [ ] `delete_alerts(all=true)` is destructive and local-only; it is gated by + `confirm=true`. +- [ ] The current opencode session only sees `get_decision` after a restart. +- [ ] Add a cheap total-count source or pagination for the list tools. An + explicit `limit` currently fetches all matching rows so the hidden count + is exact; this can be expensive on very large datasets. +- [ ] Add sort/offset (or cursor) pagination to `list_alerts` and + `list_decisions`. +- [ ] Add a dry-run/preview mode for `delete_alerts` and `unban_ip(all=true)`. +- [ ] Decide whether `since=0s` / `until=0s` should be accepted; they are + currently rejected by the positive-duration parser. +- [ ] Re-run the QA matrix after the next live deployment. 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/package.json b/packages/crowdsec-mcp/package.json index 1c4aa9a69..7001431b9 100644 --- a/packages/crowdsec-mcp/package.json +++ b/packages/crowdsec-mcp/package.json @@ -1,7 +1,7 @@ { "name": "crowdsec-mcp", "version": "0.1.0", - "description": "Model Context Protocol server for CrowdSec", + "description": "Model Context Protocol (MCP) server for CrowdSec: decisions, alerts and allowlists", "type": "module", "main": "./dist/index.js", "bin": { @@ -11,8 +11,7 @@ ".": "./dist/index.js" }, "files": [ - "dist", - "README.md" + "dist" ], "homepage": "https://github.com/thib3113/node-crowdsec/tree/main/packages/crowdsec-mcp#readme", "bugs": { @@ -20,18 +19,30 @@ }, "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-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" diff --git a/packages/crowdsec-mcp/src/client.ts b/packages/crowdsec-mcp/src/client.ts index 30087cf27..887bdb5d1 100644 --- a/packages/crowdsec-mcp/src/client.ts +++ b/packages/crowdsec-mcp/src/client.ts @@ -1,8 +1,68 @@ import { readFile } from 'node:fs/promises'; +import type { IncomingHttpHeaders } from 'node:http'; import { BouncerClient, 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; +} + +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 { + throw new Error('Invalid CrowdSec token'); + } + } +} + +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) { @@ -75,3 +135,79 @@ export async function createContext(config: Config): Promise { stop }; } + +export async function createPassthroughContext(config: Config, headers: IncomingHttpHeaders): Promise { + 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 => { + if (!loginPromise) { + loginPromise = bouncer.login(); + } + + try { + await loginPromise; + } catch { + loginPromise = undefined; + throw new Error('Invalid CrowdSec API key'); + } + }; + + return { + mode: 'bouncer', + capabilities: { + read: true, + write: false, + allowlists: false + }, + bouncer, + allowlists: unavailableAllowlistsPort(), + config, + ensureReady, + stop: async () => 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 => { + if (!loginPromise) { + 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: async () => watcher.stop() + }; +} diff --git a/packages/crowdsec-mcp/src/config.ts b/packages/crowdsec-mcp/src/config.ts index 2da18dc60..f8f830fdd 100644 --- a/packages/crowdsec-mcp/src/config.ts +++ b/packages/crowdsec-mcp/src/config.ts @@ -28,7 +28,10 @@ export interface Config { timeout: number; transport: TransportMode; http: HttpConfig; + passthrough: boolean; readonly: boolean; + enableAlertDelete: boolean; + allowAlertDeleteWithoutToken: boolean; tools?: string[]; disableTools: string[]; maxResults: number; @@ -55,6 +58,9 @@ const cliOptions = { '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' }, @@ -127,6 +133,8 @@ export function loadConfig(values: CliValues, env: NodeJS.ProcessEnv = process.e const tlsKey = firstDefined(values['tls-key'] as string | undefined, env.CROWDSEC_TLS_KEY); const tlsCa = firstDefined(values['tls-ca'] as string | undefined, env.CROWDSEC_TLS_CA); + 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(); if (requestedAuth !== undefined && requestedAuth !== 'bouncer' && requestedAuth !== 'watcher') { throw new Error(`CROWDSEC_AUTH must be 'bouncer' or 'watcher', got '${requestedAuth}'`); @@ -139,6 +147,10 @@ export function loadConfig(values: CliValues, env: NodeJS.ProcessEnv = process.e auth = 'bouncer'; } else if (machineId || password) { auth = 'watcher'; + } else if (passthrough) { + // The env credentials are not used in passthrough mode; keep a valid + // mode for the shared Config shape. + auth = 'watcher'; } else { throw new Error('unable to detect authentication mode: set CROWDSEC_API_KEY or CROWDSEC_MACHINE_ID/CROWDSEC_PASSWORD'); } @@ -151,11 +163,11 @@ export function loadConfig(values: CliValues, env: NodeJS.ProcessEnv = process.e const tls = tlsCount === 3 ? { cert: tlsCert!, key: tlsKey!, ca: tlsCa! } : undefined; - if (auth === 'bouncer' && !apiKey && !tls) { + if (!passthrough && auth === 'bouncer' && !apiKey && !tls) { throw new Error('bouncer authentication requires CROWDSEC_API_KEY or TLS certificates'); } - if (auth === 'watcher' && !tls && (!machineId || !password)) { + if (!passthrough && auth === 'watcher' && !tls && (!machineId || !password)) { throw new Error('watcher authentication requires CROWDSEC_MACHINE_ID and CROWDSEC_PASSWORD (or TLS certificates)'); } @@ -163,9 +175,12 @@ export function loadConfig(values: CliValues, env: NodeJS.ProcessEnv = process.e 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'); + } const httpToken = firstDefined(values['http-token'] as string | undefined, env.CROWDSEC_MCP_HTTP_TOKEN); - if (transport === 'http' && !httpToken) { + if (transport === 'http' && !httpToken && !passthrough) { throw new Error('HTTP transport requires CROWDSEC_MCP_HTTP_TOKEN (or --http-token)'); } @@ -175,6 +190,14 @@ export function loadConfig(values: CliValues, env: NodeJS.ProcessEnv = process.e const readonlyDefault = readBoolean(env.CROWDSEC_MCP_READONLY, 'CROWDSEC_MCP_READONLY', true); const readonly = values.write === true ? false : values.readonly === true ? true : readonlyDefault; + const enableAlertDelete = + values['enable-alert-delete'] === true + ? true + : readBoolean(env.CROWDSEC_MCP_ENABLE_ALERT_DELETE, 'CROWDSEC_MCP_ENABLE_ALERT_DELETE', false); + const allowAlertDeleteWithoutToken = + values['allow-alert-delete-without-token'] === true + ? true + : readBoolean(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); @@ -211,7 +234,10 @@ export function loadConfig(values: CliValues, env: NodeJS.ProcessEnv = process.e 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, @@ -253,6 +279,10 @@ MCP: --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 in passthrough without a service token (dangerous) --tools Only expose these tools --disable-tools Hide these tools --max-results Default result limit, default 25 diff --git a/packages/crowdsec-mcp/src/format/alerts.ts b/packages/crowdsec-mcp/src/format/alerts.ts index fb0f87df2..243614ef2 100644 --- a/packages/crowdsec-mcp/src/format/alerts.ts +++ b/packages/crowdsec-mcp/src/format/alerts.ts @@ -1,7 +1,8 @@ import type { APITypes } from 'crowdsec-client'; -import { escapeCell, formatDate, markdownTable, moreHint, plural, relativeTime, truncateRows } from './common.js'; +import { resolveDecisionExpiry } from '../utils.js'; +import { escapeCell, expiry, formatDate, markdownTable, moreHint, plural, relativeTime, truncateRows } from './common.js'; -function formatDecisionsSummary(decisions?: APITypes.Alert['decisions']): string { +function formatDecisionsSummary(decisions: APITypes.Alert['decisions']): string { if (!decisions || decisions.length === 0) { return '—'; } @@ -9,6 +10,10 @@ function formatDecisionsSummary(decisions?: APITypes.Alert['decisions']): string 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 }; @@ -33,15 +38,20 @@ function formatDecisionsSummary(decisions?: APITypes.Alert['decisions']): string return parts.length > 0 ? parts.join(' ') : '—'; } -export function formatAlertList(alerts: APITypes.GetAlertsResponse, maxResults: number): string { - const { rows, hidden } = truncateRows(alerts, maxResults); +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 (rows.length === 0) { + if (displayRows.length === 0) { return '_No alerts found._'; } const headers = ['ID', 'Scope', 'Value', 'Reason', 'Country', 'AS', 'Decisions', 'Created at', 'Kind']; - const tableRows = rows.map((alert) => { + 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 ?? '—'); @@ -62,8 +72,13 @@ export function formatAlertList(alerts: APITypes.GetAlertsResponse, maxResults: ]; }); - const summary = plural(rows.length, 'alert'); - const hint = moreHint(hidden, 'list_alerts'); + const summary = plural(displayRows.length, 'alert'); + const hint = + hidden > 0 + ? moreHint(hidden, 'list_alerts') + : maybeMore && sortedAlerts.length >= maxResults + ? '\n_More results may be available. Increase `limit` or narrow the filters._' + : ''; return `${summary}:\n\n${markdownTable(headers, tableRows)}${hint}`; } @@ -74,10 +89,16 @@ function formatDecisionRow(decision: APITypes.Decision): string[] { const value = decision.value ?? '—'; const origin = decision.origin ?? '—'; const duration = decision.duration ?? '—'; - const until = decision.until ? `${formatDate(decision.until)} (${relativeTime(decision.until)})` : '—'; + const expiryInfo = resolveDecisionExpiry(decision); + const until = expiryInfo.expires + ? `${formatDate(expiryInfo.expires)} (${expiry(expiryInfo.expires)})` + : expiryInfo.active + ? '—' + : 'expired'; const simulated = decision.simulated === true ? 'yes' : 'no'; return [ + escapeCell(decision.id ?? '—'), escapeCell(type), escapeCell(scope), escapeCell(value), @@ -159,6 +180,7 @@ export function formatAlertDetail(alert: APITypes.Alert): string { `- **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}`, @@ -170,11 +192,21 @@ export function formatAlertDetail(alert: APITypes.Alert): string { `- **UUID:** ${alert.uuid ?? '—'}` ]; - // Decisions table - if (alert.decisions && alert.decisions.length > 0) { - const decisionHeaders = ['Type', 'Scope', 'Value', 'Origin', 'Duration', 'Until', 'Simulated']; - const decisionRows = alert.decisions.map(formatDecisionRow); + // 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 diff --git a/packages/crowdsec-mcp/src/format/allowlists.ts b/packages/crowdsec-mcp/src/format/allowlists.ts index 3e5514ea0..8decfe569 100644 --- a/packages/crowdsec-mcp/src/format/allowlists.ts +++ b/packages/crowdsec-mcp/src/format/allowlists.ts @@ -1,4 +1,5 @@ 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 { @@ -34,7 +35,11 @@ export function formatAllowlistDetail(allowlist: APITypes.GetAllowlistResponse, ]) ]; - if (!withContent || !allowlist.items || allowlist.items.length === 0) { + 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._`; } @@ -59,20 +64,22 @@ export function formatAllowlistCheck(targets: string[], results: APITypes.BulkCh return '_No targets provided._'; } + const uniqueTargets = [...new Set(targets)]; const lines: string[] = []; let allowlistedCount = 0; - for (const target of targets) { + for (const target of uniqueTargets) { const result = results.find((r) => r.target === target); const allowlists = result?.allowlists ?? []; if (allowlists.length > 0) { allowlistedCount++; - lines.push(`- ${escapeCell(target)}: allowlisted by ${escapeCell(allowlists.join(', '))}`); + 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(targets.length, 'target')} 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 index a20ad0f35..0a0944118 100644 --- a/packages/crowdsec-mcp/src/format/common.ts +++ b/packages/crowdsec-mcp/src/format/common.ts @@ -68,6 +68,11 @@ export function expiry(value?: string | Date, now = Date.now()): string { return String(value); } + // CrowdSec uses 0001-01-01T00:00:00Z for "no expiration". + if (date.getUTCFullYear() <= 1) { + return 'never'; + } + return date.getTime() <= now ? 'expired' : relativeTime(date, now); } diff --git a/packages/crowdsec-mcp/src/format/decisions.ts b/packages/crowdsec-mcp/src/format/decisions.ts index a577aa4ac..38b6834a5 100644 --- a/packages/crowdsec-mcp/src/format/decisions.ts +++ b/packages/crowdsec-mcp/src/format/decisions.ts @@ -1,5 +1,6 @@ import type { APITypes } from 'crowdsec-client'; -import { markdownTable, escapeCell, expiry, plural, truncateRows } from './common.js'; +import { resolveDecisionExpiry } from '../utils.js'; +import { escapeCell, expiry, formatDate, markdownTable, plural, truncateRows } from './common.js'; export interface DecisionRow { type: string; @@ -7,22 +8,38 @@ export interface DecisionRow { value: string; origin: string; scenario: string; - expires: string; + expires?: string; simulated: boolean; alertId?: number; + decisionId?: number; } -export function formatDecisionList(alerts: APITypes.GetAlertsResponse, maxResults: number): string { +export function formatDecisionList(alerts: APITypes.GetAlertsResponse, maxResults: number, maybeMore = false): string { const rows: DecisionRow[] = []; const seen = new Set(); + const now = Date.now(); - for (const alert of alerts) { + 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) { if (!alert.decisions || alert.decisions.length === 0) { continue; } for (const decision of alert.decisions) { - const key = `${decision.simulated ?? false}:${decision.type}:${decision.scope}:${decision.value}`; + const expiryInfo = resolveDecisionExpiry(decision, undefined, now); + if (!expiryInfo.active) { + continue; + } + + const key = + decision.id !== undefined + ? `id:${decision.id}` + : `${decision.simulated ?? false}:${decision.type}:${decision.scope}:${decision.value}`; if (seen.has(key)) { continue; } @@ -34,9 +51,10 @@ export function formatDecisionList(alerts: APITypes.GetAlertsResponse, maxResult value: decision.value, origin: decision.origin, scenario: decision.scenario, - expires: decision.until ?? 'never', + expires: expiryInfo.expires, simulated: decision.simulated ?? false, - alertId: alert.id + alertId: alert.id, + decisionId: decision.id }); } } @@ -44,7 +62,7 @@ export function formatDecisionList(alerts: APITypes.GetAlertsResponse, maxResult const { rows: displayRows, hidden } = truncateRows(rows, maxResults); const table = markdownTable( - ['Type', 'Scope', 'Value', 'Origin', 'Scenario', 'Expires', 'Simulated', 'Alert ID'], + ['Type', 'Scope', 'Value', 'Origin', 'Scenario', 'Expires', 'Simulated', 'Alert ID', 'Decision ID'], displayRows.map((r) => [ escapeCell(r.type), escapeCell(r.scope), @@ -53,35 +71,91 @@ export function formatDecisionList(alerts: APITypes.GetAlertsResponse, maxResult escapeCell(r.scenario), escapeCell(expiry(r.expires)), escapeCell(r.simulated ? 'yes' : 'no'), - escapeCell(r.alertId ?? '—') + escapeCell(r.alertId ?? '—'), + escapeCell(r.decisionId ?? '—') ]) ); - const summary = `\n${plural(rows.length, 'decision')} total`; - return table + summary + (hidden > 0 ? `\n_${hidden} more result(s) hidden. Increase \`limit\` or narrow the filters._` : ''); + 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 { rows: displayRows, hidden } = truncateRows(decisions, maxResults); + 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) => [ - escapeCell(d.id), - escapeCell(d.type), - escapeCell(d.scope), - escapeCell(d.value), - escapeCell(d.origin), - escapeCell(d.scenario), - escapeCell(expiry(d.until)), - escapeCell(d.simulated ? 'yes' : 'no') - ]) + displayRows.map((d) => { + const endAt = (d as { endAt?: Date }).endAt; + const expires = d.until ?? (endAt instanceof Date ? endAt.toISOString() : resolveDecisionExpiry(d).expires); + + return [ + escapeCell(d.id), + escapeCell(d.type), + escapeCell(d.scope), + escapeCell(d.value), + escapeCell(d.origin), + escapeCell(d.scenario), + escapeCell(expiry(expires)), + escapeCell(d.simulated ? 'yes' : 'no') + ]; + }) ); - const summary = `\n${plural(decisions.length, 'decision')} total`; + 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 = expiryInfo.expires + ? `${formatDate(expiryInfo.expires)} (${expiry(expiryInfo.expires)})` + : expiryInfo.active + ? '—' + : 'expired'; + + 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 } @@ -99,5 +173,10 @@ export function formatBanResult( } export function formatUnbanResult(nbDeleted: string | number): string { - return `**${nbDeleted} decision(s) deleted**`; + 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 index fa2faf764..af49a05e5 100644 --- a/packages/crowdsec-mcp/src/index.ts +++ b/packages/crowdsec-mcp/src/index.ts @@ -2,10 +2,11 @@ import debug from 'debug'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { loadConfig, parseCliArgs, printHelp, printVersion } from './config.js'; -import { createContext } from './client.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'); @@ -23,9 +24,8 @@ async function main(): Promise { } const config = loadConfig(values); - const context = await createContext(config); - const tools = selectTools(allTools(), context); - const server = createServer(tools, context); + let context: AppContext | undefined; + let stdioServer: ReturnType | undefined; let stopping = false; const shutdown = async (signal: string): Promise => { @@ -36,8 +36,8 @@ async function main(): Promise { stopping = true; log('received %s, shutting down', signal); try { - await context.stop(); - await server.close(); + await context?.stop(); + await stdioServer?.close(); } finally { process.exit(0); } @@ -51,13 +51,29 @@ async function main(): Promise { }); if (config.transport === 'http') { - await startHttpServer(server, config); + if (config.passthrough) { + await startHttpServer(async (headers) => { + const requestContext = await 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); + 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 server.connect(transport); + await stdioServer.connect(transport); log('stdio transport ready'); } diff --git a/packages/crowdsec-mcp/src/registry.ts b/packages/crowdsec-mcp/src/registry.ts index 6d12cf8ef..2fe951be8 100644 --- a/packages/crowdsec-mcp/src/registry.ts +++ b/packages/crowdsec-mcp/src/registry.ts @@ -10,9 +10,41 @@ 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 = message.match(/^allowlist '(.+)' not found$/i); + if (allowlist) { + message = `Allowlist '${allowlist[1]}' not found`; + } + + const decision = message.match(/^decision with id '(.+)' doesn't exist.*/i); + 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 message = error instanceof Error ? error.message : String(error); - log('tool error: %s', message); + 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 @@ -43,6 +75,19 @@ export function selectTools(tools: ToolDefinition[], ctx: AppContext): ToolDefin return false; } + if (tool.feature === 'alert-delete') { + if (!context.config.enableAlertDelete) { + return false; + } + + // 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. + if (context.config.passthrough && !context.config.http.token && !context.config.allowAlertDeleteWithoutToken) { + return false; + } + } + if (context.config.tools && !context.config.tools.includes(tool.name)) { return false; } diff --git a/packages/crowdsec-mcp/src/tools/alerts.ts b/packages/crowdsec-mcp/src/tools/alerts.ts index 2032641f6..d89ba2c75 100644 --- a/packages/crowdsec-mcp/src/tools/alerts.ts +++ b/packages/crowdsec-mcp/src/tools/alerts.ts @@ -1,39 +1,70 @@ import { z } from 'zod'; -import type { ToolDefinition } from '../types.js'; +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 +} from '../utils.js'; -const ListAlertsInput = z.object({ - ip: z.string().optional().describe('Filter by IP address'), - range: z.string().optional().describe('Filter by CIDR range'), - 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, 30m)'), - until: z.string().optional().describe('Filter alerts older than this duration (e.g., 4h, 30m)'), - simulated: z.boolean().optional().describe('Include simulated alerts'), - 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') -}); - -const GetAlertInput = z.object({ - id: z.union([z.string(), z.number()]).describe('Alert ID') -}); - -const DeleteAlertsInput = z.object({ - id: z.union([z.string(), z.number()]).optional().describe('Delete a specific alert by ID'), - ip: z.string().optional().describe('Filter by IP address'), - range: z.string().optional().describe('Filter by CIDR range'), - 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'), - all: z.boolean().optional().describe('Delete all matching alerts (requires confirm=true)'), - confirm: z.boolean().optional().describe('Confirm deletion when using all=true') -}); +async function deleteAlertIds(ctx: AppContext, ids: number[]): Promise { + let deleted = 0; + for (const id of ids) { + const response = await ctx.watcher!.Alerts.deleteById(String(id)); + deleted += Number(response.nbDeleted ?? 0); + } + return deleted; +} + +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') + }) + .strict(); export const alertTools: ToolDefinition[] = [ { @@ -51,28 +82,49 @@ export const alertTools: ToolDefinition[] = [ handler: async (input, ctx) => { await ctx.ensureReady(); - const { ip, range, scope, value, scenario, type, origin, kind, since, until, simulated, has_active_decision, all, limit } = - input as z.infer; + 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); + } + if (scope) { + assertValidDecisionScope(scope); + } + if (scope && value) { + assertValidScopeValue(scope, 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 query: APITypes.Alerts.SearchAlerts.RequestQuery & { kind?: string } = { + const window = resolveListWindow(limit, all === true, ctx.config.maxResults); + const query: APITypes.Alerts.SearchAlerts.RequestQuery & { kind?: string; contains?: boolean } = { ip, range, scope, value, scenario, - decision_type: type, + decision_type: decisionType, origin, kind, since, until, - simulated, + contains: false, has_active_decision, - limit: all === true ? 0 : (limit ?? ctx.config.maxResults) + limit: window.fetchLimit }; const alerts = await ctx.watcher!.Alerts.search(query); - return formatAlertList(alerts, ctx.config.maxResults); + return formatAlertList(alerts, window.displayLimit, window.maybeMore); } }, { @@ -89,20 +141,30 @@ export const alertTools: ToolDefinition[] = [ handler: async (input, ctx) => { await ctx.ensureReady(); - const { id } = input as z.infer; - const alert = await ctx.watcher!.Alerts.getById(String(id)); + const { id } = GetAlertInput.parse(input); + const alertId = parsePositiveId(id, 'alert id'); - return formatAlertDetail(alert); + 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. CrowdSec only allows alert deletion from the LAPI host; remote watchers receive 403.', + 'Delete alerts by ID or filters. Requires at least one filter or all=true with confirm=true; all=true cannot be combined with filters. 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, @@ -110,37 +172,171 @@ export const alertTools: ToolDefinition[] = [ }, handler: async (input, ctx) => { await ctx.ensureReady(); + const { id, ip, range, scope, value, scenario, origin, type, since, until, has_active_decision, all, confirm } = + DeleteAlertsInput.parse(input); + + 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 { id, ip, range, scope, value, scenario, all, confirm } = input as z.infer; + const decisionType = type ? assertValidDecisionType(type) : undefined; if (all === true) { if (confirm !== true) { throw new Error('Deleting all alerts requires confirm=true'); } + if ( + id !== undefined || + ip || + range || + scope || + value || + scenario || + origin || + decisionType || + since || + until || + has_active_decision !== undefined + ) { + throw new Error( + 'Cannot combine all=true with id or filters; use all=true with confirm=true alone to delete everything' + ); + } } + if (scope && (ip || range)) { + throw new Error('Provide only one of ip, range, or scope'); + } + + if (ip && range) { + throw new Error('Provide only one of ip or range'); + } + 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; + let result: APITypes.Alerts.DeleteAlerts.ResponseBody; if (id !== undefined) { - result = await ctx.watcher!.Alerts.deleteById(String(id)); + if (ip || range || scope || value || scenario || origin || decisionType || since || until || all === true) { + throw new Error('Cannot combine id with filters or all=true'); + } + + const alertId = parsePositiveId(id, 'alert id'); + try { + result = await ctx.watcher!.Alerts.deleteById(alertId); + } catch (error) { + const message = cleanErrorMessage(error); + if (/not found/i.test(message)) { + throw new Error(`Alert ${alertId} not found`); + } + throw error; + } } else { - const hasFilter = ip || range || scope || value || scenario; + const hasFilter = + ip || + range || + scope || + value || + scenario || + origin || + decisionType || + since || + until || + has_active_decision !== undefined; if (!hasFilter && all !== true) { - throw new Error('At least one filter (ip, range, scope, value, scenario) or all=true is required'); + throw new Error( + 'At least one filter (ip, range, scope, value, scenario, origin, type, since, until, has_active_decision) or all=true is required' + ); + } + + if (normalizedScope && !value) { + const alerts = await ctx.watcher!.Alerts.search({ + scope: normalizedScope, + origin, + has_active_decision, + limit: 0 + }); + const alertIds = alerts + .filter((alert) => alert.source?.scope?.toLowerCase() === normalizedScope.toLowerCase()) + .map((alert) => alert.id) + .filter((alertId): alertId is number => typeof alertId === 'number'); + + if (alertIds.length === 0) { + return 'No matching alerts found.'; + } + + const deleted = await deleteAlertIds(ctx, alertIds); + return deleted === 0 ? 'No matching alerts found.' : `Deleted ${deleted} alert(s).`; + } + + const cidr = range ?? (ip && isValidCidr(ip) ? ip : undefined); + if (cidr) { + const alerts = await ctx.watcher!.Alerts.search({ + ip: cidr, + contains: false, + scope: normalizedScope, + value: normalizedValue, + scenario, + origin, + has_active_decision, + limit: 0 + } as APITypes.Alerts.SearchAlerts.RequestQuery & { contains?: boolean }); + + const alertIds = alerts.map((alert) => alert.id).filter((id): id is number => typeof id === 'number'); + if (alertIds.length === 0) { + return 'No matching alerts found.'; + } + + const deleted = await deleteAlertIds(ctx, alertIds); + return deleted === 0 ? 'No matching alerts found.' : `Deleted ${deleted} alert(s).`; + } + + if (origin || decisionType || since || until || has_active_decision !== undefined) { + const alerts = await ctx.watcher!.Alerts.search({ + ip, + contains: false, + scope: normalizedScope, + value: normalizedValue, + scenario, + origin, + decision_type: decisionType, + since, + until, + has_active_decision, + limit: 0 + } as APITypes.Alerts.SearchAlerts.RequestQuery & { contains?: boolean }); + + const alertIds = alerts.map((alert) => alert.id).filter((id): id is number => typeof id === 'number'); + if (alertIds.length === 0) { + return 'No matching alerts found.'; + } + + const deleted = await deleteAlertIds(ctx, alertIds); + return deleted === 0 ? 'No matching alerts found.' : `Deleted ${deleted} alert(s).`; } const query: APITypes.Alerts.DeleteAlerts.RequestQuery = { ip, - range, - scope, - value, + scope: normalizedScope, + value: normalizedValue, scenario }; result = await ctx.watcher!.Alerts.delete(query); } - return `Deleted ${result.nbDeleted ?? 0} alert(s).`; + const count = Number(result.nbDeleted ?? 0); + return count === 0 ? 'No matching alerts found.' : `Deleted ${count} alert(s).`; } } ]; diff --git a/packages/crowdsec-mcp/src/tools/allowlists.ts b/packages/crowdsec-mcp/src/tools/allowlists.ts index 4a5063cb8..2464a282a 100644 --- a/packages/crowdsec-mcp/src/tools/allowlists.ts +++ b/packages/crowdsec-mcp/src/tools/allowlists.ts @@ -1,24 +1,34 @@ 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') -}); +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().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') -}); +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().min(1).optional().describe('Single value (IP, range, username, etc.) to check'), - values: z.array(z.string().min(1)).optional().describe('Multiple values to check in bulk') + 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[] = [ @@ -59,16 +69,23 @@ export const allowlistTools: ToolDefinition[] = [ const { name, with_content: withContent, limit } = GetAllowlistInput.parse(input); - const allowlist = await ctx.allowlists.get(name, withContent); - - return formatAllowlistDetail(allowlist, withContent, limit ?? ctx.config.maxResults); + 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 if one or more values (IPs, ranges, usernames, etc.) are present in any allowlist. Provide either a single `value` or an array of `values`.', + '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'], @@ -81,22 +98,29 @@ export const allowlistTools: ToolDefinition[] = [ const { value, values } = CheckAllowlistInput.parse(input); - const targets: string[] = values?.length ? values : value ? [value] : []; + const targets: string[] = [...(values ?? []), ...(value ? [value] : [])]; + const uniqueTargets = [...new Set(targets)]; - if (targets.length === 0) { + if (uniqueTargets.length === 0) { throw new Error('At least one of `value` or `values` must be provided and non-empty'); } - if (targets.length === 1) { - const result = await ctx.allowlists.check(targets[0]); + 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`); + } + } + + if (uniqueTargets.length === 1) { + const result = await ctx.allowlists.check(uniqueTargets[0]); return formatAllowlistCheck( - targets, - result.allowlisted ? [{ target: targets[0], allowlists: [result.reason || 'allowlist'] }] : [] + uniqueTargets, + result.allowlisted ? [{ target: uniqueTargets[0], allowlists: [result.reason || 'allowlist'] }] : [] ); } - const bulkResult = await ctx.allowlists.bulkCheck(targets); - return formatAllowlistCheck(targets, bulkResult.results ?? []); + 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 index ba6a27d2b..5e868e0f9 100644 --- a/packages/crowdsec-mcp/src/tools/decisions.ts +++ b/packages/crowdsec-mcp/src/tools/decisions.ts @@ -1,47 +1,130 @@ import { z } from 'zod'; import type { APITypes } from 'crowdsec-client'; -import { parseDuration, formatDuration, normalizeScope } from '../utils.js'; -import { formatDecisionList, formatBouncerDecisionList, formatBanResult, formatUnbanResult } from '../format/decisions.js'; -import type { ToolDefinition } from '../types.js'; - -const ListDecisionsInput = z.object({ - ip: z.string().optional().describe('IP address to filter by (shorthand for scope=Ip)'), - 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)'), - until: z.string().optional().describe('Show alerts older than this duration (e.g., 24h, 7d)'), - contains: z.boolean().optional().default(false).describe('Search for decisions containing the value or contained within 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') -}); - -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.string().optional().default('4h').describe('Duration of the ban (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') -}); - -const UnbanIpInput = z.object({ - id: z.union([z.string(), z.number()]).optional().describe('Decision ID to delete'), - ip: z.string().optional().describe('IP address to filter by (shorthand for scope=Ip)'), - 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.)'), - 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') -}); +import { + assertValidCidr, + assertValidDecisionScope, + assertValidDecisionType, + assertValidIp, + assertValidScopeValue, + formatDuration, + isValidCidr, + isValidIp, + isValidTimeFilter, + normalizeScope, + parseDuration, + parsePositiveId, + resolveListWindow +} 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.Alerts.SearchAlerts.RequestQuery & { contains?: boolean } +): Promise { + const alerts = await ctx.watcher!.Alerts.search({ ...query, has_active_decision: true, limit: 0 }); + return collectDecisionIds(alerts); +} + +async function deleteDecisionIds(ctx: AppContext, ids: number[]): Promise { + let deleted = 0; + for (const id of ids) { + const result = await ctx.watcher!.Decisions.deleteById(String(id)); + deleted += Number(result.nbDeleted ?? 0); + } + return deleted; +} + +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 (Ip or Range; other scopes are unsupported by the LAPI)'), + 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') + }) + .strict(); export const decisionTools: ToolDefinition[] = [ { @@ -57,7 +140,24 @@ export const decisionTools: ToolDefinition[] = [ const { ip, range, scope, value, type, origin, scenario, since, until, contains, no_simulated, all, limit } = ListDecisionsInput.parse(input); - const effectiveLimit = all ? 0 : (limit ?? ctx.config.maxResults); + if (ip && !isValidIp(ip) && !isValidCidr(ip)) { + throw new Error(`${ip} is not a valid ip or CIDR range`); + } + if (range) { + assertValidCidr(range); + } + if (scope) { + assertValidDecisionScope(scope); + } + 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, ctx.config.maxResults); if (ctx.mode === 'watcher' && ctx.watcher) { const query = { @@ -66,19 +166,19 @@ export const decisionTools: ToolDefinition[] = [ scope, value, scenario, - decision_type: type, + decision_type: decisionType, origin, since, until, simulated: no_simulated ? false : undefined, contains, has_active_decision: true, - limit: effectiveLimit + limit: window.fetchLimit } as APITypes.Alerts.SearchAlerts.RequestQuery & { contains?: boolean }; const alerts = await ctx.watcher.Alerts.search(query); - return formatDecisionList(alerts, ctx.config.maxResults); + return formatDecisionList(alerts, window.displayLimit, window.maybeMore); } if (ctx.mode === 'bouncer' && ctx.bouncer) { @@ -87,18 +187,47 @@ export const decisionTools: ToolDefinition[] = [ range, scope, value, - type, + type: decisionType, origins: origin, scenarios_containing: scenario, contains }); - return formatBouncerDecisionList(decisions, ctx.config.maxResults); + 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) => { + await ctx.ensureReady(); + + 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', @@ -117,12 +246,25 @@ export const decisionTools: ToolDefinition[] = [ const { ip, range, scope: inputScope, value, duration, type, reason, force } = BanIpInput.parse(input); - // Validate that we have enough to identify the target - if (!ip && !range && !(inputScope && value)) { + const hasIp = Boolean(ip); + const hasRange = Boolean(range); + const hasScope = Boolean(inputScope); + const hasValue = Boolean(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'); } - // cscli gives --ip precedence over --range, and both over --scope/--value. + const decisionType = assertValidDecisionType(type); let scope: string; let targetValue: string; @@ -133,21 +275,17 @@ export const decisionTools: ToolDefinition[] = [ scope = normalizeScope(undefined, undefined, range); targetValue = range; } else { - scope = normalizeScope(inputScope); - targetValue = value ?? ''; + scope = assertValidDecisionScope(inputScope!); + targetValue = assertValidScopeValue(scope, value!); } if (!targetValue) { throw new Error('Value is required for the given scope'); } - // Parse and validate duration - let durationSeconds: number; - try { - durationSeconds = parseDuration(duration); - } catch (e) { - throw new Error(`Invalid duration: ${(e as Error).message}`); - } + // Parse and validate duration before sending anything to the LAPI. + const normalizedDuration = String(duration).trim(); + const durationSeconds = parseDuration(normalizedDuration); // Check allowlist first, like cscli, then enforce the local duration guard. if (!force && (scope === 'Ip' || scope === 'Range')) { @@ -162,23 +300,23 @@ export const decisionTools: ToolDefinition[] = [ // Check max duration unless forced if (!force && durationSeconds > ctx.config.maxBanDurationSeconds) { const maxDuration = formatDuration(ctx.config.maxBanDurationSeconds); - throw new Error(`Duration ${duration} exceeds maximum allowed ${maxDuration}. Use force=true to override.`); + throw new Error(`Duration ${normalizedDuration} exceeds maximum allowed ${maxDuration}. Use force=true to override.`); } // Build the alert payload exactly as cscli does const now = new Date().toISOString(); - const defaultReason = `manual '${type}' from 'crowdsec-mcp'`; - const alertReason = reason ?? defaultReason; + const defaultReason = `manual '${decisionType}' from 'crowdsec-mcp'`; + const alertReason = reason?.trim() ? reason : defaultReason; const alert = { capacity: 0, decisions: [ { origin: 'cscli', - type, + type: decisionType, scope, value: targetValue, - duration, + duration: normalizedDuration, scenario: alertReason } ], @@ -208,7 +346,13 @@ export const decisionTools: ToolDefinition[] = [ const alertIds = await ctx.watcher.Alerts.pushAlerts([alert]); - return formatBanResult(alertIds, { scope, value: targetValue, type, duration, reason: alertReason }); + return formatBanResult(alertIds, { + scope, + value: targetValue, + type: decisionType, + duration: normalizedDuration, + reason: alertReason + }); } }, { @@ -239,30 +383,130 @@ export const decisionTools: ToolDefinition[] = [ ); } - const result = await ctx.watcher.Decisions.delete({}); - return formatUnbanResult(result.nbDeleted ?? 0); + const ids = await searchActiveDecisionIds(ctx, {}); + if (ids.length === 0) { + return formatUnbanResult(0); + } + return formatUnbanResult(await deleteDecisionIds(ctx, ids)); } if (id !== undefined) { - await ctx.watcher.Decisions.deleteById(String(id)); - return formatUnbanResult(1); + if (ip || range || scope || value || type || scenario) { + throw new Error('Cannot combine id with filters'); + } + + const decisionId = parsePositiveId(id, 'decision id'); + const activeIds = await searchActiveDecisionIds(ctx, {}); + if (!activeIds.includes(Number(decisionId))) { + throw new Error(`Decision ${decisionId} not found`); + } + + 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; + } + } + + if (scope && value && (ip || range)) { + throw new Error('Provide only one of ip, range, or scope+value'); + } + if (value && !scope && (ip || range)) { + throw new Error('Provide only one of ip, range, or value'); } - // Require at least one filter - const hasFilter = ip || range || scope || value || type || scenario; + if (ip) { + assertValidIp(ip); + } + if (range) { + assertValidCidr(range); + } + + const normalizedType = type ? assertValidDecisionType(type) : undefined; + + if (scope && !value) { + const normalized = assertValidDecisionScope(scope); + const decisions = await fetchActiveDecisions(ctx); + const ids = decisions + .filter( + (decision) => + decision.scope?.toLowerCase() === normalized.toLowerCase() && + (!normalizedType || decision.type?.toLowerCase() === normalizedType) && + (!scenario || decision.scenario === scenario) + ) + .map((decision) => decision.id) + .filter((id): id is number => typeof id === 'number'); + + if (ids.length === 0) { + return formatUnbanResult(0); + } + return formatUnbanResult(await deleteDecisionIds(ctx, ids)); + } + + const query: { ip?: string; value?: string; type?: string; scenario?: string } = {}; + if (ip) query.ip = ip; + if (value) query.value = value; + if (normalizedType) query.type = normalizedType; + if (scenario) query.scenario = scenario; + + if (scope && value) { + const normalized = assertValidDecisionScope(scope); + const normalizedValue = assertValidScopeValue(normalized, value); + if (normalized === 'Ip') { + query.ip = normalizedValue; + delete query.value; + } else if (normalized === 'Range') { + const ids = await searchActiveDecisionIds(ctx, { + ip: normalizedValue, + contains: false, + decision_type: normalizedType, + scenario + }); + if (ids.length === 0) { + return formatUnbanResult(0); + } + return formatUnbanResult(await deleteDecisionIds(ctx, ids)); + } else { + const ids = await searchActiveDecisionIds(ctx, { + scope: normalized, + value: normalizedValue, + decision_type: normalizedType, + scenario + }); + if (ids.length === 0) { + return formatUnbanResult(0); + } + return formatUnbanResult(await deleteDecisionIds(ctx, ids)); + } + } + + if (range) { + const ids = await searchActiveDecisionIds(ctx, { + ip: range, + contains: false, + decision_type: normalizedType, + scenario + }); + if (ids.length === 0) { + return formatUnbanResult(0); + } + return formatUnbanResult(await deleteDecisionIds(ctx, ids)); + } + + const hasFilter = query.ip || query.value || query.type || query.scenario; if (!hasFilter) { throw new Error('Must provide id, at least one filter, or all=true with confirm=true'); } - const query = { - ip, - range, - scope, - value, - type, - scenario - }; - const result = await ctx.watcher.Decisions.delete(query); return formatUnbanResult(result.nbDeleted ?? 0); } diff --git a/packages/crowdsec-mcp/src/tools/system.ts b/packages/crowdsec-mcp/src/tools/system.ts index a41dffd7c..45b2b9fa1 100644 --- a/packages/crowdsec-mcp/src/tools/system.ts +++ b/packages/crowdsec-mcp/src/tools/system.ts @@ -26,7 +26,7 @@ export const statusTool: ToolDefinition = { `Capabilities: read=yes, write=${ctx.capabilities.write ? 'yes' : 'no'}, 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}`, + `Maximum ban duration: ${ctx.config.maxBanDuration} (force=true can override)`, `Active tools: ${(ctx.activeTools ?? []).join(', ') || 'none'}` ]) ]; diff --git a/packages/crowdsec-mcp/src/transports/http.ts b/packages/crowdsec-mcp/src/transports/http.ts index 7aca64d0c..664817078 100644 --- a/packages/crowdsec-mcp/src/transports/http.ts +++ b/packages/crowdsec-mcp/src/transports/http.ts @@ -3,6 +3,14 @@ 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; @@ -79,24 +87,16 @@ function sendJson(res: http.ServerResponse, status: number, body: string, extraH res.end(body); } -export async function startHttpServer(server: McpServer, config: Config): Promise { +export async function startHttpServer(createRequestServer: RequestServerFactory, config: Config): Promise { const token = config.http.token; - if (!token) { - throw new Error('HTTP transport requires a bearer token (config.http.token)'); - } - - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: undefined, - enableJsonResponse: true - }); - - await server.connect(transport); const httpServer = http.createServer(async (req, res) => { const { method, url } = req; - // Auth first: every request (including unknown routes/methods) requires a valid bearer token. - if (!isAuthorizedHeader(req.headers.authorization, token)) { + // 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)) { sendJson(res, 401, UNAUTHORIZED_BODY, { 'WWW-Authenticate': 'Bearer' }); return; } @@ -140,16 +140,98 @@ export async function startHttpServer(server: McpServer, config: Config): Promis return; } - await transport.handleRequest(req, res, parsedBody); + let requestServer: RequestServer; + try { + requestServer = await createRequestServer(req.headers); + } catch (error) { + if (error instanceof HttpRequestError) { + sendJson( + res, + error.status, + JSON.stringify({ + jsonrpc: '2.0', + error: { code: error.code, message: error.message }, + id: null + }) + ); + } else { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`crowdsec-mcp: request setup failed: ${message}\n`); + sendJson( + res, + 500, + JSON.stringify({ + jsonrpc: '2.0', + error: { code: -32603, message: 'Internal server error' }, + id: null + }) + ); + } + 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', () => { + void transport.close(); + void server.close(); + if (cleanup) { + void cleanup(); + } + }); + + try { + await server.connect(transport); + await transport.handleRequest(req, res, parsedBody); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`crowdsec-mcp: HTTP request failed: ${message}\n`); + if (!res.headersSent) { + sendJson( + res, + 500, + JSON.stringify({ + jsonrpc: '2.0', + error: { code: -32603, message: 'Internal server error' }, + id: null + }) + ); + } else { + res.end(); + } + } return; } if (method === 'GET' || method === 'DELETE') { - await transport.handleRequest(req, res); + sendJson( + res, + 405, + JSON.stringify({ + jsonrpc: '2.0', + error: { code: -32000, message: 'Method not allowed' }, + id: null + }) + ); return; } - res.writeHead(405).end(); + sendJson( + res, + 405, + JSON.stringify({ + jsonrpc: '2.0', + error: { code: -32000, message: 'Method not allowed' }, + id: null + }) + ); }); return new Promise((resolve, reject) => { diff --git a/packages/crowdsec-mcp/src/types.ts b/packages/crowdsec-mcp/src/types.ts index 7443e0894..3d66b8e8d 100644 --- a/packages/crowdsec-mcp/src/types.ts +++ b/packages/crowdsec-mcp/src/types.ts @@ -31,6 +31,7 @@ export interface AppContext { } export type ToolRequirement = 'watcher' | 'allowlists'; +export type ToolFeature = 'alert-delete'; export interface ToolDefinition { name: string; @@ -39,6 +40,7 @@ export interface ToolDefinition { inputSchema: ZodType; kind: 'read' | 'write'; requires?: ToolRequirement[]; + feature?: ToolFeature; annotations?: ToolAnnotations; handler: (input: Record, ctx: AppContext) => Promise; } diff --git a/packages/crowdsec-mcp/src/utils.ts b/packages/crowdsec-mcp/src/utils.ts index c19b64df1..25be7608d 100644 --- a/packages/crowdsec-mcp/src/utils.ts +++ b/packages/crowdsec-mcp/src/utils.ts @@ -11,10 +11,6 @@ export function parseDuration(input: string): number { throw new Error('duration must not be empty'); } - if (/^\d+$/.test(value)) { - return Number(value); - } - let total = 0; let consumed = 0; for (const match of value.matchAll(DURATION_PART)) { @@ -38,7 +34,7 @@ export function parseDuration(input: string): number { } if (consumed !== value.length || total <= 0) { - throw new Error(`invalid duration '${input}' (use for example 30m, 4h or 7d)`); + throw new Error(`invalid duration '${input}': use 30m, 4h or 7d`); } return total; @@ -125,6 +121,171 @@ export function normalizeScope(scope?: string, ip?: string, range?: string): str 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 }; + } + + const baseDate = base ? (base instanceof Date ? base : new Date(base)) : new Date(now); + 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; 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 index 06b9ff6d6..6ebaddb7d 100644 --- a/packages/crowdsec-mcp/tests/config.test.ts +++ b/packages/crowdsec-mcp/tests/config.test.ts @@ -96,6 +96,49 @@ describe('config', () => { 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' }); @@ -118,6 +161,16 @@ describe('config', () => { 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' }) diff --git a/packages/crowdsec-mcp/tests/e2e/stdio.test.ts b/packages/crowdsec-mcp/tests/e2e/stdio.test.ts index cb76b6011..3ab09ed33 100644 --- a/packages/crowdsec-mcp/tests/e2e/stdio.test.ts +++ b/packages/crowdsec-mcp/tests/e2e/stdio.test.ts @@ -94,6 +94,6 @@ describe('stdio end-to-end', () => { 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 total'); + 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 index 00ca68873..aba5b2bb5 100644 --- a/packages/crowdsec-mcp/tests/e2e/watcher.test.ts +++ b/packages/crowdsec-mcp/tests/e2e/watcher.test.ts @@ -107,9 +107,10 @@ describe('watcher stdio end-to-end', () => { 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).toContain('delete_alerts'); + expect(names).not.toContain('delete_alerts'); expect(names).toContain('list_allowlists'); expect(names).toContain('get_allowlist'); expect(names).toContain('check_allowlist'); diff --git a/packages/crowdsec-mcp/tests/format.test.ts b/packages/crowdsec-mcp/tests/format.test.ts index a89a031b6..ed523e9d4 100644 --- a/packages/crowdsec-mcp/tests/format.test.ts +++ b/packages/crowdsec-mcp/tests/format.test.ts @@ -31,6 +31,7 @@ describe('format', () => { 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'); }); diff --git a/packages/crowdsec-mcp/tests/helpers.ts b/packages/crowdsec-mcp/tests/helpers.ts index a7e2547aa..faf6d449d 100644 --- a/packages/crowdsec-mcp/tests/helpers.ts +++ b/packages/crowdsec-mcp/tests/helpers.ts @@ -16,6 +16,9 @@ export function makeConfig(overrides: Partial = {}): Config { token: 'token' }, readonly: true, + enableAlertDelete: false, + allowAlertDeleteWithoutToken: false, + passthrough: false, disableTools: [], maxResults: 25, maxBanDuration: '24h', diff --git a/packages/crowdsec-mcp/tests/integration/live.test.ts b/packages/crowdsec-mcp/tests/integration/live.test.ts index b1a70c38e..016db0a98 100644 --- a/packages/crowdsec-mcp/tests/integration/live.test.ts +++ b/packages/crowdsec-mcp/tests/integration/live.test.ts @@ -175,29 +175,11 @@ describe.skipIf(!enabled || !url)('live CrowdSec LAPI integration', () => { const decisions = textOf(await client.callTool({ name: 'list_decisions', arguments: {} })); expect(decisions).toContain(testIp); - const alerts = textOf(await client.callTool({ name: 'list_alerts', arguments: {} })); - const alertId = alertIdFor(alerts, 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); - - const deletedResult = await client.callTool({ - name: 'delete_alerts', - arguments: { id: alertId } - }); - const deleted = textOf(deletedResult); - - if (process.env.CROWDSEC_INTEGRATION_LOCAL_DELETE === '1') { - expect(deletedResult.isError).not.toBe(true); - expect(deleted).toContain('Deleted'); - } else if (deletedResult.isError) { - expect(deleted).toMatch(/access forbidden from this IP|only.*local/i); - } else { - expect(deleted).toContain('Deleted'); - } } finally { await client.callTool({ name: 'unban_ip', arguments: { ip: testIp } }).catch(() => undefined); } diff --git a/packages/crowdsec-mcp/tests/registry-watcher.test.ts b/packages/crowdsec-mcp/tests/registry-watcher.test.ts index 083b5a051..2428c118c 100644 --- a/packages/crowdsec-mcp/tests/registry-watcher.test.ts +++ b/packages/crowdsec-mcp/tests/registry-watcher.test.ts @@ -23,11 +23,11 @@ describe('watcher registry', () => { expect(tools.map((tool) => tool.name)).toEqual([ 'crowdsec_status', 'list_decisions', + 'get_decision', 'ban_ip', 'unban_ip', 'list_alerts', 'get_alert', - 'delete_alerts', 'list_allowlists', 'get_allowlist', 'check_allowlist' @@ -43,5 +43,48 @@ describe('watcher registry', () => { 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, + 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('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 index cafa1dcd1..0811d642c 100644 --- a/packages/crowdsec-mcp/tests/registry.test.ts +++ b/packages/crowdsec-mcp/tests/registry.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { z } from 'zod'; -import { selectTools } from '../src/registry.js'; +import { cleanErrorMessage, errorResult, selectTools } from '../src/registry.js'; import type { ToolDefinition } from '../src/types.js'; import { makeConfig, makeContext } from './helpers.js'; @@ -75,3 +75,27 @@ describe('registry', () => { 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/tools/alerts.test.ts b/packages/crowdsec-mcp/tests/tools/alerts.test.ts index ca4afccbc..674bb89e5 100644 --- a/packages/crowdsec-mcp/tests/tools/alerts.test.ts +++ b/packages/crowdsec-mcp/tests/tools/alerts.test.ts @@ -1,6 +1,9 @@ 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: { @@ -33,7 +36,7 @@ describe('alertTools', () => { mockWatcher = createMockWatcher([ { id: 1, - created_at: '2024-01-15T10:30:00Z', + created_at: CREATED_AT, machine_id: 'machine-1', simulated: false, remediation: true, @@ -57,7 +60,7 @@ describe('alertTools', () => { value: '192.168.1.100', origin: 'crowdsec', duration: '4h', - until: '2024-01-15T14:30:00Z', + until: UNTIL, simulated: false }, { @@ -66,7 +69,7 @@ describe('alertTools', () => { value: '192.168.1.100', origin: 'crowdsec', duration: '1h', - until: '2024-01-15T11:30:00Z', + until: UNTIL, simulated: true } ], @@ -91,7 +94,6 @@ describe('alertTools', () => { ip: '192.168.1.100', scenario: 'crowdsecurity/ssh-bf', type: 'ban', - simulated: true, has_active_decision: true, limit: 10 }, @@ -103,13 +105,42 @@ describe('alertTools', () => { ip: '192.168.1.100', scenario: 'crowdsecurity/ssh-bf', decision_type: 'ban', - simulated: true, has_active_decision: true, - limit: 10 + 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); @@ -121,6 +152,23 @@ describe('alertTools', () => { ); }); + 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); @@ -157,10 +205,79 @@ describe('alertTools', () => { 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 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: '2024-01-15T10:30:00Z', + created_at: CREATED_AT, machine_id: 'machine-1', simulated: false, remediation: true, @@ -181,6 +298,23 @@ describe('alertTools', () => { 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', () => { @@ -221,7 +355,7 @@ describe('alertTools', () => { 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', 'Duration', 'Until', 'Simulated']) { + for (const col of ['Type', 'Scope', 'Value', 'Origin', 'Remaining', 'Until', 'Simulated']) { expect(decisionHeader).toContain(col); } expect(result).toContain('ban'); @@ -246,7 +380,7 @@ describe('alertTools', () => { const watcher = createMockWatcher([ { id: 2, - created_at: '2024-01-15T10:30:00Z', + created_at: CREATED_AT, machine_id: 'machine-1', simulated: false, remediation: false, @@ -270,11 +404,52 @@ describe('alertTools', () => { 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: '2024-01-15T10:30:00Z', + created_at: CREATED_AT, machine_id: 'machine-1', simulated: false, remediation: false, @@ -320,6 +495,103 @@ describe('alertTools', () => { expect(result).toContain('Deleted 5 alert(s).'); }); + it('should delete all alerts for a scope when scope is provided alone', async () => { + const tool = alertTools.find((t) => t.name === 'delete_alerts')!; + mockWatcher.Alerts.search.mockResolvedValue([ + { id: 1, source: { scope: 'Range', value: '198.51.100.0/24' } }, + { id: 2, source: { scope: 'Ip', value: '198.51.100.1' } } + ]); + mockWatcher.Alerts.deleteById.mockResolvedValue({ nbDeleted: '1' }); + + const result = await tool.handler({ scope: 'Range' }, mockCtx); + + expect(mockWatcher.Alerts.search).toHaveBeenCalledWith({ scope: 'Range', limit: 0 }); + expect(mockWatcher.Alerts.deleteById).toHaveBeenCalledWith('1'); + expect(result).toContain('Deleted 1 alert(s).'); + }); + + it('should delete alerts by origin through a search', async () => { + const tool = alertTools.find((t) => t.name === 'delete_alerts')!; + mockWatcher.Alerts.search.mockResolvedValue([{ id: 11 }, { id: 12 }]); + mockWatcher.Alerts.deleteById.mockResolvedValue({ nbDeleted: '1' }); + + const result = await tool.handler({ origin: 'cscli' }, mockCtx); + + expect(mockWatcher.Alerts.search).toHaveBeenCalledWith( + expect.objectContaining({ + origin: 'cscli', + limit: 0 + }) + ); + expect(mockWatcher.Alerts.deleteById).toHaveBeenCalledTimes(2); + expect(mockWatcher.Alerts.delete).not.toHaveBeenCalled(); + expect(result).toContain('Deleted 2 alert(s).'); + }); + + it('should delete alerts by decision type and time filters through a search', 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' }, 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 delete alerts with active decisions through a search', async () => { + const tool = alertTools.find((t) => t.name === 'delete_alerts')!; + mockWatcher.Alerts.search.mockResolvedValue([{ id: 21 }]); + mockWatcher.Alerts.deleteById.mockResolvedValue({ nbDeleted: '1' }); + + const result = await tool.handler({ has_active_decision: true }, mockCtx); + + expect(mockWatcher.Alerts.search).toHaveBeenCalledWith( + expect.objectContaining({ + has_active_decision: true, + limit: 0 + }) + ); + expect(result).toContain('Deleted 1 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', 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')!; @@ -338,7 +610,7 @@ describe('alertTools', () => { 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) or all=true is required' + 'At least one filter (ip, range, scope, value, scenario, origin, type, since, until, has_active_decision) or all=true is required' ); }); diff --git a/packages/crowdsec-mcp/tests/tools/allowlists.test.ts b/packages/crowdsec-mcp/tests/tools/allowlists.test.ts index d143bf063..cee5936c7 100644 --- a/packages/crowdsec-mcp/tests/tools/allowlists.test.ts +++ b/packages/crowdsec-mcp/tests/tools/allowlists.test.ts @@ -161,7 +161,22 @@ describe('allowlistTools', () => { expect(result).not.toContain('"items"'); }); - it('should show only info and _No items._ when with_content=false even if items exist', async () => { + 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', @@ -175,7 +190,7 @@ describe('allowlistTools', () => { expect(mockAllowlists.get).toHaveBeenCalledWith('test-allowlist', false); expect(result).toContain('**test-allowlist**'); - expect(result).toContain('_No items._'); + expect(result).toContain('_Content hidden (with_content=false)._'); expect(result).not.toContain('Value | Comment'); }); @@ -242,7 +257,7 @@ describe('allowlistTools', () => { expect(mockAllowlists.check).toHaveBeenCalledWith('192.168.1.1'); expect(mockAllowlists.bulkCheck).not.toHaveBeenCalled(); expect(result).toContain('192.168.1.1: allowlisted by test-allowlist: Local network'); - expect(result).toContain('1 target of 1 target allowlisted.'); + expect(result).toContain('1 target of 1 target matched an allowlist.'); }); it('should fall back to a generic label when a single check has no reason', async () => { @@ -251,7 +266,7 @@ describe('allowlistTools', () => { const result = (await findTool('check_allowlist').handler({ value: '192.168.1.1' }, mockCtx)) as string; expect(result).toContain('192.168.1.1: allowlisted by allowlist'); - expect(result).toContain('1 target of 1 target allowlisted.'); + expect(result).toContain('1 target of 1 target matched an allowlist.'); }); it('should check a single value that is not allowlisted', async () => { @@ -260,7 +275,26 @@ describe('allowlistTools', () => { 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 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.check.mockResolvedValue({ + allowlisted: true, + reason: '192.0.2.10' + } satisfies APITypes.CheckAllowlistResponse); + + 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.check.mockResolvedValue({ allowlisted: false } satisfies APITypes.CheckAllowlistResponse); + + await findTool('check_allowlist').handler({ value: ' 192.168.1.1 ' }, mockCtx); + + expect(mockAllowlists.check).toHaveBeenCalledWith('192.168.1.1'); }); it('should treat bulk targets missing from results as not allowlisted', async () => { @@ -278,9 +312,9 @@ describe('allowlistTools', () => { 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: allowlisted by allowlist1: Private, allowlist2: RFC1918'); + 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 allowlisted.'); + expect(result).toContain('2 targets of 3 targets matched an allowlist.'); }); it('should preserve input order of bulk targets in output', async () => { @@ -303,6 +337,27 @@ describe('allowlistTools', () => { 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(); diff --git a/packages/crowdsec-mcp/tests/tools/decisions.test.ts b/packages/crowdsec-mcp/tests/tools/decisions.test.ts index 69a280954..8e742a46c 100644 --- a/packages/crowdsec-mcp/tests/tools/decisions.test.ts +++ b/packages/crowdsec-mcp/tests/tools/decisions.test.ts @@ -41,6 +41,25 @@ describe('decisionTools', () => { ...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(); }); @@ -61,7 +80,7 @@ describe('decisionTools', () => { origin: 'cscli', scenario: 'manual ban', duration: '4h', - until: '2025-01-01T00:00:00Z', + until: new Date(Date.now() + 60 * 60 * 1000).toISOString(), simulated: false } ] @@ -76,12 +95,14 @@ describe('decisionTools', () => { expect(ctx.watcher!.Alerts.search).toHaveBeenCalledWith( expect.objectContaining({ - limit: 10, + 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 () => { @@ -94,7 +115,7 @@ describe('decisionTools', () => { origin: 'cscli', scenario: 'manual ban', duration: '4h', - until: '2025-01-01T00:00:00Z', + until: new Date(Date.now() + 60 * 60 * 1000).toISOString(), simulated: false } ]; @@ -116,6 +137,59 @@ describe('decisionTools', () => { ); expect(result).toContain('ban'); expect(result).toContain('1.2.3.4'); + expect(result).not.toContain('never'); + }); + + 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 () => { @@ -131,7 +205,7 @@ describe('decisionTools', () => { origin: 'cscli', scenario: 'test', duration: '4h', - until: '2025-01-01T00:00:00Z', + until: new Date(Date.now() + 60 * 60 * 1000).toISOString(), simulated: false }, { @@ -142,7 +216,7 @@ describe('decisionTools', () => { origin: 'cscli', scenario: 'test', duration: '4h', - until: '2025-01-01T00:00:00Z', + until: new Date(Date.now() + 60 * 60 * 1000).toISOString(), simulated: false } ] @@ -158,7 +232,7 @@ describe('decisionTools', () => { origin: 'cscli', scenario: 'test', duration: '4h', - until: '2025-01-01T00:00:00Z', + until: new Date(Date.now() + 60 * 60 * 1000).toISOString(), simulated: false } ] @@ -171,9 +245,9 @@ describe('decisionTools', () => { const tool = findTool('list_decisions'); const result = await tool!.handler({}, ctx as any); - // Should only show 1 decision due to deduplication - expect(result).toContain('1 decision total'); - expect(result.match(/1\.2\.3\.4/g)?.length).toBe(1); + // 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 () => { @@ -188,7 +262,7 @@ describe('decisionTools', () => { origin: 'cscli', scenario: 'test', duration: '4h', - until: '2025-01-01T00:00:00Z', + until: new Date(Date.now() + 60 * 60 * 1000).toISOString(), simulated: false } ] @@ -198,11 +272,73 @@ describe('decisionTools', () => { ctx.watcher!.Alerts.search.mockResolvedValue(mockAlerts); const tool = findTool('list_decisions'); - const result = await tool!.handler({ all: true }, ctx as any); + 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([]); @@ -214,7 +350,7 @@ describe('decisionTools', () => { range: '192.168.1.0/24', scope: 'Ip', value: '1.2.3.4', - type: 'ban', + type: 'Ban', origin: 'cscli', scenario: 'test', since: '24h', @@ -244,6 +380,53 @@ describe('decisionTools', () => { }); }); + 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(); @@ -302,27 +485,21 @@ describe('decisionTools', () => { ); }); - it('should give ip precedence over range and value like cscli', async () => { + it('should reject multiple target forms', async () => { const ctx = createMockContext(); - ctx.watcher!.Alerts.pushAlerts.mockResolvedValue(['alert-123']); - ctx.allowlists.check.mockResolvedValue({ allowlisted: false }); + 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 tool!.handler({ ip: '1.2.3.4', range: '10.0.0.0/8', value: '5.6.7.8', scope: 'Range' }, ctx as any); - expect(ctx.allowlists.check).toHaveBeenCalledWith('1.2.3.4'); - expect(ctx.watcher!.Alerts.pushAlerts).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ - decisions: expect.arrayContaining([ - expect.objectContaining({ - scope: 'Ip', - value: '1.2.3.4' - }) - ]) - }) - ]) - ); + 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 () => { @@ -339,6 +516,53 @@ describe('decisionTools', () => { await expect(tool!.handler({ range: 'not-a-cidr' }, ctx as any)).rejects.toThrow('not-a-cidr is not a valid range'); }); + it('should reject 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" + ); + 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 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' }); @@ -532,15 +756,36 @@ describe('decisionTools', () => { await expect(tool!.handler({ all: true }, ctx as any)).rejects.toThrow('Deleting all decisions requires confirm=true'); }); - it('should delete all decisions when all=true and confirm=true', async () => { + it('should delete all active decisions when all=true and confirm=true', async () => { const ctx = createMockContext(); - ctx.watcher!.Decisions.delete.mockResolvedValue({ nbDeleted: '5' }); + 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('5 decision(s) deleted'); - expect(ctx.watcher!.Decisions.delete).toHaveBeenCalledWith({}); + expect(result).toContain('2 decision(s) deleted'); + expect(ctx.watcher!.Decisions.deleteById).toHaveBeenCalledTimes(2); + expect(ctx.watcher!.Decisions.delete).not.toHaveBeenCalled(); + }); + + it('should delete all active decisions for a scope when scope is provided alone', async () => { + const ctx = createMockContext(); + ctx.watcher!.Alerts.search.mockResolvedValue([ + { id: 1, decisions: [{ id: 11, scope: 'Range', type: 'ban' }] }, + { id: 2, decisions: [{ id: 12, scope: 'Ip', type: 'ban' }] } + ]); + ctx.watcher!.Decisions.deleteById.mockResolvedValue({ nbDeleted: '1' }); + const tool = findTool('unban_ip'); + + const result = await tool!.handler({ scope: 'Range' }, ctx as any); + + expect(ctx.watcher!.Decisions.deleteById).toHaveBeenCalledWith('11'); + expect(ctx.watcher!.Decisions.deleteById).toHaveBeenCalledTimes(1); + expect(result).toContain('1 decision(s) deleted'); }); it('should reject all=true combined with filters', async () => { @@ -554,7 +799,8 @@ describe('decisionTools', () => { it('should delete by id when provided', async () => { const ctx = createMockContext(); - ctx.watcher!.Decisions.deleteById.mockResolvedValue({}); + ctx.watcher!.Alerts.search.mockResolvedValue([{ id: 1, decisions: [{ id: 123 }] }]); + ctx.watcher!.Decisions.deleteById.mockResolvedValue({ nbDeleted: '1' }); const tool = findTool('unban_ip'); const result = await tool!.handler({ id: '123' }, ctx as any); @@ -563,6 +809,38 @@ describe('decisionTools', () => { expect(ctx.watcher!.Decisions.deleteById).toHaveBeenCalledWith('123'); }); + it('should not report a replay as a successful deletion', async () => { + const ctx = createMockContext(); + ctx.watcher!.Alerts.search.mockResolvedValue([{ id: 1, decisions: [{ id: 123 }] }]); + 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', 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' }); @@ -579,6 +857,13 @@ describe('decisionTools', () => { ); }); + 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 reject when no id, no filters, and not all', async () => { const ctx = createMockContext(); const tool = findTool('unban_ip'); @@ -588,33 +873,37 @@ describe('decisionTools', () => { ); }); - it('should pass all filter types to delete', async () => { + 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( - { - ip: '1.2.3.4', - range: '192.168.1.0/24', - scope: 'Ip', - value: '1.2.3.4', - type: 'ban', - scenario: 'test' - }, - ctx as any - ); + await tool!.handler({ scope: 'Ip', value: '1.2.3.4', type: 'Ban', scenario: 'test' }, ctx as any); - expect(ctx.watcher!.Decisions.delete).toHaveBeenCalledWith( + 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 via an active decision search', async () => { + const ctx = createMockContext(); + ctx.watcher!.Alerts.search.mockResolvedValue([{ id: 1, decisions: [{ id: 21 }] }]); + ctx.watcher!.Decisions.deleteById.mockResolvedValue({ nbDeleted: '1' }); + const tool = findTool('unban_ip'); + + const result = await tool!.handler({ scope: 'Country', value: 'DE' }, 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', - type: 'ban', - scenario: 'test' + scope: 'Country', + value: 'DE', + has_active_decision: true, + limit: 0 }) ); + expect(ctx.watcher!.Decisions.deleteById).toHaveBeenCalledWith('21'); + 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 index 7f16c7053..d2ea5c3ca 100644 --- a/packages/crowdsec-mcp/tests/transports/http.test.ts +++ b/packages/crowdsec-mcp/tests/transports/http.test.ts @@ -2,13 +2,23 @@ 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 makeServer(): McpServer { - return new McpServer({ name: 'test', version: '1.0.0' }); +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 { @@ -76,6 +86,10 @@ function initializeBody(): string { }); } +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); @@ -103,15 +117,62 @@ describe('isAuthorizedHeader', () => { }); describe('http transport authentication', () => { - it('throws when no token is configured (before listening)', async () => { - await expect(startHttpServer(makeServer(), makeConfig({ http: { host: '127.0.0.1', port: 0, token: undefined } }))).rejects.toThrow( - /bearer token/i + 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 mcp = makeServer(); - const server = await startHttpServer(mcp, makeConfig({ transport: 'http', http: { host: '127.0.0.1', port: 0, token: TOKEN } })); + const server = await startHttpServer(makeServerFactory(), httpConfig()); try { const port = portOf(server); @@ -129,13 +190,11 @@ describe('http transport authentication', () => { expect(noAuthMethod.status).toBe(401); } finally { await closeServer(server); - await mcp.close(); } }); it('returns 401 with a wrong token', async () => { - const mcp = makeServer(); - const server = await startHttpServer(mcp, makeConfig({ transport: 'http', http: { host: '127.0.0.1', port: 0, token: TOKEN } })); + const server = await startHttpServer(makeServerFactory(), httpConfig()); try { const port = portOf(server); @@ -147,13 +206,11 @@ describe('http transport authentication', () => { expect(wrongScheme.status).toBe(401); } finally { await closeServer(server); - await mcp.close(); } }); it('accepts a valid token: initialize POST is not 401 and unknown route is 404', async () => { - const mcp = makeServer(); - const server = await startHttpServer(mcp, makeConfig({ transport: 'http', http: { host: '127.0.0.1', port: 0, token: TOKEN } })); + const server = await startHttpServer(makeServerFactory(), httpConfig()); try { const port = portOf(server); @@ -170,25 +227,21 @@ describe('http transport authentication', () => { expect(other.status).toBe(404); } finally { await closeServer(server); - await mcp.close(); } }); it('returns 405 for unsupported methods after auth', async () => { - const mcp = makeServer(); - const server = await startHttpServer(mcp, makeConfig({ transport: 'http', http: { host: '127.0.0.1', port: 0, token: TOKEN } })); + 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); - await mcp.close(); } }); it('rejects invalid JSON with 400 after auth', async () => { - const mcp = makeServer(); - const server = await startHttpServer(mcp, makeConfig({ transport: 'http', http: { host: '127.0.0.1', port: 0, token: TOKEN } })); + const server = await startHttpServer(makeServerFactory(), httpConfig()); try { const res = await request(portOf(server), { method: 'POST', @@ -200,7 +253,26 @@ describe('http transport authentication', () => { expect(JSON.parse(res.body)).toMatchObject({ jsonrpc: '2.0', error: { code: -32700 } }); } finally { await closeServer(server); - await mcp.close(); + } + }); + + 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 index 5a16ae722..2d504ac63 100644 --- a/packages/crowdsec-mcp/tests/utils.test.ts +++ b/packages/crowdsec-mcp/tests/utils.test.ts @@ -1,5 +1,14 @@ import { describe, expect, it } from 'vitest'; -import { formatDuration, isValidCidr, isValidIp, normalizeScope, parseDuration, splitList } from '../src/utils.js'; +import { + assertValidScopeValue, + formatDuration, + isValidCidr, + isValidIp, + isValidTimeFilter, + normalizeScope, + parseDuration, + splitList +} from '../src/utils.js'; describe('utils', () => { it('parses cscli durations', () => { @@ -7,7 +16,6 @@ describe('utils', () => { expect(parseDuration('4h')).toBe(14400); expect(parseDuration('7d')).toBe(604800); expect(parseDuration('1h30m')).toBe(5400); - expect(parseDuration('90')).toBe(90); }); it('rejects invalid durations', () => { @@ -15,6 +23,21 @@ describe('utils', () => { 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"); + }); + + 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', () => { @@ -47,6 +70,16 @@ describe('utils', () => { 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(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d508c7ecb..d8ecc952b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -166,6 +166,9 @@ 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) @@ -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) @@ -453,6 +459,9 @@ importers: 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'} @@ -469,6 +478,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'} @@ -507,12 +525,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'} @@ -773,6 +798,9 @@ 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'} @@ -790,6 +818,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} @@ -898,6 +930,10 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@sindresorhus/is@4.6.0': + resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} + engines: {node: '>=10'} + '@tsconfig/node-lts@24.0.1': resolution: {integrity: sha512-xo+1KtV5zVb90fyyeglG1n5cqg3cQXLlCkEpLptYlZUNk28xmwhuD5GmxoXwmrVtUTvaMyclun9kAd0TK7H+Pg==} @@ -1086,14 +1122,25 @@ packages: 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==} @@ -1171,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'} @@ -1178,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'} @@ -1200,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==} @@ -1300,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'} @@ -1308,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'} @@ -1482,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==} @@ -1580,6 +1668,9 @@ 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'} @@ -1787,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'} @@ -1837,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} @@ -1857,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} @@ -1929,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'} @@ -2000,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'} @@ -2064,6 +2194,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==} @@ -2125,6 +2259,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'} @@ -2159,6 +2297,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==} @@ -2182,6 +2324,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'} @@ -2248,6 +2397,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'} @@ -2259,6 +2413,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'} @@ -2269,6 +2427,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'} @@ -2397,10 +2559,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'} @@ -2434,6 +2604,8 @@ packages: snapshots: + '@andrewbranch/untar.js@1.0.4': {} + '@apidevtools/json-schema-ref-parser@14.0.1': dependencies: '@types/json-schema': 7.0.15 @@ -2453,6 +2625,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': {} @@ -2474,6 +2667,8 @@ snapshots: '@biomejs/wasm-nodejs@2.5.2': {} + '@braidai/lang@1.1.2': {} + '@cacheable/memory@2.2.0': dependencies: '@cacheable/utils': 2.5.0 @@ -2486,6 +2681,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 @@ -2670,6 +2868,10 @@ 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) @@ -2696,6 +2898,10 @@ snapshots: '@pkgr/core@0.3.6': {} + '@publint/pack@0.1.7': + dependencies: + tinyexec: 1.3.1 + '@rolldown/binding-android-arm64@1.2.4': optional: true @@ -2760,6 +2966,8 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@sindresorhus/is@4.6.0': {} + '@tsconfig/node-lts@24.0.1': {} '@tsconfig/node10@1.0.13': {} @@ -2967,12 +3175,20 @@ snapshots: 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: {} @@ -3074,16 +3290,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 @@ -3102,6 +3350,8 @@ snapshots: dependencies: delayed-stream: 1.0.0 + commander@10.0.1: {} + concat-map@0.0.1: {} confbox@0.2.4: {} @@ -3171,10 +3421,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: {} @@ -3393,6 +3647,8 @@ snapshots: optionalDependencies: picomatch: 4.0.7 + fflate@0.8.3: {} + file-entry-cache@11.1.5: dependencies: flat-cache: 6.1.23 @@ -3478,8 +3734,7 @@ snapshots: gopd@1.2.0: {} - has-flag@4.0.0: - optional: true + has-flag@4.0.0: {} has-symbols@1.1.0: {} @@ -3495,6 +3750,8 @@ snapshots: dependencies: function-bind: 1.1.2 + highlight.js@10.7.3: {} + hono@4.13.14: {} hookified@1.15.1: {} @@ -3664,6 +3921,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: @@ -3701,8 +3971,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: {} @@ -3711,6 +3989,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 @@ -3791,10 +4076,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: {} @@ -3847,6 +4142,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: {} @@ -3924,6 +4226,10 @@ snapshots: transitivePeerDependencies: - supports-color + sade@1.8.1: + dependencies: + mri: 1.2.0 + safer-buffer@2.1.2: {} semver@7.8.5: {} @@ -4015,6 +4321,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: @@ -4043,7 +4353,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: {} @@ -4099,6 +4413,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: {} @@ -4161,12 +4483,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: @@ -4175,6 +4501,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): @@ -4242,8 +4570,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 diff --git a/readme.md b/readme.md index aa7c39b37..718bec434 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,11 @@ ![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 +The [technical documentation](https://thib3113.github.io/node-crowdsec/) is available online. -Technical documentation is available [here](https://thib3113.github.io/node-crowdsec/) +For agents and LLM tooling, see [llms.txt](./llms.txt). ## Features @@ -28,6 +31,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 +41,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 +51,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, From 46755e53a532fc79dac4d36cff156c106c61f02c Mon Sep 17 00:00:00 2001 From: thibaut severac Date: Tue, 27 Oct 2026 10:13:13 +0100 Subject: [PATCH 03/24] fix(ci): pin actions and update axios - pin external actions to commit SHAs and harden release caches - upgrade crowdsec-client to axios 1.21.0 / follow-redirects 1.16.2 - escape backslashes in Markdown table cells - pin zizmor and snyk-agent-scan versions --- .github/actions/build_and_test/action.yml | 10 +++--- .github/workflows/CI.yml | 36 +++++++++++----------- .github/workflows/actions-security.yml | 11 +++---- .github/workflows/codeql.yml | 8 ++--- .github/workflows/docker-mcp.yml | 16 +++++----- .github/workflows/documentation.yml | 10 +++--- .github/workflows/mcp-scan.yml | 10 +++--- .github/workflows/release.yml | 29 +++++++++-------- .github/workflows/scorecard.yml | 8 ++--- packages/crowdsec-client/package.json | 2 +- packages/crowdsec-mcp/src/format/common.ts | 5 ++- packages/crowdsec-mcp/tests/format.test.ts | 1 + pnpm-lock.yaml | 18 +++++------ pnpm-workspace.yaml | 2 ++ 14 files changed, 89 insertions(+), 77 deletions(-) diff --git a/.github/actions/build_and_test/action.yml b/.github/actions/build_and_test/action.yml index 3ebf94b37..0bb24cedf 100644 --- a/.github/actions/build_and_test/action.yml +++ b/.github/actions/build_and_test/action.yml @@ -25,11 +25,11 @@ 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: @@ -41,7 +41,7 @@ runs: run: | 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 }} @@ -86,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: | @@ -97,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/workflows/CI.yml b/.github/workflows/CI.yml index e1dc33101..243a3ad02 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -23,14 +23,14 @@ 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: @@ -42,7 +42,7 @@ jobs: run: | 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 }} @@ -58,12 +58,12 @@ jobs: 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: @@ -80,14 +80,14 @@ 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: @@ -128,7 +128,7 @@ 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 - uses: ./.github/actions/build_and_test with: node-version: ${{ matrix.node-version }} @@ -154,7 +154,7 @@ 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/actions/build_and_test @@ -173,14 +173,14 @@ jobs: security-events: write runs-on: ubuntu-latest 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: @@ -192,7 +192,7 @@ jobs: run: | 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 }} @@ -203,12 +203,12 @@ jobs: - name: Install dependencies 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 diff --git a/.github/workflows/actions-security.yml b/.github/workflows/actions-security.yml index a907017d6..0f68f5a2e 100644 --- a/.github/workflows/actions-security.yml +++ b/.github/workflows/actions-security.yml @@ -14,20 +14,19 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Lint workflows - uses: raven-actions/actionlint@v2.2.0 + uses: raven-actions/actionlint@3d39aea434753780c3b3d4a1a31c854b4dbf49d7 # v2.2.0 zizmor: runs-on: ubuntu-latest - continue-on-error: true steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Install uv - uses: astral-sh/setup-uv@v10.3.2 + uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 - name: Audit workflows - run: uvx zizmor --format=github .github/workflows + run: uvx 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..ec3be6927 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -40,11 +40,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 +58,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 +71,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 index fd22a3de7..08bc8bfcf 100644 --- a/.github/workflows/docker-mcp.yml +++ b/.github/workflows/docker-mcp.yml @@ -46,17 +46,17 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Log in to GitHub Container Registry if: github.event_name != 'pull_request' - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: registry: ghcr.io username: ${{ github.actor }} @@ -64,7 +64,7 @@ jobs: - name: Extract metadata id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 with: images: ghcr.io/${{ github.repository_owner }}/crowdsec-mcp tags: | @@ -75,7 +75,7 @@ jobs: - name: Build and push id: build - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . file: packages/crowdsec-mcp/Dockerfile @@ -92,4 +92,6 @@ jobs: - name: Smoke test if: github.event_name == 'pull_request' - run: docker run --rm "${{ fromJSON(steps.meta.outputs.json).tags[0] }}" --version + 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 5cf9683fe..9d393fb00 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -18,14 +18,14 @@ 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: @@ -37,7 +37,7 @@ jobs: run: | 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 }} @@ -59,7 +59,7 @@ jobs: - 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-scan.yml b/.github/workflows/mcp-scan.yml index 7ece235a9..f3943d1b0 100644 --- a/.github/workflows/mcp-scan.yml +++ b/.github/workflows/mcp-scan.yml @@ -12,14 +12,14 @@ jobs: timeout-minutes: 15 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 with: run_install: false @@ -49,9 +49,9 @@ jobs: JSON - name: Install uv - uses: astral-sh/setup-uv@v10.3.2 + uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 - name: Scan MCP server - run: uvx snyk-agent-scan@latest scan mcp.json --ci --dangerously-run-mcp-servers --no-skills + run: uvx 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 7eb14f0a0..ca4952ed5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,14 +20,15 @@ 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: @@ -39,13 +40,14 @@ jobs: run: | 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 --frozen-lockfile @@ -55,12 +57,13 @@ jobs: 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: @@ -88,14 +91,15 @@ 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: @@ -107,13 +111,14 @@ jobs: run: | 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 --frozen-lockfile @@ -121,7 +126,7 @@ jobs: - 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 index 7386e8375..b2b891e4e 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -19,25 +19,25 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: persist-credentials: false - name: Run OpenSSF Scorecard - uses: ossf/scorecard-action@v2.5.0 + 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@v7 + 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@v4 + uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4 with: sarif_file: results.sarif diff --git a/packages/crowdsec-client/package.json b/packages/crowdsec-client/package.json index 5234a9ea9..d23619713 100644 --- a/packages/crowdsec-client/package.json +++ b/packages/crowdsec-client/package.json @@ -64,7 +64,7 @@ ], "license": "MIT", "dependencies": { - "axios": "^1.20.0", + "axios": "^1.21.0", "debug": "^4.4.3" }, "engines": { diff --git a/packages/crowdsec-mcp/src/format/common.ts b/packages/crowdsec-mcp/src/format/common.ts index 0a0944118..2b974b581 100644 --- a/packages/crowdsec-mcp/src/format/common.ts +++ b/packages/crowdsec-mcp/src/format/common.ts @@ -5,7 +5,10 @@ export function escapeCell(value: unknown): string { return '—'; } - return String(value).replace(/\|/g, '\\|').replace(/\r?\n/g, ' '); + return String(value) + .replaceAll('\\', '\\\\') + .replaceAll('|', '\\|') + .replace(/[\r\n]+/g, ' '); } export function markdownTable(headers: string[], rows: string[][]): string { diff --git a/packages/crowdsec-mcp/tests/format.test.ts b/packages/crowdsec-mcp/tests/format.test.ts index ed523e9d4..68f0c1f6c 100644 --- a/packages/crowdsec-mcp/tests/format.test.ts +++ b/packages/crowdsec-mcp/tests/format.test.ts @@ -4,6 +4,7 @@ import { escapeCell, expiry, formatDate, markdownTable, moreHint, plural, relati 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('—'); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d8ecc952b..1561d639a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -294,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) @@ -1157,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==} @@ -1587,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: '*' @@ -3203,9 +3203,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 @@ -3677,7 +3677,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) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 353e0e29b..467bb1485 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,3 +4,5 @@ packages: - 'packages/*' allowBuilds: esbuild: true +minimumReleaseAgeExclude: + - axios@1.21.0 From 68f8ced234cfc40ff14f21ad989def884ced5fd4 Mon Sep 17 00:00:00 2001 From: thibaut severac Date: Tue, 27 Oct 2026 10:36:50 +0100 Subject: [PATCH 04/24] fix(ci): address Sonar security findings - run uvx tools with --no-build and pin their versions - use explicit permissions in the Scorecard workflow - simplify the duration regex and a few lint findings --- .github/workflows/actions-security.yml | 2 +- .github/workflows/mcp-scan.yml | 2 +- .github/workflows/scorecard.yml | 3 ++- packages/crowdsec-mcp/src/format/alerts.ts | 2 +- packages/crowdsec-mcp/src/format/common.ts | 6 ++++-- packages/crowdsec-mcp/src/registry.ts | 4 ++-- packages/crowdsec-mcp/src/tools/system.ts | 3 ++- packages/crowdsec-mcp/src/utils.ts | 2 +- 8 files changed, 14 insertions(+), 10 deletions(-) diff --git a/.github/workflows/actions-security.yml b/.github/workflows/actions-security.yml index 0f68f5a2e..6fbb92e35 100644 --- a/.github/workflows/actions-security.yml +++ b/.github/workflows/actions-security.yml @@ -29,4 +29,4 @@ jobs: uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 - name: Audit workflows - run: uvx zizmor==1.31.1 --min-severity high --format=github .github/workflows + run: uvx --no-build zizmor==1.31.1 --min-severity high --format=github .github/workflows diff --git a/.github/workflows/mcp-scan.yml b/.github/workflows/mcp-scan.yml index f3943d1b0..3e3bf90a4 100644 --- a/.github/workflows/mcp-scan.yml +++ b/.github/workflows/mcp-scan.yml @@ -52,6 +52,6 @@ jobs: uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 - name: Scan MCP server - run: uvx snyk-agent-scan==0.6.9 scan mcp.json --ci --dangerously-run-mcp-servers --no-skills + 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/scorecard.yml b/.github/workflows/scorecard.yml index b2b891e4e..178fce1ca 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -7,7 +7,8 @@ on: push: branches: [ main ] -permissions: read-all +permissions: + contents: read jobs: analysis: diff --git a/packages/crowdsec-mcp/src/format/alerts.ts b/packages/crowdsec-mcp/src/format/alerts.ts index 243614ef2..6d8784164 100644 --- a/packages/crowdsec-mcp/src/format/alerts.ts +++ b/packages/crowdsec-mcp/src/format/alerts.ts @@ -121,7 +121,7 @@ function formatMeta(meta?: APITypes.Alert['meta']): string { try { const parsed = JSON.parse(value); if (Array.isArray(parsed)) { - value = parsed.map((v) => String(v)).join(', '); + value = parsed.map(String).join(', '); } else if (typeof parsed === 'object' && parsed !== null) { value = JSON.stringify(parsed); } diff --git a/packages/crowdsec-mcp/src/format/common.ts b/packages/crowdsec-mcp/src/format/common.ts index 2b974b581..811b483e0 100644 --- a/packages/crowdsec-mcp/src/format/common.ts +++ b/packages/crowdsec-mcp/src/format/common.ts @@ -1,13 +1,15 @@ import { formatDuration } from '../utils.js'; +const BACKSLASH = String.fromCharCode(92); + export function escapeCell(value: unknown): string { if (value === undefined || value === null || value === '') { return '—'; } return String(value) - .replaceAll('\\', '\\\\') - .replaceAll('|', '\\|') + .replaceAll(BACKSLASH, BACKSLASH + BACKSLASH) + .replaceAll('|', BACKSLASH + '|') .replace(/[\r\n]+/g, ' '); } diff --git a/packages/crowdsec-mcp/src/registry.ts b/packages/crowdsec-mcp/src/registry.ts index 2fe951be8..1c681813b 100644 --- a/packages/crowdsec-mcp/src/registry.ts +++ b/packages/crowdsec-mcp/src/registry.ts @@ -22,12 +22,12 @@ export function cleanErrorMessage(error: unknown): string { message = message.replace(/^alert not found$/i, 'Alert not found'); message = message.replace(/^while parsing duration.*/i, 'Invalid duration'); - const allowlist = message.match(/^allowlist '(.+)' not found$/i); + const allowlist = /^allowlist '(.+)' not found$/i.exec(message); if (allowlist) { message = `Allowlist '${allowlist[1]}' not found`; } - const decision = message.match(/^decision with id '(.+)' doesn't exist.*/i); + const decision = /^decision with id '(.+)' doesn't exist.*/i.exec(message); if (decision) { message = `Decision ${decision[1]} not found`; } diff --git a/packages/crowdsec-mcp/src/tools/system.ts b/packages/crowdsec-mcp/src/tools/system.ts index 45b2b9fa1..f63ddc0df 100644 --- a/packages/crowdsec-mcp/src/tools/system.ts +++ b/packages/crowdsec-mcp/src/tools/system.ts @@ -16,7 +16,8 @@ export const statusTool: ToolDefinition = { handler: async (_input, ctx) => { await ctx.ensureReady(); - const auth = ctx.mode === 'watcher' ? `watcher${ctx.config.machineId ? ` (${ctx.config.machineId})` : ''}` : 'bouncer'; + const machineSuffix = ctx.mode === 'watcher' && ctx.config.machineId ? ` (${ctx.config.machineId})` : ''; + const auth = ctx.mode === 'watcher' ? `watcher${machineSuffix}` : 'bouncer'; const lines = [ 'CrowdSec MCP is connected.', '', diff --git a/packages/crowdsec-mcp/src/utils.ts b/packages/crowdsec-mcp/src/utils.ts index 25be7608d..e6de4e705 100644 --- a/packages/crowdsec-mcp/src/utils.ts +++ b/packages/crowdsec-mcp/src/utils.ts @@ -1,6 +1,6 @@ import { isIP } from 'node:net'; -const DURATION_PART = /(\d+(?:\.\d+)?)(d|h|m|s)/g; +const DURATION_PART = /(\d+(?:\.\d+)?)([dhms])/g; /** * Parse a cscli-like duration (4h, 30m, 7d, 1h30m) into seconds. From 56ad400d19ade71cc67b19a34026dcc97af92b5c Mon Sep 17 00:00:00 2001 From: thibaut severac Date: Tue, 27 Oct 2026 10:52:39 +0100 Subject: [PATCH 05/24] fix(deps): update vulnerable transitive packages - bump body-parser to 2.4.0, qs to 6.16.1 and fast-uri to 3.1.9 - addresses Snyk findings in the MCP dependency tree --- pnpm-lock.yaml | 35 +++++++++++++---------------------- 1 file changed, 13 insertions(+), 22 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1561d639a..cc4fc8949 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1170,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: @@ -1555,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==} @@ -2142,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.16.1: + resolution: {integrity: sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==} engines: {node: '>=0.6'} range-parser@1.3.0: @@ -3171,7 +3167,7 @@ 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 @@ -3222,7 +3218,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 @@ -3230,7 +3226,7 @@ snapshots: http-errors: 2.0.1 iconv-lite: 0.7.3 on-finished: 2.4.1 - qs: 6.15.3 + qs: 6.16.1 raw-body: 3.0.2 type-is: 2.1.0 transitivePeerDependencies: @@ -3599,7 +3595,7 @@ snapshots: 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 @@ -3618,7 +3614,7 @@ snapshots: once: 1.4.0 parseurl: 1.3.3 proxy-addr: 2.0.8 - qs: 6.16.0 + qs: 6.16.1 range-parser: 1.3.0 router: 2.2.0(supports-color@7.2.0) send: 1.2.1(supports-color@7.2.0) @@ -3641,7 +3637,7 @@ 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: @@ -4157,12 +4153,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.16.1: dependencies: es-define-property: 1.0.1 side-channel: 1.1.1 From 0bbe3380588a5989ad2bfb212d3fafb22b56943e Mon Sep 17 00:00:00 2001 From: thibaut severac Date: Tue, 27 Oct 2026 11:06:09 +0100 Subject: [PATCH 06/24] fix(ci): run CodeQL after Snyk SARIF upload - make the CodeQL job depend on the Snyk security job so code scanning sees its SARIF configurations - keep the standalone CodeQL workflow for the weekly scheduled scan only --- .github/workflows/CI.yml | 26 ++++++++++++++++++++++++++ .github/workflows/codeql.yml | 7 ++----- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 243a3ad02..0cd643b19 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -212,3 +212,29 @@ jobs: 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/codeql.yml b/.github/workflows/codeql.yml index ec3be6927..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' From f8f459b04bc205da8fd1c3d40eabc4e4391d399b Mon Sep 17 00:00:00 2001 From: thibaut severac Date: Tue, 27 Oct 2026 11:33:21 +0100 Subject: [PATCH 07/24] refactor(mcp): clear SonarCloud code smells - split complex handlers into focused helpers - remove async-without-await and awaited loops - simplify duration parsing, ternaries and string handling - inline the Node LTS tsconfig and set the Sonar Python version --- .sonarcloud.properties | 1 + package.json | 1 - packages/crowdsec-mcp/src/allowlists-port.ts | 4 +- packages/crowdsec-mcp/src/client.ts | 18 +- packages/crowdsec-mcp/src/config.ts | 131 +++-- packages/crowdsec-mcp/src/format/alerts.ts | 32 +- packages/crowdsec-mcp/src/format/common.ts | 14 +- packages/crowdsec-mcp/src/format/decisions.ts | 34 +- packages/crowdsec-mcp/src/index.ts | 6 +- packages/crowdsec-mcp/src/tools/alerts.ts | 353 +++++++------ packages/crowdsec-mcp/src/tools/decisions.ts | 465 +++++++++--------- packages/crowdsec-mcp/src/transports/http.ts | 262 +++++----- packages/crowdsec-mcp/src/utils.ts | 73 ++- packages/crowdsec-mcp/tests/helpers.ts | 13 +- .../tests/tools/decisions.test.ts | 30 +- pnpm-lock.yaml | 8 - tsconfig.json | 12 +- 17 files changed, 764 insertions(+), 693 deletions(-) create mode 100644 .sonarcloud.properties 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/package.json b/package.json index 7d5aa8f33..b85af6d24 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,6 @@ "@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", diff --git a/packages/crowdsec-mcp/src/allowlists-port.ts b/packages/crowdsec-mcp/src/allowlists-port.ts index 5af6a1756..f4028b747 100644 --- a/packages/crowdsec-mcp/src/allowlists-port.ts +++ b/packages/crowdsec-mcp/src/allowlists-port.ts @@ -1,9 +1,7 @@ import type { WatcherClient } from 'crowdsec-client'; import type { AllowlistsPort } from './types.js'; -const unavailable = async (): Promise => { - throw new Error('allowlists are not available in bouncer mode'); -}; +const unavailable = (): Promise => Promise.reject(new Error('allowlists are not available in bouncer mode')); export function createAllowlistsPort(watcher: WatcherClient): AllowlistsPort { return { diff --git a/packages/crowdsec-mcp/src/client.ts b/packages/crowdsec-mcp/src/client.ts index 887bdb5d1..427686520 100644 --- a/packages/crowdsec-mcp/src/client.ts +++ b/packages/crowdsec-mcp/src/client.ts @@ -104,9 +104,7 @@ export async function createContext(config: Config): Promise { let loginPromise: Promise | undefined; const ensureReady = async (): Promise => { - if (!loginPromise) { - loginPromise = (watcher ?? bouncer)!.login(); - } + loginPromise ??= (watcher ?? bouncer)!.login(); try { await loginPromise; @@ -136,7 +134,7 @@ export async function createContext(config: Config): Promise { }; } -export async function createPassthroughContext(config: Config, headers: IncomingHttpHeaders): Promise { +export function createPassthroughContext(config: Config, headers: IncomingHttpHeaders): AppContext { const auth = resolvePassthroughAuth(headers); if (auth.mode === 'bouncer') { @@ -149,9 +147,7 @@ export async function createPassthroughContext(config: Config, headers: Incoming let loginPromise: Promise | undefined; const ensureReady = async (): Promise => { - if (!loginPromise) { - loginPromise = bouncer.login(); - } + loginPromise ??= bouncer.login(); try { await loginPromise; @@ -172,7 +168,7 @@ export async function createPassthroughContext(config: Config, headers: Incoming allowlists: unavailableAllowlistsPort(), config, ensureReady, - stop: async () => bouncer.stop() + stop: () => bouncer.stop() }; } @@ -185,9 +181,7 @@ export async function createPassthroughContext(config: Config, headers: Incoming let loginPromise: Promise | undefined; const ensureReady = async (): Promise => { - if (!loginPromise) { - loginPromise = watcher.login(); - } + loginPromise ??= watcher.login(); try { await loginPromise; @@ -208,6 +202,6 @@ export async function createPassthroughContext(config: Config, headers: Incoming allowlists: createAllowlistsPort(watcher), config, ensureReady, - stop: async () => watcher.stop() + stop: () => watcher.stop() }; } diff --git a/packages/crowdsec-mcp/src/config.ts b/packages/crowdsec-mcp/src/config.ts index f8f830fdd..43ab8d0c1 100644 --- a/packages/crowdsec-mcp/src/config.ts +++ b/packages/crowdsec-mcp/src/config.ts @@ -116,61 +116,69 @@ function firstDefined(...values: Array): string | undefined return values.find((value) => value !== undefined && value !== ''); } -export function loadConfig(values: CliValues, env: NodeJS.ProcessEnv = process.env): Config { +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; +} - 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 tlsCert = firstDefined(values['tls-cert'] as string | undefined, env.CROWDSEC_TLS_CERT); - const tlsKey = firstDefined(values['tls-key'] as string | undefined, env.CROWDSEC_TLS_KEY); - const tlsCa = firstDefined(values['tls-ca'] as string | undefined, env.CROWDSEC_TLS_CA); - - 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(); - if (requestedAuth !== undefined && requestedAuth !== 'bouncer' && requestedAuth !== 'watcher') { +function resolveAuth( + requestedAuth: string | undefined, + apiKey: string | undefined, + machineId: string | undefined, + password: string | undefined, + passthrough: boolean +): AuthMode { + if (requestedAuth === 'bouncer' || requestedAuth === 'watcher') { + return requestedAuth; + } + if (requestedAuth !== undefined) { throw new Error(`CROWDSEC_AUTH must be 'bouncer' or 'watcher', got '${requestedAuth}'`); } - - let auth: AuthMode; - if (requestedAuth) { - auth = requestedAuth; - } else if (apiKey) { - auth = 'bouncer'; - } else if (machineId || password) { - auth = 'watcher'; - } else if (passthrough) { + 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. - auth = 'watcher'; - } else { - throw new Error('unable to detect authentication mode: set CROWDSEC_API_KEY or CROWDSEC_MACHINE_ID/CROWDSEC_PASSWORD'); + return 'watcher'; } + throw new Error('unable to detect authentication mode: set CROWDSEC_API_KEY or CROWDSEC_MACHINE_ID/CROWDSEC_PASSWORD'); +} - const tlsValues = [tlsCert, tlsKey, tlsCa]; - const tlsCount = tlsValues.filter(Boolean).length; +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; +} - const tls = tlsCount === 3 ? { cert: tlsCert!, key: tlsKey!, ca: tlsCa! } : 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}'`); @@ -178,26 +186,67 @@ export function loadConfig(values: CliValues, env: NodeJS.ProcessEnv = process.e if (passthrough && transport !== 'http') { throw new Error('passthrough mode requires CROWDSEC_MCP_TRANSPORT=http'); } + return transport; +} - const httpToken = firstDefined(values['http-token'] as string | undefined, env.CROWDSEC_MCP_HTTP_TOKEN); +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 auth = resolveAuth(requestedAuth, apiKey, machineId, password, passthrough); + + 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) + ); + 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 readonlyDefault = readBoolean(env.CROWDSEC_MCP_READONLY, 'CROWDSEC_MCP_READONLY', true); - const readonly = values.write === true ? false : values.readonly === true ? true : readonlyDefault; - const enableAlertDelete = - values['enable-alert-delete'] === true - ? true - : readBoolean(env.CROWDSEC_MCP_ENABLE_ALERT_DELETE, 'CROWDSEC_MCP_ENABLE_ALERT_DELETE', false); - const allowAlertDeleteWithoutToken = - values['allow-alert-delete-without-token'] === true - ? true - : readBoolean(env.CROWDSEC_MCP_ALLOW_ALERT_DELETE_WITHOUT_TOKEN, 'CROWDSEC_MCP_ALLOW_ALERT_DELETE_WITHOUT_TOKEN', false); + 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); diff --git a/packages/crowdsec-mcp/src/format/alerts.ts b/packages/crowdsec-mcp/src/format/alerts.ts index 6d8784164..091395c7e 100644 --- a/packages/crowdsec-mcp/src/format/alerts.ts +++ b/packages/crowdsec-mcp/src/format/alerts.ts @@ -1,6 +1,16 @@ import type { APITypes } from 'crowdsec-client'; import { resolveDecisionExpiry } from '../utils.js'; -import { escapeCell, expiry, formatDate, markdownTable, moreHint, plural, relativeTime, truncateRows } from './common.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) { @@ -73,12 +83,7 @@ export function formatAlertList(alerts: APITypes.GetAlertsResponse, maxResults: }); const summary = plural(displayRows.length, 'alert'); - const hint = - hidden > 0 - ? moreHint(hidden, 'list_alerts') - : maybeMore && sortedAlerts.length >= maxResults - ? '\n_More results may be available. Increase `limit` or narrow the filters._' - : ''; + const hint = formatListHint(hidden, maybeMore, sortedAlerts.length, maxResults); return `${summary}:\n\n${markdownTable(headers, tableRows)}${hint}`; } @@ -90,11 +95,7 @@ function formatDecisionRow(decision: APITypes.Decision): string[] { const origin = decision.origin ?? '—'; const duration = decision.duration ?? '—'; const expiryInfo = resolveDecisionExpiry(decision); - const until = expiryInfo.expires - ? `${formatDate(expiryInfo.expires)} (${expiry(expiryInfo.expires)})` - : expiryInfo.active - ? '—' - : 'expired'; + const until = formatDecisionExpiry(expiryInfo); const simulated = decision.simulated === true ? 'yes' : 'no'; return [ @@ -209,11 +210,8 @@ export function formatAlertDetail(alert: APITypes.Alert): string { lines.push('', `_${inactiveCount} inactive decision(s) hidden._`); } - // Meta - lines.push(formatMeta(alert.meta)); - - // Events - lines.push(formatEvents(alert.events)); + // Meta and events + lines.push(formatMeta(alert.meta), formatEvents(alert.events)); return lines.join('\n'); } diff --git a/packages/crowdsec-mcp/src/format/common.ts b/packages/crowdsec-mcp/src/format/common.ts index 811b483e0..72f10cfb6 100644 --- a/packages/crowdsec-mcp/src/format/common.ts +++ b/packages/crowdsec-mcp/src/format/common.ts @@ -1,13 +1,15 @@ import { formatDuration } from '../utils.js'; -const BACKSLASH = String.fromCharCode(92); +const BACKSLASH = String.fromCodePoint(92); export function escapeCell(value: unknown): string { if (value === undefined || value === null || value === '') { return '—'; } - return String(value) + const text = typeof value === 'object' ? (JSON.stringify(value) ?? String(value)) : String(value); + + return text .replaceAll(BACKSLASH, BACKSLASH + BACKSLASH) .replaceAll('|', BACKSLASH + '|') .replace(/[\r\n]+/g, ' '); @@ -81,6 +83,14 @@ export function expiry(value?: string | Date, now = Date.now()): string { 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 }; diff --git a/packages/crowdsec-mcp/src/format/decisions.ts b/packages/crowdsec-mcp/src/format/decisions.ts index 38b6834a5..d0b6a29ca 100644 --- a/packages/crowdsec-mcp/src/format/decisions.ts +++ b/packages/crowdsec-mcp/src/format/decisions.ts @@ -1,6 +1,6 @@ import type { APITypes } from 'crowdsec-client'; import { resolveDecisionExpiry } from '../utils.js'; -import { escapeCell, expiry, formatDate, markdownTable, plural, truncateRows } from './common.js'; +import { escapeCell, expiry, formatDate, formatDecisionExpiry, markdownTable, plural, truncateRows } from './common.js'; export interface DecisionRow { type: string; @@ -14,11 +14,15 @@ export interface DecisionRow { decisionId?: number; } -export function formatDecisionList(alerts: APITypes.GetAlertsResponse, maxResults: number, maybeMore = false): string { +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 now = Date.now(); - 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; @@ -26,20 +30,13 @@ export function formatDecisionList(alerts: APITypes.GetAlertsResponse, maxResult }); for (const alert of sortedAlerts) { - if (!alert.decisions || alert.decisions.length === 0) { - continue; - } - - for (const decision of alert.decisions) { + for (const decision of alert.decisions ?? []) { const expiryInfo = resolveDecisionExpiry(decision, undefined, now); if (!expiryInfo.active) { continue; } - const key = - decision.id !== undefined - ? `id:${decision.id}` - : `${decision.simulated ?? false}:${decision.type}:${decision.scope}:${decision.value}`; + const key = decisionKey(decision); if (seen.has(key)) { continue; } @@ -59,6 +56,11 @@ export function formatDecisionList(alerts: APITypes.GetAlertsResponse, maxResult } } + 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( @@ -124,11 +126,7 @@ export function formatBouncerDecisionList(decisions: APITypes.GetDecisionsRespon export function formatDecisionDetail(decision: APITypes.Decision, alert?: APITypes.Alert): string { const expiryInfo = resolveDecisionExpiry(decision); - const expires = expiryInfo.expires - ? `${formatDate(expiryInfo.expires)} (${expiry(expiryInfo.expires)})` - : expiryInfo.active - ? '—' - : 'expired'; + const expires = formatDecisionExpiry(expiryInfo); const lines = [ `### Decision #${decision.id ?? '—'}`, diff --git a/packages/crowdsec-mcp/src/index.ts b/packages/crowdsec-mcp/src/index.ts index af49a05e5..f48c46152 100644 --- a/packages/crowdsec-mcp/src/index.ts +++ b/packages/crowdsec-mcp/src/index.ts @@ -77,8 +77,10 @@ async function main(): Promise { log('stdio transport ready'); } -main().catch((error: unknown) => { +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/tools/alerts.ts b/packages/crowdsec-mcp/src/tools/alerts.ts index d89ba2c75..97a359645 100644 --- a/packages/crowdsec-mcp/src/tools/alerts.ts +++ b/packages/crowdsec-mcp/src/tools/alerts.ts @@ -16,12 +16,8 @@ import { } from '../utils.js'; async function deleteAlertIds(ctx: AppContext, ids: number[]): Promise { - let deleted = 0; - for (const id of ids) { - const response = await ctx.watcher!.Alerts.deleteById(String(id)); - deleted += Number(response.nbDeleted ?? 0); - } - return deleted; + 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 @@ -66,6 +62,181 @@ const DeleteAlertsInput = z }) .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); + + 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' + ); + } + + 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 deleteAlertsByScope(ctx: AppContext, filters: NormalizedDeleteAlertInput): Promise { + const alerts = await ctx.watcher!.Alerts.search({ + scope: filters.normalizedScope, + origin: filters.origin, + has_active_decision: filters.has_active_decision, + limit: 0 + }); + const alertIds = alerts + .filter((alert) => alert.source?.scope?.toLowerCase() === filters.normalizedScope?.toLowerCase()) + .map((alert) => alert.id) + .filter((alertId): alertId is number => typeof alertId === 'number'); + + return deleteAlertsByIds(ctx, alertIds); +} + +async function deleteAlertsByCidr(ctx: AppContext, filters: NormalizedDeleteAlertInput, cidr: string): Promise { + const alerts = await ctx.watcher!.Alerts.search({ + ip: cidr, + contains: false, + scope: filters.normalizedScope, + value: filters.normalizedValue, + scenario: filters.scenario, + origin: filters.origin, + has_active_decision: filters.has_active_decision, + limit: 0 + } as APITypes.Alerts.SearchAlerts.RequestQuery & { contains?: boolean }); + + return deleteAlertsByIds( + ctx, + alerts.map((alert) => alert.id).filter((id): id is number => typeof id === 'number') + ); +} + +async function deleteAlertsBySearch(ctx: AppContext, filters: NormalizedDeleteAlertInput): Promise { + const alerts = await ctx.watcher!.Alerts.search({ + ip: filters.ip, + contains: false, + scope: filters.normalizedScope, + value: filters.normalizedValue, + scenario: filters.scenario, + origin: filters.origin, + decision_type: filters.decisionType, + since: filters.since, + until: filters.until, + has_active_decision: filters.has_active_decision, + limit: 0 + } as APITypes.Alerts.SearchAlerts.RequestQuery & { contains?: boolean }); + + return deleteAlertsByIds( + ctx, + alerts.map((alert) => alert.id).filter((id): id is number => typeof id === 'number') + ); +} + +async function deleteAlertsDirect(ctx: AppContext, filters: NormalizedDeleteAlertInput): Promise { + const result = await ctx.watcher!.Alerts.delete({ + ip: filters.ip, + scope: filters.normalizedScope, + value: filters.normalizedValue, + scenario: filters.scenario + }); + + 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', @@ -172,171 +343,27 @@ export const alertTools: ToolDefinition[] = [ }, handler: async (input, ctx) => { await ctx.ensureReady(); - const { id, ip, range, scope, value, scenario, origin, type, since, until, has_active_decision, all, confirm } = - DeleteAlertsInput.parse(input); + const filters = normalizeDeleteInput(DeleteAlertsInput.parse(input)); - if (since && !isValidTimeFilter(since)) { - throw new Error(`invalid since '${since}': use h, m, s or d (e.g. 4h, 30d)`); + if (filters.id !== undefined) { + return deleteAlertsById(ctx, filters.id); } - if (until && !isValidTimeFilter(until)) { - throw new Error(`invalid until '${until}': use h, m, s or d (e.g. 4h, 30d)`); - } - - const decisionType = type ? assertValidDecisionType(type) : undefined; - - if (all === true) { - if (confirm !== true) { - throw new Error('Deleting all alerts requires confirm=true'); - } - if ( - id !== undefined || - ip || - range || - scope || - value || - scenario || - origin || - decisionType || - since || - until || - has_active_decision !== undefined - ) { - throw new Error( - 'Cannot combine all=true with id or filters; use all=true with confirm=true alone to delete everything' - ); - } + if (filters.all === true) { + return deleteAllAlerts(ctx); } - - if (scope && (ip || range)) { - throw new Error('Provide only one of ip, range, or scope'); + if (filters.normalizedScope && !filters.value) { + return deleteAlertsByScope(ctx, filters); } - if (ip && range) { - throw new Error('Provide only one of ip or range'); - } - if (ip && !isValidIp(ip) && !isValidCidr(ip)) { - throw new Error(`${ip} is not a valid ip or CIDR range`); - } - if (range) { - assertValidCidr(range); + const cidr = filters.range ?? (filters.ip && isValidCidr(filters.ip) ? filters.ip : undefined); + if (cidr) { + return deleteAlertsByCidr(ctx, filters, cidr); } - - const normalizedScope = scope ? assertValidDecisionScope(scope) : undefined; - const normalizedValue = normalizedScope && value ? assertValidScopeValue(normalizedScope, value) : value; - - let result: APITypes.Alerts.DeleteAlerts.ResponseBody; - - if (id !== undefined) { - if (ip || range || scope || value || scenario || origin || decisionType || since || until || all === true) { - throw new Error('Cannot combine id with filters or all=true'); - } - - const alertId = parsePositiveId(id, 'alert id'); - try { - result = await ctx.watcher!.Alerts.deleteById(alertId); - } catch (error) { - const message = cleanErrorMessage(error); - if (/not found/i.test(message)) { - throw new Error(`Alert ${alertId} not found`); - } - throw error; - } - } else { - const hasFilter = - ip || - range || - scope || - value || - scenario || - origin || - decisionType || - since || - until || - has_active_decision !== undefined; - if (!hasFilter && all !== true) { - throw new Error( - 'At least one filter (ip, range, scope, value, scenario, origin, type, since, until, has_active_decision) or all=true is required' - ); - } - - if (normalizedScope && !value) { - const alerts = await ctx.watcher!.Alerts.search({ - scope: normalizedScope, - origin, - has_active_decision, - limit: 0 - }); - const alertIds = alerts - .filter((alert) => alert.source?.scope?.toLowerCase() === normalizedScope.toLowerCase()) - .map((alert) => alert.id) - .filter((alertId): alertId is number => typeof alertId === 'number'); - - if (alertIds.length === 0) { - return 'No matching alerts found.'; - } - - const deleted = await deleteAlertIds(ctx, alertIds); - return deleted === 0 ? 'No matching alerts found.' : `Deleted ${deleted} alert(s).`; - } - - const cidr = range ?? (ip && isValidCidr(ip) ? ip : undefined); - if (cidr) { - const alerts = await ctx.watcher!.Alerts.search({ - ip: cidr, - contains: false, - scope: normalizedScope, - value: normalizedValue, - scenario, - origin, - has_active_decision, - limit: 0 - } as APITypes.Alerts.SearchAlerts.RequestQuery & { contains?: boolean }); - - const alertIds = alerts.map((alert) => alert.id).filter((id): id is number => typeof id === 'number'); - if (alertIds.length === 0) { - return 'No matching alerts found.'; - } - - const deleted = await deleteAlertIds(ctx, alertIds); - return deleted === 0 ? 'No matching alerts found.' : `Deleted ${deleted} alert(s).`; - } - - if (origin || decisionType || since || until || has_active_decision !== undefined) { - const alerts = await ctx.watcher!.Alerts.search({ - ip, - contains: false, - scope: normalizedScope, - value: normalizedValue, - scenario, - origin, - decision_type: decisionType, - since, - until, - has_active_decision, - limit: 0 - } as APITypes.Alerts.SearchAlerts.RequestQuery & { contains?: boolean }); - - const alertIds = alerts.map((alert) => alert.id).filter((id): id is number => typeof id === 'number'); - if (alertIds.length === 0) { - return 'No matching alerts found.'; - } - - const deleted = await deleteAlertIds(ctx, alertIds); - return deleted === 0 ? 'No matching alerts found.' : `Deleted ${deleted} alert(s).`; - } - - const query: APITypes.Alerts.DeleteAlerts.RequestQuery = { - ip, - scope: normalizedScope, - value: normalizedValue, - scenario - }; - - result = await ctx.watcher!.Alerts.delete(query); + if (filters.origin || filters.decisionType || filters.since || filters.until || filters.has_active_decision !== undefined) { + return deleteAlertsBySearch(ctx, filters); } - const count = Number(result.nbDeleted ?? 0); - return count === 0 ? 'No matching alerts found.' : `Deleted ${count} alert(s).`; + return deleteAlertsDirect(ctx, filters); } } ]; diff --git a/packages/crowdsec-mcp/src/tools/decisions.ts b/packages/crowdsec-mcp/src/tools/decisions.ts index 5e868e0f9..952c3f7cb 100644 --- a/packages/crowdsec-mcp/src/tools/decisions.ts +++ b/packages/crowdsec-mcp/src/tools/decisions.ts @@ -46,12 +46,8 @@ async function searchActiveDecisionIds( } async function deleteDecisionIds(ctx: AppContext, ids: number[]): Promise { - let deleted = 0; - for (const id of ids) { - const result = await ctx.watcher!.Decisions.deleteById(String(id)); - deleted += Number(result.nbDeleted ?? 0); - } - return deleted; + 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 { @@ -126,6 +122,212 @@ const UnbanIpInput = z }) .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 && checkResult.reason) { + throw new Error(`${targetValue} is allowlisted by item ${checkResult.reason}, 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 (scope && value && (ip || range)) { + throw new Error('Provide only one of ip, range, or scope+value'); + } + if (value && !scope && (ip || range)) { + throw new Error('Provide only one of ip, range, or value'); + } +} + +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'); + } + + return unbanDecisionIds(ctx, await searchActiveDecisionIds(ctx, {})); +} + +async function unbanById(ctx: AppContext, id: string | number): Promise { + const decisionId = parsePositiveId(id, 'decision id'); + const activeIds = await searchActiveDecisionIds(ctx, {}); + if (!activeIds.includes(Number(decisionId))) { + throw new Error(`Decision ${decisionId} not found`); + } + + 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 unbanByScope(ctx: AppContext, scope: string, type: string | undefined, scenario: string | undefined) { + const normalized = assertValidDecisionScope(scope); + const decisions = await fetchActiveDecisions(ctx); + const ids = decisions + .filter( + (decision) => + decision.scope?.toLowerCase() === normalized.toLowerCase() && + (!type || decision.type?.toLowerCase() === type) && + (!scenario || decision.scenario === scenario) + ) + .map((decision) => decision.id) + .filter((id): id is number => typeof id === 'number'); + + return unbanDecisionIds(ctx, ids); +} + +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 }); + } + + const ids = await searchActiveDecisionIds(ctx, { + ip: normalized === 'Range' ? normalizedValue : undefined, + contains: false, + scope: normalized === 'Range' ? undefined : normalized, + value: normalized === 'Range' ? undefined : normalizedValue, + decision_type: type, + scenario + }); + + return unbanDecisionIds(ctx, ids); +} + +async function unbanByRange(ctx: AppContext, range: string, type: string | undefined, scenario: string | undefined) { + const ids = await searchActiveDecisionIds(ctx, { + ip: range, + contains: false, + decision_type: type, + scenario + }); + return unbanDecisionIds(ctx, ids); +} + +async function unbanByQuery(ctx: AppContext, query: { ip?: string; value?: string; type?: string; scenario?: string }) { + if (!query.ip && !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); +} + export const decisionTools: ToolDefinition[] = [ { name: 'list_decisions', @@ -244,106 +446,19 @@ export const decisionTools: ToolDefinition[] = [ throw new Error('Watcher client not available'); } - const { ip, range, scope: inputScope, value, duration, type, reason, force } = BanIpInput.parse(input); - - const hasIp = Boolean(ip); - const hasRange = Boolean(range); - const hasScope = Boolean(inputScope); - const hasValue = Boolean(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'); - } - - const decisionType = assertValidDecisionType(type); - let scope: string; - let targetValue: string; - - if (ip) { - scope = normalizeScope(undefined, ip); - targetValue = ip; - } else if (range) { - scope = normalizeScope(undefined, undefined, range); - targetValue = range; - } else { - scope = assertValidDecisionScope(inputScope!); - targetValue = assertValidScopeValue(scope, value!); - } + const banInput = BanIpInput.parse(input); + validateBanTarget(banInput); - if (!targetValue) { - throw new Error('Value is required for the given scope'); - } - - // Parse and validate duration before sending anything to the LAPI. - const normalizedDuration = String(duration).trim(); + const decisionType = assertValidDecisionType(banInput.type); + const { scope, targetValue } = resolveBanTarget(banInput); + const normalizedDuration = String(banInput.duration).trim(); const durationSeconds = parseDuration(normalizedDuration); - // Check allowlist first, like cscli, then enforce the local duration guard. - if (!force && (scope === 'Ip' || scope === 'Range')) { - const checkResult = await ctx.allowlists.check(targetValue); - if (checkResult.allowlisted && checkResult.reason) { - throw new Error( - `${targetValue} is allowlisted by item ${checkResult.reason}, use force=true to add the decision anyway` - ); - } - } - - // Check max duration unless forced - if (!force && durationSeconds > ctx.config.maxBanDurationSeconds) { - const maxDuration = formatDuration(ctx.config.maxBanDurationSeconds); - throw new Error(`Duration ${normalizedDuration} exceeds maximum allowed ${maxDuration}. Use force=true to override.`); - } - - // Build the alert payload exactly as cscli does - const now = new Date().toISOString(); - const defaultReason = `manual '${decisionType}' from 'crowdsec-mcp'`; - const alertReason = reason?.trim() ? reason : defaultReason; - - const alert = { - capacity: 0, - decisions: [ - { - origin: 'cscli', - type: decisionType, - scope, - value: targetValue, - duration: normalizedDuration, - scenario: alertReason - } - ], - events: [], - events_count: 1, - leakspeed: '0', - message: alertReason, - scenario: alertReason, - 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' - }; + 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, { @@ -370,145 +485,47 @@ export const decisionTools: ToolDefinition[] = [ throw new Error('Watcher client not available'); } - const { id, ip, range, scope, value, type, scenario, all, confirm } = UnbanIpInput.parse(input); - - if (all) { - if (!confirm) { - throw new Error('Deleting all decisions requires confirm=true'); - } + const unbanInput = UnbanIpInput.parse(input); - if (id !== undefined || ip || range || scope || value || type || scenario) { - throw new Error( - 'Cannot combine all=true with id or filters; use all=true with confirm=true alone to delete everything' - ); - } - - const ids = await searchActiveDecisionIds(ctx, {}); - if (ids.length === 0) { - return formatUnbanResult(0); - } - return formatUnbanResult(await deleteDecisionIds(ctx, ids)); + if (unbanInput.all) { + return unbanAll(ctx, unbanInput); } - if (id !== undefined) { - if (ip || range || scope || value || type || scenario) { + if (unbanInput.id !== undefined) { + if (unbanInput.ip || unbanInput.range || unbanInput.scope || unbanInput.value || unbanInput.type || unbanInput.scenario) { throw new Error('Cannot combine id with filters'); } - - const decisionId = parsePositiveId(id, 'decision id'); - const activeIds = await searchActiveDecisionIds(ctx, {}); - if (!activeIds.includes(Number(decisionId))) { - throw new Error(`Decision ${decisionId} not found`); - } - - 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; - } + return unbanById(ctx, unbanInput.id); } - if (scope && value && (ip || range)) { - throw new Error('Provide only one of ip, range, or scope+value'); + validateUnbanTargets(unbanInput.ip, unbanInput.range, unbanInput.scope, unbanInput.value); + + if (unbanInput.ip) { + assertValidIp(unbanInput.ip); } - if (value && !scope && (ip || range)) { - throw new Error('Provide only one of ip, range, or value'); + if (unbanInput.range) { + assertValidCidr(unbanInput.range); } - if (ip) { - assertValidIp(ip); + const normalizedType = unbanInput.type ? assertValidDecisionType(unbanInput.type) : undefined; + + if (unbanInput.scope && !unbanInput.value) { + return unbanByScope(ctx, unbanInput.scope, normalizedType, unbanInput.scenario); } - if (range) { - assertValidCidr(range); + if (unbanInput.scope && unbanInput.value) { + return unbanByScopeValue(ctx, unbanInput.scope, unbanInput.value, normalizedType, unbanInput.scenario); } - - const normalizedType = type ? assertValidDecisionType(type) : undefined; - - if (scope && !value) { - const normalized = assertValidDecisionScope(scope); - const decisions = await fetchActiveDecisions(ctx); - const ids = decisions - .filter( - (decision) => - decision.scope?.toLowerCase() === normalized.toLowerCase() && - (!normalizedType || decision.type?.toLowerCase() === normalizedType) && - (!scenario || decision.scenario === scenario) - ) - .map((decision) => decision.id) - .filter((id): id is number => typeof id === 'number'); - - if (ids.length === 0) { - return formatUnbanResult(0); - } - return formatUnbanResult(await deleteDecisionIds(ctx, ids)); + if (unbanInput.range) { + return unbanByRange(ctx, unbanInput.range, normalizedType, unbanInput.scenario); } const query: { ip?: string; value?: string; type?: string; scenario?: string } = {}; - if (ip) query.ip = ip; - if (value) query.value = value; + if (unbanInput.ip) query.ip = unbanInput.ip; + if (unbanInput.value) query.value = unbanInput.value; if (normalizedType) query.type = normalizedType; - if (scenario) query.scenario = scenario; - - if (scope && value) { - const normalized = assertValidDecisionScope(scope); - const normalizedValue = assertValidScopeValue(normalized, value); - if (normalized === 'Ip') { - query.ip = normalizedValue; - delete query.value; - } else if (normalized === 'Range') { - const ids = await searchActiveDecisionIds(ctx, { - ip: normalizedValue, - contains: false, - decision_type: normalizedType, - scenario - }); - if (ids.length === 0) { - return formatUnbanResult(0); - } - return formatUnbanResult(await deleteDecisionIds(ctx, ids)); - } else { - const ids = await searchActiveDecisionIds(ctx, { - scope: normalized, - value: normalizedValue, - decision_type: normalizedType, - scenario - }); - if (ids.length === 0) { - return formatUnbanResult(0); - } - return formatUnbanResult(await deleteDecisionIds(ctx, ids)); - } - } - - if (range) { - const ids = await searchActiveDecisionIds(ctx, { - ip: range, - contains: false, - decision_type: normalizedType, - scenario - }); - if (ids.length === 0) { - return formatUnbanResult(0); - } - return formatUnbanResult(await deleteDecisionIds(ctx, ids)); - } - - const hasFilter = query.ip || query.value || query.type || query.scenario; - if (!hasFilter) { - throw new Error('Must provide id, at least one filter, or all=true with confirm=true'); - } + if (unbanInput.scenario) query.scenario = unbanInput.scenario; - const result = await ctx.watcher.Decisions.delete(query); - return formatUnbanResult(result.nbDeleted ?? 0); + return unbanByQuery(ctx, query); } } ]; diff --git a/packages/crowdsec-mcp/src/transports/http.ts b/packages/crowdsec-mcp/src/transports/http.ts index 664817078..e8d4194dd 100644 --- a/packages/crowdsec-mcp/src/transports/http.ts +++ b/packages/crowdsec-mcp/src/transports/http.ts @@ -14,18 +14,6 @@ export type RequestServerFactory = (headers: http.IncomingHttpHeaders) => Reques const MAX_BODY_SIZE = 4 * 1024 * 1024; -const UNAUTHORIZED_BODY = JSON.stringify({ - jsonrpc: '2.0', - error: { code: -32001, message: 'Unauthorized' }, - id: null -}); - -const NOT_FOUND_BODY = JSON.stringify({ - jsonrpc: '2.0', - error: { code: -32000, message: 'Not found' }, - id: null -}); - export function isAuthorizedHeader(header: string | undefined, token: string): boolean { if (!header) { return false; @@ -87,151 +75,133 @@ function sendJson(res: http.ServerResponse, status: number, body: string, extraH res.end(body); } -export async function startHttpServer(createRequestServer: RequestServerFactory, config: Config): Promise { - const token = config.http.token; +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 + ); +} - const httpServer = http.createServer(async (req, res) => { - const { method, url } = req; +async function readJsonBody(req: http.IncomingMessage, res: http.ServerResponse): Promise { + let raw: string; + try { + raw = await readBody(req); + } catch { + sendRpcError(res, 413, -32000, 'Payload too large'); + return undefined; + } - // 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)) { - sendJson(res, 401, UNAUTHORIZED_BODY, { 'WWW-Authenticate': 'Bearer' }); - return; - } + try { + return JSON.parse(raw); + } catch { + sendRpcError(res, 400, -32700, 'Parse error'); + return undefined; + } +} - const pathname = url?.split('?')[0]; - if (pathname !== '/mcp') { - sendJson(res, 404, NOT_FOUND_BODY); - return; - } +function sendSetupError(res: http.ServerResponse, error: unknown): void { + if (error instanceof HttpRequestError) { + sendRpcError(res, error.status, error.code, error.message); + return; + } - if (method === 'POST') { - let raw: string; - try { - raw = await readBody(req); - } catch { - sendJson( - res, - 413, - JSON.stringify({ - jsonrpc: '2.0', - error: { code: -32000, message: 'Payload too large' }, - id: null - }) - ); - 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'); +} - let parsedBody: unknown; - try { - parsedBody = JSON.parse(raw); - } catch { - sendJson( - res, - 400, - JSON.stringify({ - jsonrpc: '2.0', - error: { code: -32700, message: 'Parse error' }, - id: null - }) - ); - return; - } +async function handlePost(req: http.IncomingMessage, res: http.ServerResponse, createRequestServer: RequestServerFactory): Promise { + const parsedBody = await readJsonBody(req, res); + if (parsedBody === undefined) { + return; + } - let requestServer: RequestServer; - try { - requestServer = await createRequestServer(req.headers); - } catch (error) { - if (error instanceof HttpRequestError) { - sendJson( - res, - error.status, - JSON.stringify({ - jsonrpc: '2.0', - error: { code: error.code, message: error.message }, - id: null - }) - ); - } else { - const message = error instanceof Error ? error.message : String(error); - process.stderr.write(`crowdsec-mcp: request setup failed: ${message}\n`); - sendJson( - res, - 500, - JSON.stringify({ - jsonrpc: '2.0', - error: { code: -32603, message: 'Internal server error' }, - id: null - }) - ); - } - 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', () => { - void transport.close(); - void server.close(); - if (cleanup) { - void cleanup(); - } - }); - - try { - await server.connect(transport); - await transport.handleRequest(req, res, parsedBody); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - process.stderr.write(`crowdsec-mcp: HTTP request failed: ${message}\n`); - if (!res.headersSent) { - sendJson( - res, - 500, - JSON.stringify({ - jsonrpc: '2.0', - error: { code: -32603, message: 'Internal server error' }, - id: null - }) - ); - } else { - res.end(); - } - } - 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', () => { + void transport.close(); + void server.close(); + if (cleanup) { + void cleanup(); } + }); - if (method === 'GET' || method === 'DELETE') { - sendJson( - res, - 405, - JSON.stringify({ - jsonrpc: '2.0', - error: { code: -32000, message: 'Method not allowed' }, - id: null - }) - ); - return; + try { + await server.connect(transport); + await transport.handleRequest(req, res, parsedBody); + } 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; - sendJson( - res, - 405, - JSON.stringify({ - jsonrpc: '2.0', - error: { code: -32000, message: 'Method not allowed' }, - id: null - }) - ); + const httpServer = http.createServer((req, res) => { + void handleRequest(req, res, createRequestServer, token); }); return new Promise((resolve, reject) => { diff --git a/packages/crowdsec-mcp/src/utils.ts b/packages/crowdsec-mcp/src/utils.ts index e6de4e705..a0488113e 100644 --- a/packages/crowdsec-mcp/src/utils.ts +++ b/packages/crowdsec-mcp/src/utils.ts @@ -1,6 +1,15 @@ import { isIP } from 'node:net'; -const DURATION_PART = /(\d+(?:\.\d+)?)([dhms])/g; +const DURATION_UNITS: Record = { + d: 86400, + h: 3600, + m: 60, + s: 1 +}; + +function isDigit(char: string | undefined): boolean { + return char !== undefined && char >= '0' && char <= '9'; +} /** * Parse a cscli-like duration (4h, 30m, 7d, 1h30m) into seconds. @@ -12,28 +21,42 @@ export function parseDuration(input: string): number { } let total = 0; - let consumed = 0; - for (const match of value.matchAll(DURATION_PART)) { - const amount = Number(match[1]); - const unit = match[2]; - consumed += match[0].length; - switch (unit) { - case 'd': - total += amount * 86400; - break; - case 'h': - total += amount * 3600; - break; - case 'm': - total += amount * 60; - break; - case 's': - total += amount; - break; + let index = 0; + + while (index < value.length) { + const start = index; + while (isDigit(value[index])) { + index++; + } + + if (index === start) { + throw new Error(`invalid duration '${input}': use 30m, 4h or 7d`); + } + + if (value[index] === '.') { + index++; + const fractionStart = index; + while (isDigit(value[index])) { + index++; + } + if (index === fractionStart) { + throw new Error(`invalid duration '${input}': use 30m, 4h or 7d`); + } + } + + const amount = Number(value.slice(start, index)); + const unit = value[index]; + const multiplier = unit === undefined ? undefined : DURATION_UNITS[unit]; + + if (multiplier === undefined) { + throw new Error(`invalid duration '${input}': use 30m, 4h or 7d`); } + + total += amount * multiplier; + index++; } - if (consumed !== value.length || total <= 0) { + if (total <= 0) { throw new Error(`invalid duration '${input}': use 30m, 4h or 7d`); } @@ -277,7 +300,15 @@ export function resolveDecisionExpiry( return { active: false }; } - const baseDate = base ? (base instanceof Date ? base : new Date(base)) : new Date(now); + 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 }; } diff --git a/packages/crowdsec-mcp/tests/helpers.ts b/packages/crowdsec-mcp/tests/helpers.ts index faf6d449d..8f0940b46 100644 --- a/packages/crowdsec-mcp/tests/helpers.ts +++ b/packages/crowdsec-mcp/tests/helpers.ts @@ -37,16 +37,17 @@ export function makeContext(overrides: Partial = {}): AppContext { allowlists: true }, allowlists: { - list: async () => [], - get: async () => ({}), - check: async () => ({ allowlisted: false }), - bulkCheck: async () => ({ results: [] }) + list: () => Promise.resolve([]), + get: () => Promise.resolve({}), + check: () => Promise.resolve({ allowlisted: false }), + bulkCheck: () => Promise.resolve({ results: [] }) }, config: makeConfig(), - ensureReady: async () => { + ensureReady: () => { readyCalls++; + return Promise.resolve(); }, - stop: async () => undefined, + stop: () => Promise.resolve(), ...overrides }; diff --git a/packages/crowdsec-mcp/tests/tools/decisions.test.ts b/packages/crowdsec-mcp/tests/tools/decisions.test.ts index 8e742a46c..19c4196cc 100644 --- a/packages/crowdsec-mcp/tests/tools/decisions.test.ts +++ b/packages/crowdsec-mcp/tests/tools/decisions.test.ts @@ -678,7 +678,11 @@ describe('decisionTools', () => { expect(result).toContain('2h'); }); - it('should use default duration 4h when not provided', async () => { + 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 }); @@ -686,29 +690,7 @@ describe('decisionTools', () => { const result = await tool!.handler({ ip: '1.2.3.4' }, ctx as any); - expect(result).toContain('4h'); - }); - - it('should use default type ban when not 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('ban'); - }); - - it('should use default reason when not 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("manual 'ban' from 'crowdsec-mcp'"); + expect(result).toContain(expected); }); it('should pass custom reason to alert', async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc4fc8949..5248d691d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -175,9 +175,6 @@ importers: '@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 @@ -934,9 +931,6 @@ packages: resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} - '@tsconfig/node-lts@24.0.1': - resolution: {integrity: sha512-xo+1KtV5zVb90fyyeglG1n5cqg3cQXLlCkEpLptYlZUNk28xmwhuD5GmxoXwmrVtUTvaMyclun9kAd0TK7H+Pg==} - '@tsconfig/node10@1.0.13': resolution: {integrity: sha512-gcLdvR9HO1ZJBypsOGqaP6TFEzb6vIta0KSTLt9NAQ6pXQO3cRgSVyCN6pzYqI9DlJgY71XKO0dpDhCf08b3pg==} @@ -2964,8 +2958,6 @@ snapshots: '@sindresorhus/is@4.6.0': {} - '@tsconfig/node-lts@24.0.1': {} - '@tsconfig/node10@1.0.13': {} '@tsconfig/node12@1.0.11': {} 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"] } From af61ffd991bb151cc4deeca14e9395dde0f5c3c1 Mon Sep 17 00:00:00 2001 From: thibaut severac Date: Tue, 27 Oct 2026 11:46:32 +0100 Subject: [PATCH 08/24] refactor(mcp): finish SonarCloud cleanup - remove the now-unnecessary await on the synchronous passthrough factory - split the remaining complex helpers - make cell stringification and JSON body parsing explicit --- packages/crowdsec-mcp/src/format/common.ts | 26 +++++++-- packages/crowdsec-mcp/src/index.ts | 2 +- packages/crowdsec-mcp/src/tools/decisions.ts | 27 ++++++---- packages/crowdsec-mcp/src/transports/http.ts | 16 +++--- packages/crowdsec-mcp/src/utils.ts | 55 ++++++++++++-------- 5 files changed, 83 insertions(+), 43 deletions(-) diff --git a/packages/crowdsec-mcp/src/format/common.ts b/packages/crowdsec-mcp/src/format/common.ts index 72f10cfb6..3c3730f80 100644 --- a/packages/crowdsec-mcp/src/format/common.ts +++ b/packages/crowdsec-mcp/src/format/common.ts @@ -2,14 +2,34 @@ 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 ''; +} + export function escapeCell(value: unknown): string { if (value === undefined || value === null || value === '') { return '—'; } - const text = typeof value === 'object' ? (JSON.stringify(value) ?? String(value)) : String(value); - - return text + return stringifyCell(value) .replaceAll(BACKSLASH, BACKSLASH + BACKSLASH) .replaceAll('|', BACKSLASH + '|') .replace(/[\r\n]+/g, ' '); diff --git a/packages/crowdsec-mcp/src/index.ts b/packages/crowdsec-mcp/src/index.ts index f48c46152..c4c4ce944 100644 --- a/packages/crowdsec-mcp/src/index.ts +++ b/packages/crowdsec-mcp/src/index.ts @@ -53,7 +53,7 @@ async function main(): Promise { if (config.transport === 'http') { if (config.passthrough) { await startHttpServer(async (headers) => { - const requestContext = await createPassthroughContext(config, headers); + const requestContext = createPassthroughContext(config, headers); const requestTools = selectTools(allTools(), requestContext); return { server: createServer(requestTools, requestContext), diff --git a/packages/crowdsec-mcp/src/tools/decisions.ts b/packages/crowdsec-mcp/src/tools/decisions.ts index 952c3f7cb..01cef4a34 100644 --- a/packages/crowdsec-mcp/src/tools/decisions.ts +++ b/packages/crowdsec-mcp/src/tools/decisions.ts @@ -226,6 +226,21 @@ function validateUnbanTargets(ip?: string, range?: string, scope?: string, value } } +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); @@ -492,9 +507,7 @@ export const decisionTools: ToolDefinition[] = [ } if (unbanInput.id !== undefined) { - if (unbanInput.ip || unbanInput.range || unbanInput.scope || unbanInput.value || unbanInput.type || unbanInput.scenario) { - throw new Error('Cannot combine id with filters'); - } + assertUnbanIdHasNoFilters(unbanInput); return unbanById(ctx, unbanInput.id); } @@ -519,13 +532,7 @@ export const decisionTools: ToolDefinition[] = [ return unbanByRange(ctx, unbanInput.range, normalizedType, unbanInput.scenario); } - const query: { ip?: string; value?: string; type?: string; scenario?: string } = {}; - if (unbanInput.ip) query.ip = unbanInput.ip; - if (unbanInput.value) query.value = unbanInput.value; - if (normalizedType) query.type = normalizedType; - if (unbanInput.scenario) query.scenario = unbanInput.scenario; - - return unbanByQuery(ctx, query); + return unbanByQuery(ctx, buildUnbanQuery(unbanInput, normalizedType)); } } ]; diff --git a/packages/crowdsec-mcp/src/transports/http.ts b/packages/crowdsec-mcp/src/transports/http.ts index e8d4194dd..e1941b51d 100644 --- a/packages/crowdsec-mcp/src/transports/http.ts +++ b/packages/crowdsec-mcp/src/transports/http.ts @@ -94,20 +94,22 @@ function sendRpcError( ); } -async function readJsonBody(req: http.IncomingMessage, res: http.ServerResponse): Promise { +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 { sendRpcError(res, 413, -32000, 'Payload too large'); - return undefined; + return { ok: false }; } try { - return JSON.parse(raw); + return { ok: true, body: JSON.parse(raw) }; } catch { sendRpcError(res, 400, -32700, 'Parse error'); - return undefined; + return { ok: false }; } } @@ -123,8 +125,8 @@ function sendSetupError(res: http.ServerResponse, error: unknown): void { } async function handlePost(req: http.IncomingMessage, res: http.ServerResponse, createRequestServer: RequestServerFactory): Promise { - const parsedBody = await readJsonBody(req, res); - if (parsedBody === undefined) { + const bodyResult = await readJsonBody(req, res); + if (!bodyResult.ok) { return; } @@ -155,7 +157,7 @@ async function handlePost(req: http.IncomingMessage, res: http.ServerResponse, c try { await server.connect(transport); - await transport.handleRequest(req, res, parsedBody); + 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`); diff --git a/packages/crowdsec-mcp/src/utils.ts b/packages/crowdsec-mcp/src/utils.ts index a0488113e..96958ec84 100644 --- a/packages/crowdsec-mcp/src/utils.ts +++ b/packages/crowdsec-mcp/src/utils.ts @@ -11,6 +11,34 @@ function isDigit(char: string | undefined): boolean { return char !== undefined && char >= '0' && char <= '9'; } +interface DurationNumber { + amount: number; + next: number; +} + +function readDurationNumber(value: string, start: number): DurationNumber | undefined { + let index = start; + while (isDigit(value[index])) { + index++; + } + if (index === start) { + return undefined; + } + + if (value[index] === '.') { + index++; + const fractionStart = index; + while (isDigit(value[index])) { + index++; + } + if (index === fractionStart) { + return undefined; + } + } + + return { amount: Number(value.slice(start, index)), next: index }; +} + /** * Parse a cscli-like duration (4h, 30m, 7d, 1h30m) into seconds. */ @@ -24,36 +52,19 @@ export function parseDuration(input: string): number { let index = 0; while (index < value.length) { - const start = index; - while (isDigit(value[index])) { - index++; - } - - if (index === start) { + const parsed = readDurationNumber(value, index); + if (!parsed) { throw new Error(`invalid duration '${input}': use 30m, 4h or 7d`); } - if (value[index] === '.') { - index++; - const fractionStart = index; - while (isDigit(value[index])) { - index++; - } - if (index === fractionStart) { - throw new Error(`invalid duration '${input}': use 30m, 4h or 7d`); - } - } - - const amount = Number(value.slice(start, index)); - const unit = value[index]; + const unit = value[parsed.next]; const multiplier = unit === undefined ? undefined : DURATION_UNITS[unit]; - if (multiplier === undefined) { throw new Error(`invalid duration '${input}': use 30m, 4h or 7d`); } - total += amount * multiplier; - index++; + total += parsed.amount * multiplier; + index = parsed.next + 1; } if (total <= 0) { From 9265d588b38c56a299a4c42dab1325895b20f429 Mon Sep 17 00:00:00 2001 From: thibaut severac Date: Tue, 27 Oct 2026 11:48:15 +0100 Subject: [PATCH 09/24] refactor(mcp): make passthrough request factory synchronous --- packages/crowdsec-mcp/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/crowdsec-mcp/src/index.ts b/packages/crowdsec-mcp/src/index.ts index c4c4ce944..1f35195b2 100644 --- a/packages/crowdsec-mcp/src/index.ts +++ b/packages/crowdsec-mcp/src/index.ts @@ -52,7 +52,7 @@ async function main(): Promise { if (config.transport === 'http') { if (config.passthrough) { - await startHttpServer(async (headers) => { + await startHttpServer((headers) => { const requestContext = createPassthroughContext(config, headers); const requestTools = selectTools(allTools(), requestContext); return { From b38dab2ba72d87148df8b007f496784a5c793112 Mon Sep 17 00:00:00 2001 From: thibaut severac Date: Tue, 27 Oct 2026 12:06:19 +0100 Subject: [PATCH 10/24] chore(ci): remove unused Context7 refresh workflow --- .github/workflows/context7-refresh.yml | 23 ----------------------- 1 file changed, 23 deletions(-) delete mode 100644 .github/workflows/context7-refresh.yml diff --git a/.github/workflows/context7-refresh.yml b/.github/workflows/context7-refresh.yml deleted file mode 100644 index eeee4be9c..000000000 --- a/.github/workflows/context7-refresh.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: Refresh Context7 Docs - -# The library must first be added at https://context7.com/add-library -# and CONTEXT7_API_KEY must be set as a repository secret. -# To refresh automatically after that, add: -# push: -# branches: [ main ] -on: - workflow_dispatch: - -permissions: - contents: read - -jobs: - refresh: - runs-on: ubuntu-latest - steps: - - name: Trigger Context7 refresh - run: | - curl --fail --silent --show-error -X POST https://context7.com/api/v1/refresh \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer ${{ secrets.CONTEXT7_API_KEY }}" \ - -d '{"libraryName": "/${{ github.repository }}"}' From cb0e543f7b565e9d94dcd22eb4ae57c505a2f06c Mon Sep 17 00:00:00 2001 From: thibaut severac Date: Tue, 27 Oct 2026 16:16:13 +0100 Subject: [PATCH 11/24] feat(mcp): add docs, prompts and HTTP E2E - add model-facing resources, instructions and triage/investigation prompts - require scope+value for delete_alerts - add Dockerized HTTP passthrough E2E and CI workflow - document deployment, E2E, security notes and release status --- .github/workflows/mcp-http-e2e.yml | 46 +++ packages/crowdsec-mcp/README.md | 57 +++- packages/crowdsec-mcp/TODO.md | 14 +- packages/crowdsec-mcp/package.json | 1 + packages/crowdsec-mcp/src/docs.ts | 307 ++++++++++++++++++ packages/crowdsec-mcp/src/prompts.ts | 220 +++++++++++++ packages/crowdsec-mcp/src/registry.ts | 37 ++- packages/crowdsec-mcp/src/tools/alerts.ts | 22 +- packages/crowdsec-mcp/src/types.ts | 2 +- packages/crowdsec-mcp/tests/docs.test.ts | 233 +++++++++++++ .../tests/e2e/http/acquis/nginx.yaml | 4 + .../crowdsec-mcp/tests/e2e/http/compose.yaml | 75 +++++ .../tests/e2e/http/generate-nginx-logs.mjs | 88 +++++ .../crowdsec-mcp/tests/e2e/http/live.test.ts | 242 ++++++++++++++ packages/crowdsec-mcp/tests/e2e/http/run.sh | 135 ++++++++ .../crowdsec-mcp/tests/tools/alerts.test.ts | 15 +- 16 files changed, 1454 insertions(+), 44 deletions(-) create mode 100644 .github/workflows/mcp-http-e2e.yml create mode 100644 packages/crowdsec-mcp/src/docs.ts create mode 100644 packages/crowdsec-mcp/src/prompts.ts create mode 100644 packages/crowdsec-mcp/tests/docs.test.ts create mode 100644 packages/crowdsec-mcp/tests/e2e/http/acquis/nginx.yaml create mode 100644 packages/crowdsec-mcp/tests/e2e/http/compose.yaml create mode 100644 packages/crowdsec-mcp/tests/e2e/http/generate-nginx-logs.mjs create mode 100644 packages/crowdsec-mcp/tests/e2e/http/live.test.ts create mode 100644 packages/crowdsec-mcp/tests/e2e/http/run.sh diff --git a/.github/workflows/mcp-http-e2e.yml b/.github/workflows/mcp-http-e2e.yml new file mode 100644 index 000000000..893a233ce --- /dev/null +++ b/.github/workflows/mcp-http-e2e.yml @@ -0,0 +1,46 @@ +name: MCP HTTP E2E + +on: + pull_request: + paths: + - "packages/crowdsec-mcp/**" + - "packages/crowdsec-client/**" + - ".github/workflows/mcp-http-e2e.yml" + push: + branches: [main] + paths: + - "packages/crowdsec-mcp/**" + - "packages/crowdsec-client/**" + - ".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 packages/crowdsec-mcp/tests/e2e/http/run.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/packages/crowdsec-mcp/README.md b/packages/crowdsec-mcp/README.md index bef65fb29..94ec77605 100644 --- a/packages/crowdsec-mcp/README.md +++ b/packages/crowdsec-mcp/README.md @@ -100,7 +100,7 @@ If both a bouncer key and machine credentials are present, set `CROWDSEC_AUTH` e | `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 | +| `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` | In passthrough, allow `delete_alerts` without a service token (dangerous) | `false` | @@ -113,7 +113,7 @@ Secrets should stay in the environment. Command-line arguments are available for ### CLI arguments -Every environment variable above has a kebab-case argument equivalent: `--url`, `--auth`, `--api-key`, `--machine-id`, `--password`, `--tls-cert`, `--tls-key`, `--tls-ca`, `--insecure`, `--timeout`, `--transport`, `--http-host`, `--http-port`, `--http-token`, `--readonly`, `--write`, `--passthrough`, `--enable-alert-delete`, `--allow-alert-delete-without-token`, `--tools`, `--disable-tools`, `--max-results`, `--max-ban-duration`. +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. @@ -160,6 +160,37 @@ 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` @@ -205,7 +236,7 @@ A remote watcher receives `403 access forbidden from this IP`. Use `unban_ip` to In passthrough mode, `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 from exposing the local-only destructive path. -`all=true` cannot be combined with filters. `ip` and `range` accept a CIDR and perform containment. Filters `id`, `origin`, `type`, `since`, `until` and `has_active_decision` are resolved through an alert search followed by deletion by ID, because the LAPI DELETE endpoint does not support all of them directly. Deleting an alert also deletes its active decisions; this cascade is a CrowdSec LAPI behavior. +`all=true` cannot be combined with filters. `ip` and `range` accept a CIDR and perform containment. Filters `id`, `origin`, `type`, `since`, `until` and `has_active_decision` are resolved through an alert search followed by deletion by ID, because the LAPI DELETE endpoint does not support all of them directly. `scope` must be combined with `value`; scope alone is rejected to avoid deleting every alert of that scope. Deleting an alert also deletes its active decisions; this cascade is a CrowdSec LAPI behavior. ### CIDR semantics @@ -446,6 +477,26 @@ pnpm --filter crowdsec-mcp exec vitest run tests/integration/live.test.ts 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`. + +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/TODO.md b/packages/crowdsec-mcp/TODO.md index df6f878c9..2b409a45f 100644 --- a/packages/crowdsec-mcp/TODO.md +++ b/packages/crowdsec-mcp/TODO.md @@ -81,19 +81,19 @@ If `delete_alerts` is not needed, a regular Docker bridge network is enough. - [x] Add a `docker-mcp.yml` workflow publishing `ghcr.io/thib3113/crowdsec-mcp` (on dependency changes and tags). - [x] Keep the CrowdSec image unmodified. -- [ ] Verify `delete_alerts` from the sidecar deployment. -- [ ] Verify passthrough with bouncer keys and watcher JWTs. -- [ ] Verify the service-token gate and the +- [x] Verify `delete_alerts` from the sidecar deployment. +- [x] Verify passthrough with bouncer keys and watcher JWTs. +- [x] Verify the service-token gate and the `ALLOW_ALERT_DELETE_WITHOUT_TOKEN` override. ## Release prerequisites - [ ] Publish `crowdsec-client@1.3.0` (allowlists, delete query params, `getById` typing). It is currently not on npm. -- [ ] Resolve the `follow-redirects` Snyk finding before the client release. -- [ ] Decide whether to wait for `axios@1.21.0` on npm or use a temporary - override. -- [ ] Publish `crowdsec-mcp` (only a placeholder `0.0.1-alpha.0` exists). +- [x] Resolve the `follow-redirects` Snyk finding before the client release. +- [x] Update `crowdsec-client` to `axios@1.21.0` (`follow-redirects@1.16.2`). +- [ ] Publish `crowdsec-mcp` (package is `0.1.0`; an npm placeholder + `0.0.1-alpha.0` may still exist). - [ ] Ensure the release workflow rewrites `workspace:^` correctly. `npm pack` keeps `workspace:^`; use `pnpm publish` (or replace the dependency with a published semver range). diff --git a/packages/crowdsec-mcp/package.json b/packages/crowdsec-mcp/package.json index 7001431b9..ffae34ce8 100644 --- a/packages/crowdsec-mcp/package.json +++ b/packages/crowdsec-mcp/package.json @@ -54,6 +54,7 @@ "start": "node dist/index.js", "test": "vitest run", "test:ci": "vitest run", + "test:e2e:http": "bash tests/e2e/http/run.sh", "test:ci:coverage": "vitest run --coverage", "lint": "eslint src", "lint:fix": "npm run lint -- --fix", diff --git a/packages/crowdsec-mcp/src/docs.ts b/packages/crowdsec-mcp/src/docs.ts new file mode 100644 index 000000000..8dd34ecd0 --- /dev/null +++ b/packages/crowdsec-mcp/src/docs.ts @@ -0,0 +1,307 @@ +import type { AppContext } from './types.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 { + return ( + ctx.config.enableAlertDelete && + (!ctx.config.passthrough || Boolean(ctx.config.http.token) || ctx.config.allowAlertDeleteWithoutToken) + ); +} + +function availabilityLines(ctx: AppContext): string[] { + return [ + `- Alerts (list_alerts, get_alert): ${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: list_decisions then get_decision. Say when alerts or allowlists would need watcher credentials.' + ); + 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 { + add( + 'ban_ip, unban_ip and delete_alerts 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('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, unban_ip).', + '- 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 get_decision, and say when an investigation 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 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/prompts.ts b/packages/crowdsec-mcp/src/prompts.ts new file mode 100644 index 000000000..a6bc524c7 --- /dev/null +++ b/packages/crowdsec-mcp/src/prompts.ts @@ -0,0 +1,220 @@ +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 } 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() + .regex(/^\d+[smhd]$/, 'Use a duration with a unit, e.g. 30m, 4h or 7d'); + +const CountArg = z.string().trim().regex(/^\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 steps: string[] = []; + let summary: string; + let recommendation: string; + + if (ctx.mode === 'bouncer') { + steps.push(`1. Call list_decisions with ip="${ip}" to see whether it is currently blocked.`); + steps.push('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.push(`1. Call list_alerts with ip="${ip}" and since="30d" to get the detection history.`); + steps.push(`2. Call list_decisions with ip="${ip}" to see whether it is currently blocked.`); + steps.push(`3. Call check_allowlist with value="${ip}" to detect an exemption.`); + steps.push('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 index 1c681813b..7a1ca14b1 100644 --- a/packages/crowdsec-mcp/src/registry.ts +++ b/packages/crowdsec-mcp/src/registry.ts @@ -2,6 +2,8 @@ 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 { crowdsecPrompts, isPromptAvailable } from './prompts.js'; import { NAME, VERSION } from './version.js'; const log = debug('crowdsec-mcp:registry'); @@ -110,14 +112,14 @@ export function selectTools(tools: ToolDefinition[], ctx: AppContext): ToolDefin } export function createServer(tools: ToolDefinition[], ctx: AppContext): McpServer { - const server = new McpServer({ name: NAME, version: VERSION }); 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 ?? {}) as Record, ctx); + const result = await tool.handler(input ?? {}, ctx); return typeof result === 'string' ? textResult(result) : result; } catch (error) { return errorResult(error); @@ -129,13 +131,40 @@ export function createServer(tools: ToolDefinition[], ctx: AppContext): McpServe { title: tool.title, description: tool.description, - inputSchema: tool.inputSchema as never, + inputSchema: tool.inputSchema, annotations: tool.annotations }, - handler as never + handler ); } + for (const resource of docResources) { + server.registerResource( + resource.name, + resource.uri, + { + title: resource.title, + description: resource.description, + mimeType: 'text/markdown' + }, + async (uri) => ({ + 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 index 97a359645..2e265bd89 100644 --- a/packages/crowdsec-mcp/src/tools/alerts.ts +++ b/packages/crowdsec-mcp/src/tools/alerts.ts @@ -127,6 +127,10 @@ function normalizeDeleteInput(input: DeleteAlertInput): NormalizedDeleteAlertInp } 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; @@ -167,21 +171,6 @@ async function deleteAlertsById(ctx: AppContext, id: string | number): Promise { - const alerts = await ctx.watcher!.Alerts.search({ - scope: filters.normalizedScope, - origin: filters.origin, - has_active_decision: filters.has_active_decision, - limit: 0 - }); - const alertIds = alerts - .filter((alert) => alert.source?.scope?.toLowerCase() === filters.normalizedScope?.toLowerCase()) - .map((alert) => alert.id) - .filter((alertId): alertId is number => typeof alertId === 'number'); - - return deleteAlertsByIds(ctx, alertIds); -} - async function deleteAlertsByCidr(ctx: AppContext, filters: NormalizedDeleteAlertInput, cidr: string): Promise { const alerts = await ctx.watcher!.Alerts.search({ ip: cidr, @@ -351,9 +340,6 @@ export const alertTools: ToolDefinition[] = [ if (filters.all === true) { return deleteAllAlerts(ctx); } - if (filters.normalizedScope && !filters.value) { - return deleteAlertsByScope(ctx, filters); - } const cidr = filters.range ?? (filters.ip && isValidCidr(filters.ip) ? filters.ip : undefined); if (cidr) { diff --git a/packages/crowdsec-mcp/src/types.ts b/packages/crowdsec-mcp/src/types.ts index 3d66b8e8d..34b2e49cf 100644 --- a/packages/crowdsec-mcp/src/types.ts +++ b/packages/crowdsec-mcp/src/types.ts @@ -42,5 +42,5 @@ export interface ToolDefinition { requires?: ToolRequirement[]; feature?: ToolFeature; annotations?: ToolAnnotations; - handler: (input: Record, ctx: AppContext) => Promise; + handler: (input: unknown, ctx: AppContext) => Promise; } diff --git a/packages/crowdsec-mcp/tests/docs.test.ts b/packages/crowdsec-mcp/tests/docs.test.ts new file mode 100644 index 000000000..27c8de4e0 --- /dev/null +++ b/packages/crowdsec-mcp/tests/docs.test.ts @@ -0,0 +1,233 @@ +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'); + }); +}); + +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/http/run.sh b/packages/crowdsec-mcp/tests/e2e/http/run.sh new file mode 100644 index 000000000..25298107f --- /dev/null +++ b/packages/crowdsec-mcp/tests/e2e/http/run.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../../../.." && pwd)" +COMPOSE_FILE="$SCRIPT_DIR/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 diff --git a/packages/crowdsec-mcp/tests/tools/alerts.test.ts b/packages/crowdsec-mcp/tests/tools/alerts.test.ts index 674bb89e5..404037485 100644 --- a/packages/crowdsec-mcp/tests/tools/alerts.test.ts +++ b/packages/crowdsec-mcp/tests/tools/alerts.test.ts @@ -495,19 +495,12 @@ describe('alertTools', () => { expect(result).toContain('Deleted 5 alert(s).'); }); - it('should delete all alerts for a scope when scope is provided alone', async () => { + it('should reject scope without value', async () => { const tool = alertTools.find((t) => t.name === 'delete_alerts')!; - mockWatcher.Alerts.search.mockResolvedValue([ - { id: 1, source: { scope: 'Range', value: '198.51.100.0/24' } }, - { id: 2, source: { scope: 'Ip', value: '198.51.100.1' } } - ]); - mockWatcher.Alerts.deleteById.mockResolvedValue({ nbDeleted: '1' }); - const result = await tool.handler({ scope: 'Range' }, mockCtx); - - expect(mockWatcher.Alerts.search).toHaveBeenCalledWith({ scope: 'Range', limit: 0 }); - expect(mockWatcher.Alerts.deleteById).toHaveBeenCalledWith('1'); - expect(result).toContain('Deleted 1 alert(s).'); + 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 delete alerts by origin through a search', async () => { From a36621ad67b80b585e179e3f993f60e33aba8b1f Mon Sep 17 00:00:00 2001 From: thibaut severac Date: Tue, 27 Oct 2026 16:41:18 +0100 Subject: [PATCH 12/24] fix(deps): pin qs to 6.17.0 - address SNYK-JS-QS-21755395 in the MCP dependency tree --- pnpm-lock.yaml | 13 ++++++++----- pnpm-workspace.yaml | 3 +++ 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5248d691d..ab0e50ffb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -162,6 +162,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + qs: 6.17.0 + importers: .: @@ -2136,8 +2139,8 @@ packages: resolution: {integrity: sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==} engines: {node: '>=20'} - qs@6.16.1: - resolution: {integrity: sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==} + qs@6.17.0: + resolution: {integrity: sha512-5LK5BZ2XDHMvcUJaf0CQ5HL3aVgKbpklvByC7qLSxYkfE8COEEbJg5xCHWCTqzqi0Gs4Jn5Mv5v3R38W6edscA==} engines: {node: '>=0.6'} range-parser@1.3.0: @@ -3218,7 +3221,7 @@ snapshots: http-errors: 2.0.1 iconv-lite: 0.7.3 on-finished: 2.4.1 - qs: 6.16.1 + qs: 6.17.0 raw-body: 3.0.2 type-is: 2.1.0 transitivePeerDependencies: @@ -3606,7 +3609,7 @@ snapshots: once: 1.4.0 parseurl: 1.3.3 proxy-addr: 2.0.8 - qs: 6.16.1 + 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) @@ -4145,7 +4148,7 @@ snapshots: dependencies: hookified: 2.2.0 - qs@6.16.1: + qs@6.17.0: dependencies: es-define-property: 1.0.1 side-channel: 1.1.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 467bb1485..4adc93199 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,5 +4,8 @@ packages: - 'packages/*' allowBuilds: esbuild: true +overrides: + qs: 6.17.0 minimumReleaseAgeExclude: - axios@1.21.0 + - qs@6.17.0 From 0290bfaf062dcd4d778d88b37012ff7926542d69 Mon Sep 17 00:00:00 2001 From: thibaut severac Date: Tue, 27 Oct 2026 16:59:24 +0100 Subject: [PATCH 13/24] fix(mcp): clear remaining Sonar issues - use bash [[ tests in the E2E script - remove an async function without await - replace push calls with arrays in the investigation prompt --- packages/crowdsec-mcp/src/prompts.ts | 22 +++++++++++++-------- packages/crowdsec-mcp/src/registry.ts | 19 +++++++++--------- packages/crowdsec-mcp/tests/e2e/http/run.sh | 12 +++++------ 3 files changed, 30 insertions(+), 23 deletions(-) diff --git a/packages/crowdsec-mcp/src/prompts.ts b/packages/crowdsec-mcp/src/prompts.ts index a6bc524c7..b68ddea20 100644 --- a/packages/crowdsec-mcp/src/prompts.ts +++ b/packages/crowdsec-mcp/src/prompts.ts @@ -113,20 +113,26 @@ function renderTriageAlerts(args: TriageAlertsArgs, ctx: AppContext): string { function renderInvestigateIp(args: InvestigateIpArgs, ctx: AppContext): string { const { ip } = args; - const steps: string[] = []; + const isBouncer = ctx.mode === 'bouncer'; + + let steps: string[]; let summary: string; let recommendation: string; - if (ctx.mode === 'bouncer') { - steps.push(`1. Call list_decisions with ip="${ip}" to see whether it is currently blocked.`); - steps.push('2. Alerts and allowlists need watcher credentials: state this limitation if it matters.'); + 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.push(`1. Call list_alerts with ip="${ip}" and since="30d" to get the detection history.`); - steps.push(`2. Call list_decisions with ip="${ip}" to see whether it is currently blocked.`); - steps.push(`3. Call check_allowlist with value="${ip}" to detect an exemption.`); - steps.push('4. If nothing matches, say so explicitly instead of guessing.'); + 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.'; } diff --git a/packages/crowdsec-mcp/src/registry.ts b/packages/crowdsec-mcp/src/registry.ts index 7a1ca14b1..133b920a6 100644 --- a/packages/crowdsec-mcp/src/registry.ts +++ b/packages/crowdsec-mcp/src/registry.ts @@ -147,15 +147,16 @@ export function createServer(tools: ToolDefinition[], ctx: AppContext): McpServe description: resource.description, mimeType: 'text/markdown' }, - async (uri) => ({ - contents: [ - { - uri: uri.href, - mimeType: 'text/markdown', - text: resource.render(ctx) - } - ] - }) + (uri) => + Promise.resolve({ + contents: [ + { + uri: uri.href, + mimeType: 'text/markdown', + text: resource.render(ctx) + } + ] + }) ); } diff --git a/packages/crowdsec-mcp/tests/e2e/http/run.sh b/packages/crowdsec-mcp/tests/e2e/http/run.sh index 25298107f..755c2dba9 100644 --- a/packages/crowdsec-mcp/tests/e2e/http/run.sh +++ b/packages/crowdsec-mcp/tests/e2e/http/run.sh @@ -20,12 +20,12 @@ cleanup() { local status=$? trap - EXIT - if [ "$status" -ne 0 ]; then + if [[ "$status" -ne 0 ]]; then compose ps || true compose logs --no-color --tail=200 || true fi - if [ "${E2E_KEEP:-0}" != "1" ]; then + if [[ "${E2E_KEEP:-0}" != "1" ]]; then compose down -v --remove-orphans >/dev/null 2>&1 || true else echo "E2E_KEEP=1: stack left running" @@ -38,7 +38,7 @@ cleanup() { } trap cleanup EXIT -if [ "${E2E_SKIP_BUILD:-0}" != "1" ]; then +if [[ "${E2E_SKIP_BUILD:-0}" != "1" ]]; then (cd "$REPO_ROOT" && pnpm --filter crowdsec-client build && pnpm --filter crowdsec-mcp build) fi @@ -82,7 +82,7 @@ wait_for_mcp() { 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 + if [[ "$code" = "401" ]]; then return 0 fi sleep 1 @@ -107,7 +107,7 @@ if ! compose exec -T crowdsec cscli machines add "$MACHINE_ID" --password "$MACH exit 1 fi -if [ -z "$BOUNCER_KEY" ]; then +if [[ -z "$BOUNCER_KEY" ]]; then echo "Failed to create E2E bouncer" >&2 exit 1 fi @@ -117,7 +117,7 @@ LOGIN_JSON=$(curl -sS -X POST "http://127.0.0.1:${E2E_LAPI_PORT}/v1/watchers/log --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 +if [[ -z "$WATCHER_TOKEN" ]]; then echo "E2E watcher login failed (machine: $MACHINE_ID)" >&2 compose exec -T crowdsec cscli machines list || true exit 1 From 8d49e882e062aaa15fb9a16b4468f54294ff433d Mon Sep 17 00:00:00 2001 From: thibaut severac Date: Tue, 27 Oct 2026 19:35:27 +0100 Subject: [PATCH 14/24] chore(mcp): remove internal TODO --- packages/crowdsec-mcp/TODO.md | 122 ---------------------------------- 1 file changed, 122 deletions(-) delete mode 100644 packages/crowdsec-mcp/TODO.md diff --git a/packages/crowdsec-mcp/TODO.md b/packages/crowdsec-mcp/TODO.md deleted file mode 100644 index 2b409a45f..000000000 --- a/packages/crowdsec-mcp/TODO.md +++ /dev/null @@ -1,122 +0,0 @@ -# TODO - -## Deployment notes - -### Do not modify the CrowdSec image - -Keep `crowdsecurity/crowdsec` as-is. The MCP is a separate container (or a host -process). Do not add Node.js or the MCP into the CrowdSec image. - -### Recommended local deployment: sidecar in CrowdSec's network namespace - -`delete_alerts` is local-only on the CrowdSec LAPI. To make it work, the MCP -must reach the LAPI from `127.0.0.1`. The simplest way is to share CrowdSec's -network namespace: - -```yaml -services: - crowdsec: - image: crowdsecurity/crowdsec:latest - ports: - - "8080:8080" - - "3939:3939" # published by crowdsec because the MCP shares its netns - volumes: - - crowdsec-data:/var/lib/crowdsec/data - - crowdsec-mcp: - image: crowdsec-mcp:local # build/publish the MCP image separately - 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_HTTP_TOKEN: change-me # optional service gate - CROWDSEC_MCP_READONLY: "false" - CROWDSEC_MCP_ENABLE_ALERT_DELETE: "true" - -volumes: - crowdsec-data: -``` - -Important: - -- `ports` must be declared on the `crowdsec` service. Docker does not allow - `ports` on a service using `network_mode: "service:..."`. -- The MCP listens on `0.0.0.0:3939` inside the shared netns. -- `CROWDSEC_URL` is `http://127.0.0.1:8080` inside the shared netns. -- In passthrough mode, clients send one of: - - `X-CrowdSec-Api-Key: ` (read decisions only) - - `X-CrowdSec-Token: ` (watcher tools) -- If `CROWDSEC_MCP_HTTP_TOKEN` is set, requests must also send - `Authorization: Bearer `. -- Without a service token, `delete_alerts` is hidden unless - `CROWDSEC_MCP_ALLOW_ALERT_DELETE_WITHOUT_TOKEN=true`. -- `delete_alerts` remains local-only even with passthrough. - -### Other deployment modes - -| Mode | `delete_alerts` | CrowdSec image modified | -| --- | --- | --- | -| Sidecar sharing CrowdSec's netns | yes | no | -| MCP on the host, CrowdSec installed natively | yes | no image | -| MCP on a Docker bridge (`http://crowdsec:8080`) | no (403) | no | -| MCP and CrowdSec both with `network_mode: host` | yes | no | - -If `delete_alerts` is not needed, a regular Docker bridge network is enough. -`unban_ip` works remotely. - -## Docker image work (later) - -- [x] Add a `Dockerfile` for the MCP package. -- [x] Use a multi-stage build with `node:26-alpine`. -- [x] Build `crowdsec-client` and `crowdsec-mcp` in the build stage. -- [x] Produce a small runtime image with only production dependencies. -- [x] Run as a non-root user. -- [x] Add a `HEALTHCHECK` (or document an external one). -- [x] Add a production `compose.yaml` based on the sidecar pattern above. -- [x] Add a `docker-mcp.yml` workflow publishing - `ghcr.io/thib3113/crowdsec-mcp` (on dependency changes and tags). -- [x] Keep the CrowdSec image unmodified. -- [x] Verify `delete_alerts` from the sidecar deployment. -- [x] Verify passthrough with bouncer keys and watcher JWTs. -- [x] Verify the service-token gate and the - `ALLOW_ALERT_DELETE_WITHOUT_TOKEN` override. - -## Release prerequisites - -- [ ] Publish `crowdsec-client@1.3.0` (allowlists, delete query params, - `getById` typing). It is currently not on npm. -- [x] Resolve the `follow-redirects` Snyk finding before the client release. -- [x] Update `crowdsec-client` to `axios@1.21.0` (`follow-redirects@1.16.2`). -- [ ] Publish `crowdsec-mcp` (package is `0.1.0`; an npm placeholder - `0.0.1-alpha.0` may still exist). -- [ ] Ensure the release workflow rewrites `workspace:^` correctly. `npm pack` - keeps `workspace:^`; use `pnpm publish` (or replace the dependency with a - published semver range). -- [ ] Configure npm trusted publishing (OIDC) after the first publish. -- [ ] Confirm Node.js `>=24` is acceptable for the published package. - -## Remaining QA items - -- [ ] `origin` / `kind` values with no match on list tools still return an - empty result (not an error). -- [ ] Allowlist write tools are impossible through the LAPI (use `cscli` or the - Console). -- [ ] `list_bouncers`, LAPI health/version/metrics, raw API passthrough and - simulated-object creation are not exposed. -- [ ] `delete_alerts(all=true)` is destructive and local-only; it is gated by - `confirm=true`. -- [ ] The current opencode session only sees `get_decision` after a restart. -- [ ] Add a cheap total-count source or pagination for the list tools. An - explicit `limit` currently fetches all matching rows so the hidden count - is exact; this can be expensive on very large datasets. -- [ ] Add sort/offset (or cursor) pagination to `list_alerts` and - `list_decisions`. -- [ ] Add a dry-run/preview mode for `delete_alerts` and `unban_ip(all=true)`. -- [ ] Decide whether `since=0s` / `until=0s` should be accepted; they are - currently rejected by the positive-duration parser. -- [ ] Re-run the QA matrix after the next live deployment. From bcc54337795eb2cda83679f50a9f39ab38f7895d Mon Sep 17 00:00:00 2001 From: thibaut severac Date: Tue, 27 Oct 2026 19:35:28 +0100 Subject: [PATCH 15/24] docs: link the wiki guides and clarify default scenarios --- .github/ISSUE_TEMPLATE/config.yml | 7 +++++-- SUPPORT.md | 3 ++- docs/agent-setup-guide.md | 10 +++++++--- llms.txt | 1 + packages/crowdsec-client-scenarios/readme.md | 2 +- packages/crowdsec-client/README.md | 6 +++--- packages/crowdsec-http-middleware/readme.md | 13 +++++++++---- readme.md | 7 +++++-- 8 files changed, 33 insertions(+), 16 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 523ef8596..8ea924c41 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -3,6 +3,9 @@ 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: Documentation + - 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 API documentation. + about: Read the generated technical documentation. diff --git a/SUPPORT.md b/SUPPORT.md index a6e7a7dfe..6fb34c459 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -2,7 +2,8 @@ ## Documentation -- API 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), diff --git a/docs/agent-setup-guide.md b/docs/agent-setup-guide.md index 740e10d71..dd3f9d271 100644 --- a/docs/agent-setup-guide.md +++ b/docs/agent-setup-guide.md @@ -169,8 +169,10 @@ acceptable that requests pass silently ? If yes → `failOpen`, if no → - 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 } }; ``` @@ -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 | --- diff --git a/llms.txt b/llms.txt index 9ef887372..bfb6779dd 100644 --- a/llms.txt +++ b/llms.txt @@ -11,6 +11,7 @@ ## 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. diff --git a/packages/crowdsec-client-scenarios/readme.md b/packages/crowdsec-client-scenarios/readme.md index d1c5e516d..bce1bf37c 100644 --- a/packages/crowdsec-client-scenarios/readme.md +++ b/packages/crowdsec-client-scenarios/readme.md @@ -38,7 +38,7 @@ install it npm i crowdsec-client-scenarios ``` -and then read the [documentation](https://thib3113.github.io/node-crowdsec/). +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 diff --git a/packages/crowdsec-client/README.md b/packages/crowdsec-client/README.md index 0f5c5cfc7..73ee12b4c 100644 --- a/packages/crowdsec-client/README.md +++ b/packages/crowdsec-client/README.md @@ -36,7 +36,7 @@ install it npm i crowdsec-client ``` -and then read the [documentation](https://thib3113.github.io/node-crowdsec/). +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 @@ -112,7 +112,7 @@ const client = new WatcherClient({ auth: { machineID: 'nameOfTheMachine', password: 'password', - // the CrowdSec token is valid for 1h; enable autoRenew to renew it automatically + // 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 @@ -144,7 +144,7 @@ await client.Alerts.delete({ }); ``` -More authentication options (such as TLS) are documented in the [documentation](https://thib3113.github.io/node-crowdsec/). +More authentication options (such as TLS) are documented in the [wiki](https://github.com/thib3113/node-crowdsec/wiki/Authentications). ## Debug diff --git a/packages/crowdsec-http-middleware/readme.md b/packages/crowdsec-http-middleware/readme.md index dd6109dbd..fdc8d01c7 100644 --- a/packages/crowdsec-http-middleware/readme.md +++ b/packages/crowdsec-http-middleware/readme.md @@ -83,10 +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. +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/). + ## Default scenarios 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 passing an empty `scenarios` configuration: +Install `crowdsec-http-middleware` and `crowdsec-client-scenarios`, and omit the +`scenarios` option to load the defaults: ``` npm i crowdsec-http-middleware crowdsec-client-scenarios @@ -190,9 +194,10 @@ const watcherOptions = { 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 + // omit `scenarios` to load the default scenarios (requires + // crowdsec-client-scenarios); an empty array disables all scenarios + // scenarios: [], + // options passed to the scenarios scenariosOptions: {} } ```` diff --git a/readme.md b/readme.md index 718bec434..b4a1b2555 100644 --- a/readme.md +++ b/readme.md @@ -12,9 +12,12 @@ This repository hosts several packages, listed below with examples. -The [technical documentation](https://thib3113.github.io/node-crowdsec/) is available online. +## Documentation -For agents and LLM tooling, see [llms.txt](./llms.txt). +- [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 From fd5c4ba175c2df0113a6dc9a75732b11d5eb4494 Mon Sep 17 00:00:00 2001 From: thibaut severac Date: Tue, 27 Oct 2026 19:44:43 +0100 Subject: [PATCH 16/24] docs(http-middleware): simplify default scenarios wording --- packages/crowdsec-http-middleware/readme.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/crowdsec-http-middleware/readme.md b/packages/crowdsec-http-middleware/readme.md index fdc8d01c7..278aaddd5 100644 --- a/packages/crowdsec-http-middleware/readme.md +++ b/packages/crowdsec-http-middleware/readme.md @@ -89,8 +89,8 @@ the generated API reference is in the [technical documentation](https://thib3113 ## Default scenarios This package supports a default setup with default scenarios. -Install `crowdsec-http-middleware` and `crowdsec-client-scenarios`, and omit the -`scenarios` option to load the defaults: +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 @@ -194,9 +194,7 @@ const watcherOptions = { password: 'myPassword', // send heartbeat to LAPI ? it allow the LAPI to see the watcher "online" heartbeat: true, - // omit `scenarios` to load the default scenarios (requires - // crowdsec-client-scenarios); an empty array disables all scenarios - // scenarios: [], + // scenarios: [], // omit to use the default scenarios; [] disables them // options passed to the scenarios scenariosOptions: {} } From 5226102dff7c2f686caf93dfaa7d8af1814175b4 Mon Sep 17 00:00:00 2001 From: thibaut severac Date: Tue, 27 Oct 2026 19:44:43 +0100 Subject: [PATCH 17/24] chore: remove context7 config --- context7.json | 14 -------------- 1 file changed, 14 deletions(-) delete mode 100644 context7.json diff --git a/context7.json b/context7.json deleted file mode 100644 index 4f879fa7e..000000000 --- a/context7.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "$schema": "https://context7.com/schema/context7.json", - "projectTitle": "node-crowdsec", - "description": "Node.js CrowdSec client, HTTP middleware, detection scenarios and MCP server", - "folders": ["packages", "docs", "examples"], - "excludeFolders": ["**/node_modules", "**/lib", "**/dist", "**/coverage", "**/tests", "**/bench"], - "excludeFiles": [], - "rules": [ - "Use Node.js 24 or newer.", - "Use pnpm for development in this monorepo.", - "Use crowdsec-http-middleware for HTTP servers and crowdsec-client for direct LAPI access.", - "Bouncer API keys and watcher machine credentials are different; use the authentication mode that matches your credentials." - ] -} From c8727c521a84978c58ac7051dbc51b2037b6ca93 Mon Sep 17 00:00:00 2001 From: thibaut severac Date: Tue, 27 Oct 2026 23:35:01 +0100 Subject: [PATCH 18/24] fix(mcp): align durations with CrowdSec and harden HTTP E2E - add shared duration helpers to crowdsec-client (days, Go wire format) - interpret numeric ban durations as seconds - stop sending undocumented alert filters and filter kind locally - delete decisions by id/scope directly, keep search for CIDR containment - harden HTTP transport (timeouts, 413 vs 500, safe cleanup, shutdown) - move the HTTP E2E script to .github/scripts and run the live integration test in CI - remove unused workflow patch scripts --- .../run.sh => .github/scripts/mcp-http-e2e.sh | 27 ++- .github/workflows/mcp-http-e2e.yml | 4 +- packages/crowdsec-client/src/duration.ts | 161 ++++++++++++++++++ packages/crowdsec-client/src/index.ts | 1 + .../tests/Duration/duration.test.ts | 50 ++++++ packages/crowdsec-mcp/README.md | 4 +- packages/crowdsec-mcp/package.json | 2 +- packages/crowdsec-mcp/src/config.ts | 4 + packages/crowdsec-mcp/src/docs.ts | 6 +- packages/crowdsec-mcp/src/index.ts | 11 +- packages/crowdsec-mcp/src/prompts.ts | 11 +- packages/crowdsec-mcp/src/registry.ts | 11 +- packages/crowdsec-mcp/src/tools/alerts.ts | 50 ++++-- packages/crowdsec-mcp/src/tools/decisions.ts | 85 ++++----- packages/crowdsec-mcp/src/transports/http.ts | 35 +++- packages/crowdsec-mcp/src/utils.ts | 94 +--------- .../crowdsec-mcp/tests/tools/alerts.test.ts | 50 +++--- .../tests/tools/decisions.test.ts | 63 +++---- .../tests/transports/http.test.ts | 28 +++ packages/crowdsec-mcp/tests/utils.test.ts | 18 +- patch_workflows_node24.py | 17 -- patch_workflows_node24_matrix.py | 14 -- 22 files changed, 470 insertions(+), 276 deletions(-) rename packages/crowdsec-mcp/tests/e2e/http/run.sh => .github/scripts/mcp-http-e2e.sh (77%) create mode 100644 packages/crowdsec-client/src/duration.ts create mode 100644 packages/crowdsec-client/tests/Duration/duration.test.ts delete mode 100644 patch_workflows_node24.py delete mode 100644 patch_workflows_node24_matrix.py diff --git a/packages/crowdsec-mcp/tests/e2e/http/run.sh b/.github/scripts/mcp-http-e2e.sh similarity index 77% rename from packages/crowdsec-mcp/tests/e2e/http/run.sh rename to .github/scripts/mcp-http-e2e.sh index 755c2dba9..505709a27 100644 --- a/packages/crowdsec-mcp/tests/e2e/http/run.sh +++ b/.github/scripts/mcp-http-e2e.sh @@ -2,8 +2,8 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/../../../../.." && pwd)" -COMPOSE_FILE="$SCRIPT_DIR/compose.yaml" +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 @@ -133,3 +133,26 @@ 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/mcp-http-e2e.yml b/.github/workflows/mcp-http-e2e.yml index 893a233ce..86079837d 100644 --- a/.github/workflows/mcp-http-e2e.yml +++ b/.github/workflows/mcp-http-e2e.yml @@ -5,12 +5,14 @@ on: 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: @@ -39,7 +41,7 @@ jobs: run: pnpm install --frozen-lockfile - name: Run HTTP MCP E2E - run: bash packages/crowdsec-mcp/tests/e2e/http/run.sh + run: bash .github/scripts/mcp-http-e2e.sh - name: Cleanup if: always() diff --git a/packages/crowdsec-client/src/duration.ts b/packages/crowdsec-client/src/duration.ts new file mode 100644 index 000000000..c41f40d99 --- /dev/null +++ b/packages/crowdsec-client/src/duration.ts @@ -0,0 +1,161 @@ +const UNIT_SECONDS: Record = { + h: 3600, + m: 60, + s: 1 +}; + +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): string { + 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..021b33350 100644 --- a/packages/crowdsec-client/src/index.ts +++ b/packages/crowdsec-client/src/index.ts @@ -9,6 +9,7 @@ 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 const VERSION = pkg.version; 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-mcp/README.md b/packages/crowdsec-mcp/README.md index 94ec77605..55f121dc5 100644 --- a/packages/crowdsec-mcp/README.md +++ b/packages/crowdsec-mcp/README.md @@ -424,7 +424,7 @@ 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. An opt-in integration test also runs against a real CrowdSec LAPI in Docker. +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: @@ -491,7 +491,7 @@ Requirements: Docker Compose v2, `pnpm`, `jq` and `openssl`. The script builds t 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`. +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. diff --git a/packages/crowdsec-mcp/package.json b/packages/crowdsec-mcp/package.json index ffae34ce8..588390819 100644 --- a/packages/crowdsec-mcp/package.json +++ b/packages/crowdsec-mcp/package.json @@ -54,7 +54,7 @@ "start": "node dist/index.js", "test": "vitest run", "test:ci": "vitest run", - "test:e2e:http": "bash tests/e2e/http/run.sh", + "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", diff --git a/packages/crowdsec-mcp/src/config.ts b/packages/crowdsec-mcp/src/config.ts index 43ab8d0c1..c037337a5 100644 --- a/packages/crowdsec-mcp/src/config.ts +++ b/packages/crowdsec-mcp/src/config.ts @@ -295,6 +295,10 @@ export function loadConfig(values: CliValues, env: NodeJS.ProcessEnv = process.e }; } +export function isAlertDeleteAvailable(config: Config): boolean { + return config.enableAlertDelete && (!config.passthrough || Boolean(config.http.token) || config.allowAlertDeleteWithoutToken); +} + export function redactedUrl(url: string): string { try { const parsed = new URL(url); diff --git a/packages/crowdsec-mcp/src/docs.ts b/packages/crowdsec-mcp/src/docs.ts index 8dd34ecd0..63ea05344 100644 --- a/packages/crowdsec-mcp/src/docs.ts +++ b/packages/crowdsec-mcp/src/docs.ts @@ -1,4 +1,5 @@ import type { AppContext } from './types.js'; +import { isAlertDeleteAvailable } from './config.js'; export const DOC_BASE_URL = 'https://docs.crowdsec.net'; @@ -43,10 +44,7 @@ function writeSummary(ctx: AppContext): string { } function canDeleteAlerts(ctx: AppContext): boolean { - return ( - ctx.config.enableAlertDelete && - (!ctx.config.passthrough || Boolean(ctx.config.http.token) || ctx.config.allowAlertDeleteWithoutToken) - ); + return isAlertDeleteAvailable(ctx.config); } function availabilityLines(ctx: AppContext): string[] { diff --git a/packages/crowdsec-mcp/src/index.ts b/packages/crowdsec-mcp/src/index.ts index 1f35195b2..27185808b 100644 --- a/packages/crowdsec-mcp/src/index.ts +++ b/packages/crowdsec-mcp/src/index.ts @@ -26,6 +26,7 @@ async function main(): Promise { 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 => { @@ -36,6 +37,12 @@ async function main(): Promise { 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 { @@ -52,7 +59,7 @@ async function main(): Promise { if (config.transport === 'http') { if (config.passthrough) { - await startHttpServer((headers) => { + httpServer = await startHttpServer((headers) => { const requestContext = createPassthroughContext(config, headers); const requestTools = selectTools(allTools(), requestContext); return { @@ -63,7 +70,7 @@ async function main(): Promise { } else { context = await createContext(config); const tools = selectTools(allTools(), context); - await startHttpServer(() => ({ server: createServer(tools, context!) }), config); + httpServer = await startHttpServer(() => ({ server: createServer(tools, context!) }), config); } log('HTTP transport listening on %s:%d', config.http.host, config.http.port); return; diff --git a/packages/crowdsec-mcp/src/prompts.ts b/packages/crowdsec-mcp/src/prompts.ts index b68ddea20..c61f8b3c8 100644 --- a/packages/crowdsec-mcp/src/prompts.ts +++ b/packages/crowdsec-mcp/src/prompts.ts @@ -2,7 +2,7 @@ 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 } from './utils.js'; +import { isValidIp, parseDuration } from './utils.js'; export interface CrowdSecPrompt { name: string; @@ -45,7 +45,14 @@ function writeGuardrail(ctx: AppContext): string { const DurationArg = z .string() .trim() - .regex(/^\d+[smhd]$/, 'Use a duration with a unit, e.g. 30m, 4h or 7d'); + .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(/^\d+$/, 'Use a positive integer'); diff --git a/packages/crowdsec-mcp/src/registry.ts b/packages/crowdsec-mcp/src/registry.ts index 133b920a6..adb364b4e 100644 --- a/packages/crowdsec-mcp/src/registry.ts +++ b/packages/crowdsec-mcp/src/registry.ts @@ -3,6 +3,7 @@ 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'; @@ -77,17 +78,11 @@ export function selectTools(tools: ToolDefinition[], ctx: AppContext): ToolDefin return false; } - if (tool.feature === 'alert-delete') { - if (!context.config.enableAlertDelete) { - 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. - if (context.config.passthrough && !context.config.http.token && !context.config.allowAlertDeleteWithoutToken) { - return false; - } + return false; } if (context.config.tools && !context.config.tools.includes(tool.name)) { diff --git a/packages/crowdsec-mcp/src/tools/alerts.ts b/packages/crowdsec-mcp/src/tools/alerts.ts index 2e265bd89..3d424029f 100644 --- a/packages/crowdsec-mcp/src/tools/alerts.ts +++ b/packages/crowdsec-mcp/src/tools/alerts.ts @@ -12,7 +12,8 @@ import { isValidIp, isValidTimeFilter, parsePositiveId, - resolveListWindow + resolveListWindow, + toGoDuration } from '../utils.js'; async function deleteAlertIds(ctx: AppContext, ids: number[]): Promise { @@ -172,16 +173,20 @@ async function deleteAlertsById(ctx: AppContext, id: string | number): Promise { + // The delete endpoint matches exact filter values; search first so alerts + // whose source IP is contained in the range are also removed. const alerts = await ctx.watcher!.Alerts.search({ ip: cidr, - contains: false, 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, has_active_decision: filters.has_active_decision, limit: 0 - } as APITypes.Alerts.SearchAlerts.RequestQuery & { contains?: boolean }); + }); return deleteAlertsByIds( ctx, @@ -190,19 +195,21 @@ async function deleteAlertsByCidr(ctx: AppContext, filters: NormalizedDeleteAler } async function deleteAlertsBySearch(ctx: AppContext, filters: NormalizedDeleteAlertInput): Promise { + // `decision_type` is not supported by the delete endpoint, so this is the + // only filter that still requires a search followed by id deletions. const alerts = await ctx.watcher!.Alerts.search({ ip: filters.ip, - contains: false, + range: filters.range, scope: filters.normalizedScope, value: filters.normalizedValue, scenario: filters.scenario, origin: filters.origin, decision_type: filters.decisionType, - since: filters.since, - until: filters.until, + since: filters.since ? toGoDuration(filters.since) : undefined, + until: filters.until ? toGoDuration(filters.until) : undefined, has_active_decision: filters.has_active_decision, limit: 0 - } as APITypes.Alerts.SearchAlerts.RequestQuery & { contains?: boolean }); + }); return deleteAlertsByIds( ctx, @@ -210,13 +217,21 @@ async function deleteAlertsBySearch(ctx: AppContext, filters: NormalizedDeleteAl ); } -async function deleteAlertsDirect(ctx: AppContext, filters: NormalizedDeleteAlertInput): Promise { - const result = await ctx.watcher!.Alerts.delete({ +function buildDeleteQuery(filters: NormalizedDeleteAlertInput): APITypes.Alerts.DeleteAlerts.RequestQuery { + return { ip: filters.ip, scope: filters.normalizedScope, value: filters.normalizedValue, - scenario: filters.scenario - }); + 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)); } @@ -266,7 +281,7 @@ export const alertTools: ToolDefinition[] = [ } const window = resolveListWindow(limit, all === true, ctx.config.maxResults); - const query: APITypes.Alerts.SearchAlerts.RequestQuery & { kind?: string; contains?: boolean } = { + const query: APITypes.Alerts.SearchAlerts.RequestQuery = { ip, range, scope, @@ -274,17 +289,16 @@ export const alertTools: ToolDefinition[] = [ scenario, decision_type: decisionType, origin, - kind, - since, - until, - contains: false, + since: since ? toGoDuration(since) : undefined, + until: until ? toGoDuration(until) : undefined, has_active_decision, limit: window.fetchLimit }; const alerts = await ctx.watcher!.Alerts.search(query); + const filteredAlerts = kind ? alerts.filter((alert) => alert.kind === kind) : alerts; - return formatAlertList(alerts, window.displayLimit, window.maybeMore); + return formatAlertList(filteredAlerts, window.displayLimit, window.maybeMore); } }, { @@ -345,7 +359,7 @@ export const alertTools: ToolDefinition[] = [ if (cidr) { return deleteAlertsByCidr(ctx, filters, cidr); } - if (filters.origin || filters.decisionType || filters.since || filters.until || filters.has_active_decision !== undefined) { + if (filters.decisionType) { return deleteAlertsBySearch(ctx, filters); } diff --git a/packages/crowdsec-mcp/src/tools/decisions.ts b/packages/crowdsec-mcp/src/tools/decisions.ts index 01cef4a34..f2d1989ee 100644 --- a/packages/crowdsec-mcp/src/tools/decisions.ts +++ b/packages/crowdsec-mcp/src/tools/decisions.ts @@ -13,7 +13,8 @@ import { normalizeScope, parseDuration, parsePositiveId, - resolveListWindow + resolveListWindow, + toGoDuration } from '../utils.js'; import { formatBanResult, @@ -37,10 +38,7 @@ function collectDecisionIds(alerts: APITypes.GetAlertsResponse): number[] { return [...ids]; } -async function searchActiveDecisionIds( - ctx: AppContext, - query: APITypes.Alerts.SearchAlerts.RequestQuery & { contains?: boolean } -): Promise { +async function searchActiveDecisionIds(ctx: AppContext, query: APITypes.Alerts.SearchAlerts.RequestQuery): Promise { const alerts = await ctx.watcher!.Alerts.search({ ...query, has_active_decision: true, limit: 0 }); return collectDecisionIds(alerts); } @@ -78,7 +76,9 @@ const ListDecisionsInput = z .boolean() .optional() .default(false) - .describe('false: decisions contained within the value (CIDR containment); true: decisions that contain the value'), + .describe( + 'Bouncer mode only: 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') @@ -256,15 +256,13 @@ async function unbanAll(ctx: AppContext, input: UnbanInput): Promise { throw new Error('Cannot combine all=true with id or filters; use all=true with confirm=true alone to delete everything'); } - return unbanDecisionIds(ctx, await searchActiveDecisionIds(ctx, {})); + 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'); - const activeIds = await searchActiveDecisionIds(ctx, {}); - if (!activeIds.includes(Number(decisionId))) { - throw new Error(`Decision ${decisionId} not found`); - } try { const result = await ctx.watcher!.Decisions.deleteById(decisionId); @@ -282,20 +280,9 @@ async function unbanById(ctx: AppContext, id: string | number): Promise } } -async function unbanByScope(ctx: AppContext, scope: string, type: string | undefined, scenario: string | undefined) { +async function unbanByScope(ctx: AppContext, scope: string, type: string | undefined, scenario: string | undefined): Promise { const normalized = assertValidDecisionScope(scope); - const decisions = await fetchActiveDecisions(ctx); - const ids = decisions - .filter( - (decision) => - decision.scope?.toLowerCase() === normalized.toLowerCase() && - (!type || decision.type?.toLowerCase() === type) && - (!scenario || decision.scenario === scenario) - ) - .map((decision) => decision.id) - .filter((id): id is number => typeof id === 'number'); - - return unbanDecisionIds(ctx, ids); + return unbanByQuery(ctx, { scope: normalized, type, scenario }); } async function unbanByScopeValue( @@ -311,31 +298,32 @@ async function unbanByScopeValue( 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); + } - const ids = await searchActiveDecisionIds(ctx, { - ip: normalized === 'Range' ? normalizedValue : undefined, - contains: false, - scope: normalized === 'Range' ? undefined : normalized, - value: normalized === 'Range' ? undefined : normalizedValue, - decision_type: type, - scenario - }); + 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); } -async function unbanByRange(ctx: AppContext, range: string, type: string | undefined, scenario: string | undefined) { - const ids = await searchActiveDecisionIds(ctx, { - ip: range, - contains: false, - 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: { ip?: string; value?: string; type?: string; scenario?: string }) { - if (!query.ip && !query.value && !query.type && !query.scenario) { +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'); } @@ -375,6 +363,8 @@ export const decisionTools: ToolDefinition[] = [ } 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 = { @@ -385,13 +375,12 @@ export const decisionTools: ToolDefinition[] = [ scenario, decision_type: decisionType, origin, - since, - until, + since: normalizedSince, + until: normalizedUntil, simulated: no_simulated ? false : undefined, - contains, has_active_decision: true, limit: window.fetchLimit - } as APITypes.Alerts.SearchAlerts.RequestQuery & { contains?: boolean }; + } as APITypes.Alerts.SearchAlerts.RequestQuery; const alerts = await ctx.watcher.Alerts.search(query); @@ -466,8 +455,8 @@ export const decisionTools: ToolDefinition[] = [ const decisionType = assertValidDecisionType(banInput.type); const { scope, targetValue } = resolveBanTarget(banInput); - const normalizedDuration = String(banInput.duration).trim(); - const durationSeconds = parseDuration(normalizedDuration); + 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); diff --git a/packages/crowdsec-mcp/src/transports/http.ts b/packages/crowdsec-mcp/src/transports/http.ts index e1941b51d..5a33675e4 100644 --- a/packages/crowdsec-mcp/src/transports/http.ts +++ b/packages/crowdsec-mcp/src/transports/http.ts @@ -14,6 +14,8 @@ export type RequestServerFactory = (headers: http.IncomingHttpHeaders) => Reques const MAX_BODY_SIZE = 4 * 1024 * 1024; +class BodyTooLargeError extends Error {} + export function isAuthorizedHeader(header: string | undefined, token: string): boolean { if (!header) { return false; @@ -57,7 +59,7 @@ function readBody(req: http.IncomingMessage): Promise { req.on('end', () => { if (tooLarge) { - reject(new Error('Payload too large')); + reject(new BodyTooLargeError('Payload too large')); return; } resolve(Buffer.concat(chunks).toString('utf-8')); @@ -100,8 +102,14 @@ async function readJsonBody(req: http.IncomingMessage, res: http.ServerResponse) let raw: string; try { raw = await readBody(req); - } catch { - sendRpcError(res, 413, -32000, 'Payload too large'); + } catch (error) { + if (error instanceof BodyTooLargeError) { + sendRpcError(res, 413, -32000, 'Payload too large'); + } 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 }; } @@ -113,6 +121,16 @@ async function readJsonBody(req: http.IncomingMessage, res: http.ServerResponse) } } +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); @@ -148,10 +166,10 @@ async function handlePost(req: http.IncomingMessage, res: http.ServerResponse, c }); res.on('close', () => { - void transport.close(); - void server.close(); + closeResource(transport.close(), 'transport close'); + closeResource(server.close(), 'server close'); if (cleanup) { - void cleanup(); + closeResource(cleanup(), 'request cleanup'); } }); @@ -206,6 +224,11 @@ export async function startHttpServer(createRequestServer: RequestServerFactory, void handleRequest(req, res, createRequestServer, token); }); + httpServer.requestTimeout = 30_000; + httpServer.headersTimeout = 10_000; + httpServer.keepAliveTimeout = 5_000; + httpServer.maxHeadersCount = 100; + return new Promise((resolve, reject) => { httpServer.once('error', reject); diff --git a/packages/crowdsec-mcp/src/utils.ts b/packages/crowdsec-mcp/src/utils.ts index 96958ec84..87cad9167 100644 --- a/packages/crowdsec-mcp/src/utils.ts +++ b/packages/crowdsec-mcp/src/utils.ts @@ -1,97 +1,7 @@ import { isIP } from 'node:net'; +import { parseDuration } from 'crowdsec-client'; -const DURATION_UNITS: Record = { - d: 86400, - h: 3600, - m: 60, - s: 1 -}; - -function isDigit(char: string | undefined): boolean { - return char !== undefined && char >= '0' && char <= '9'; -} - -interface DurationNumber { - amount: number; - next: number; -} - -function readDurationNumber(value: string, start: number): DurationNumber | undefined { - let index = start; - while (isDigit(value[index])) { - index++; - } - if (index === start) { - return undefined; - } - - if (value[index] === '.') { - index++; - const fractionStart = index; - while (isDigit(value[index])) { - index++; - } - if (index === fractionStart) { - return undefined; - } - } - - return { amount: Number(value.slice(start, index)), next: index }; -} - -/** - * Parse a cscli-like duration (4h, 30m, 7d, 1h30m) into seconds. - */ -export function parseDuration(input: string): number { - const value = input.trim(); - if (value === '') { - throw new Error('duration must not be empty'); - } - - let total = 0; - let index = 0; - - while (index < value.length) { - const parsed = readDurationNumber(value, index); - if (!parsed) { - throw new Error(`invalid duration '${input}': use 30m, 4h or 7d`); - } - - const unit = value[parsed.next]; - const multiplier = unit === undefined ? undefined : DURATION_UNITS[unit]; - if (multiplier === undefined) { - throw new Error(`invalid duration '${input}': use 30m, 4h or 7d`); - } - - total += parsed.amount * multiplier; - index = parsed.next + 1; - } - - if (total <= 0) { - throw new Error(`invalid duration '${input}': use 30m, 4h or 7d`); - } - - return total; -} - -export function formatDuration(seconds: number): string { - if (seconds <= 0) { - return '0s'; - } - - const days = Math.floor(seconds / 86400); - const hours = Math.floor((seconds % 86400) / 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'; -} +export { formatDuration, parseDuration, toGoDuration } from 'crowdsec-client'; export function isValidIp(value: string): boolean { return isIP(value) !== 0; diff --git a/packages/crowdsec-mcp/tests/tools/alerts.test.ts b/packages/crowdsec-mcp/tests/tools/alerts.test.ts index 404037485..e0283120a 100644 --- a/packages/crowdsec-mcp/tests/tools/alerts.test.ts +++ b/packages/crowdsec-mcp/tests/tools/alerts.test.ts @@ -180,15 +180,17 @@ describe('alertTools', () => { ); }); - it('should include kind filter in search', async () => { + it('should filter by kind client-side', async () => { + mockWatcher.Alerts.search.mockResolvedValue([ + { id: 1, created_at: CREATED_AT, kind: 'waf', source: { scope: 'Ip', value: '1.1.1.1' }, decisions: [] }, + { id: 2, created_at: CREATED_AT, kind: 'crowdsec', source: { scope: 'Ip', value: '2.2.2.2' }, decisions: [] } + ]); const tool = alertTools.find((t) => t.name === 'list_alerts')!; - await tool.handler({ kind: 'waf' }, mockCtx); + const result = await tool.handler({ kind: 'waf' }, mockCtx); - expect(mockWatcher.Alerts.search).toHaveBeenCalledWith( - expect.objectContaining({ - kind: 'waf' - }) - ); + expect(mockWatcher.Alerts.search).toHaveBeenCalledWith(expect.not.objectContaining({ kind: expect.anything() })); + expect(result).toContain('1.1.1.1'); + expect(result).not.toContain('2.2.2.2'); }); it('should format output as markdown table with summary', async () => { @@ -261,8 +263,7 @@ describe('alertTools', () => { expect(mockWatcher.Alerts.search).toHaveBeenCalledWith( expect.objectContaining({ - ip: '198.51.100.0/24', - contains: false + ip: '198.51.100.0/24' }) ); }); @@ -503,22 +504,17 @@ describe('alertTools', () => { expect(mockWatcher.Alerts.deleteById).not.toHaveBeenCalled(); }); - it('should delete alerts by origin through a search', async () => { + it('should delete alerts by origin with a direct delete', async () => { const tool = alertTools.find((t) => t.name === 'delete_alerts')!; - mockWatcher.Alerts.search.mockResolvedValue([{ id: 11 }, { id: 12 }]); - mockWatcher.Alerts.deleteById.mockResolvedValue({ nbDeleted: '1' }); - const result = await tool.handler({ origin: 'cscli' }, mockCtx); - expect(mockWatcher.Alerts.search).toHaveBeenCalledWith( + expect(mockWatcher.Alerts.delete).toHaveBeenCalledWith( expect.objectContaining({ - origin: 'cscli', - limit: 0 + alert_source: 'cscli' }) ); - expect(mockWatcher.Alerts.deleteById).toHaveBeenCalledTimes(2); - expect(mockWatcher.Alerts.delete).not.toHaveBeenCalled(); - expect(result).toContain('Deleted 2 alert(s).'); + expect(mockWatcher.Alerts.search).not.toHaveBeenCalled(); + expect(result).toContain('Deleted 5 alert(s).'); }); it('should delete alerts by decision type and time filters through a search', async () => { @@ -539,20 +535,17 @@ describe('alertTools', () => { expect(result).toContain('Deleted 1 alert(s).'); }); - it('should delete alerts with active decisions through a search', async () => { + it('should delete alerts with active decisions with a direct delete', async () => { const tool = alertTools.find((t) => t.name === 'delete_alerts')!; - mockWatcher.Alerts.search.mockResolvedValue([{ id: 21 }]); - mockWatcher.Alerts.deleteById.mockResolvedValue({ nbDeleted: '1' }); - const result = await tool.handler({ has_active_decision: true }, mockCtx); - expect(mockWatcher.Alerts.search).toHaveBeenCalledWith( + expect(mockWatcher.Alerts.delete).toHaveBeenCalledWith( expect.objectContaining({ - has_active_decision: true, - limit: 0 + has_active_decision: true }) ); - expect(result).toContain('Deleted 1 alert(s).'); + expect(mockWatcher.Alerts.search).not.toHaveBeenCalled(); + expect(result).toContain('Deleted 5 alert(s).'); }); it('should reject unknown parameters', async () => { @@ -567,7 +560,7 @@ describe('alertTools', () => { await expect(tool.handler({ scope: 'Country', value: 'ZZ' }, mockCtx)).rejects.toThrow(/invalid country code/); }); - it('should delete alerts contained in a CIDR', async () => { + 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' }); @@ -577,7 +570,6 @@ describe('alertTools', () => { expect(mockWatcher.Alerts.search).toHaveBeenCalledWith( expect.objectContaining({ ip: '198.51.100.0/24', - contains: false, limit: 0 }) ); diff --git a/packages/crowdsec-mcp/tests/tools/decisions.test.ts b/packages/crowdsec-mcp/tests/tools/decisions.test.ts index 19c4196cc..8f1a07928 100644 --- a/packages/crowdsec-mcp/tests/tools/decisions.test.ts +++ b/packages/crowdsec-mcp/tests/tools/decisions.test.ts @@ -186,8 +186,7 @@ describe('decisionTools', () => { expect(ctx.watcher!.Alerts.search).toHaveBeenCalledWith( expect.objectContaining({ - ip: '198.51.100.0/24', - contains: false + ip: '198.51.100.0/24' }) ); }); @@ -355,7 +354,6 @@ describe('decisionTools', () => { scenario: 'test', since: '24h', until: '1h', - contains: true, no_simulated: true }, ctx as any @@ -372,7 +370,6 @@ describe('decisionTools', () => { origin: 'cscli', since: '24h', until: '1h', - contains: true, simulated: false, has_active_decision: true }) @@ -516,20 +513,35 @@ describe('decisionTools', () => { await expect(tool!.handler({ range: 'not-a-cidr' }, ctx as any)).rejects.toThrow('not-a-cidr is not a valid range'); }); - it('should reject durations without a unit before calling the LAPI', async () => { + 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" ); - 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'); @@ -754,19 +766,17 @@ describe('decisionTools', () => { expect(ctx.watcher!.Decisions.delete).not.toHaveBeenCalled(); }); - it('should delete all active decisions for a scope when scope is provided alone', async () => { + it('should delete decisions for a scope with a direct delete', async () => { const ctx = createMockContext(); - ctx.watcher!.Alerts.search.mockResolvedValue([ - { id: 1, decisions: [{ id: 11, scope: 'Range', type: 'ban' }] }, - { id: 2, decisions: [{ id: 12, scope: 'Ip', type: 'ban' }] } - ]); - ctx.watcher!.Decisions.deleteById.mockResolvedValue({ nbDeleted: '1' }); + ctx.watcher!.Decisions.delete.mockResolvedValue({ nbDeleted: '1' }); const tool = findTool('unban_ip'); const result = await tool!.handler({ scope: 'Range' }, ctx as any); - expect(ctx.watcher!.Decisions.deleteById).toHaveBeenCalledWith('11'); - expect(ctx.watcher!.Decisions.deleteById).toHaveBeenCalledTimes(1); + expect(ctx.watcher!.Decisions.delete).toHaveBeenCalledWith( + expect.objectContaining({ scope: 'Range' }) + ); + expect(ctx.watcher!.Alerts.search).not.toHaveBeenCalled(); expect(result).toContain('1 decision(s) deleted'); }); @@ -779,9 +789,8 @@ describe('decisionTools', () => { ); }); - it('should delete by id when provided', async () => { + it('should delete by id when provided without scanning alerts', async () => { const ctx = createMockContext(); - ctx.watcher!.Alerts.search.mockResolvedValue([{ id: 1, decisions: [{ id: 123 }] }]); ctx.watcher!.Decisions.deleteById.mockResolvedValue({ nbDeleted: '1' }); const tool = findTool('unban_ip'); @@ -789,18 +798,18 @@ describe('decisionTools', () => { 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!.Alerts.search.mockResolvedValue([{ id: 1, decisions: [{ id: 123 }] }]); 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', async () => { + 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 }] }, @@ -814,7 +823,6 @@ describe('decisionTools', () => { expect(ctx.watcher!.Alerts.search).toHaveBeenCalledWith( expect.objectContaining({ ip: '198.51.100.0/24', - contains: false, has_active_decision: true, limit: 0 }) @@ -868,23 +876,20 @@ describe('decisionTools', () => { expect(query).not.toHaveProperty('value'); }); - it('should delete non-IP scopes via an active decision search', async () => { + it('should delete non-IP scopes with a direct delete', async () => { const ctx = createMockContext(); - ctx.watcher!.Alerts.search.mockResolvedValue([{ id: 1, decisions: [{ id: 21 }] }]); - ctx.watcher!.Decisions.deleteById.mockResolvedValue({ nbDeleted: '1' }); + 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!.Alerts.search).toHaveBeenCalledWith( + expect(ctx.watcher!.Decisions.delete).toHaveBeenCalledWith( expect.objectContaining({ scope: 'Country', - value: 'DE', - has_active_decision: true, - limit: 0 + value: 'DE' }) ); - expect(ctx.watcher!.Decisions.deleteById).toHaveBeenCalledWith('21'); + 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 index d2ea5c3ca..29ab1e24b 100644 --- a/packages/crowdsec-mcp/tests/transports/http.test.ts +++ b/packages/crowdsec-mcp/tests/transports/http.test.ts @@ -256,6 +256,34 @@ describe('http transport authentication', () => { } }); + 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 { diff --git a/packages/crowdsec-mcp/tests/utils.test.ts b/packages/crowdsec-mcp/tests/utils.test.ts index 2d504ac63..eb4843845 100644 --- a/packages/crowdsec-mcp/tests/utils.test.ts +++ b/packages/crowdsec-mcp/tests/utils.test.ts @@ -7,7 +7,8 @@ import { isValidTimeFilter, normalizeScope, parseDuration, - splitList + splitList, + toGoDuration } from '../src/utils.js'; describe('utils', () => { @@ -16,6 +17,8 @@ describe('utils', () => { 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', () => { @@ -25,6 +28,19 @@ describe('utils', () => { 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', () => { 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.") From 951da88adc954f298db5e61359fe1037f1a04c9c Mon Sep 17 00:00:00 2001 From: thibaut severac Date: Wed, 28 Oct 2026 00:12:14 +0100 Subject: [PATCH 19/24] fix(mcp): restore CIDR containment and simplify list_decisions - pass contains=false for alert searches so CIDR ranges match contained decisions - keep contains for watcher decisions and internal range unbans - extract list_decisions validation to lower cognitive complexity (Sonar S3776) --- packages/crowdsec-mcp/src/tools/alerts.ts | 16 +++-- packages/crowdsec-mcp/src/tools/decisions.ts | 64 +++++++++++-------- packages/crowdsec-mcp/src/utils.ts | 5 ++ .../crowdsec-mcp/tests/tools/alerts.test.ts | 4 +- .../tests/tools/decisions.test.ts | 6 +- 5 files changed, 62 insertions(+), 33 deletions(-) diff --git a/packages/crowdsec-mcp/src/tools/alerts.ts b/packages/crowdsec-mcp/src/tools/alerts.ts index 3d424029f..af093824e 100644 --- a/packages/crowdsec-mcp/src/tools/alerts.ts +++ b/packages/crowdsec-mcp/src/tools/alerts.ts @@ -15,6 +15,7 @@ import { resolveListWindow, toGoDuration } from '../utils.js'; +import type { AlertsSearchQuery } 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)))); @@ -175,7 +176,7 @@ async function deleteAlertsById(ctx: AppContext, id: string | number): Promise { // The delete endpoint matches exact filter values; search first so alerts // whose source IP is contained in the range are also removed. - const alerts = await ctx.watcher!.Alerts.search({ + const query: AlertsSearchQuery = { ip: cidr, scope: filters.normalizedScope, value: filters.normalizedValue, @@ -184,9 +185,11 @@ async function deleteAlertsByCidr(ctx: AppContext, filters: NormalizedDeleteAler 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, @@ -197,7 +200,7 @@ async function deleteAlertsByCidr(ctx: AppContext, filters: NormalizedDeleteAler async function deleteAlertsBySearch(ctx: AppContext, filters: NormalizedDeleteAlertInput): Promise { // `decision_type` is not supported by the delete endpoint, so this is the // only filter that still requires a search followed by id deletions. - const alerts = await ctx.watcher!.Alerts.search({ + const query: AlertsSearchQuery = { ip: filters.ip, range: filters.range, scope: filters.normalizedScope, @@ -207,9 +210,11 @@ async function deleteAlertsBySearch(ctx: AppContext, filters: NormalizedDeleteAl 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, @@ -281,7 +286,7 @@ export const alertTools: ToolDefinition[] = [ } const window = resolveListWindow(limit, all === true, ctx.config.maxResults); - const query: APITypes.Alerts.SearchAlerts.RequestQuery = { + const query: AlertsSearchQuery = { ip, range, scope, @@ -291,6 +296,7 @@ export const alertTools: ToolDefinition[] = [ origin, since: since ? toGoDuration(since) : undefined, until: until ? toGoDuration(until) : undefined, + contains: false, has_active_decision, limit: window.fetchLimit }; diff --git a/packages/crowdsec-mcp/src/tools/decisions.ts b/packages/crowdsec-mcp/src/tools/decisions.ts index f2d1989ee..b0b784bc3 100644 --- a/packages/crowdsec-mcp/src/tools/decisions.ts +++ b/packages/crowdsec-mcp/src/tools/decisions.ts @@ -25,6 +25,7 @@ import { } from '../format/decisions.js'; import { cleanErrorMessage } from '../registry.js'; import type { AppContext, ToolDefinition } from '../types.js'; +import type { AlertsSearchQuery } from '../utils.js'; function collectDecisionIds(alerts: APITypes.GetAlertsResponse): number[] { const ids = new Set(); @@ -38,8 +39,16 @@ function collectDecisionIds(alerts: APITypes.GetAlertsResponse): number[] { return [...ids]; } -async function searchActiveDecisionIds(ctx: AppContext, query: APITypes.Alerts.SearchAlerts.RequestQuery): Promise { - const alerts = await ctx.watcher!.Alerts.search({ ...query, has_active_decision: true, limit: 0 }); +async function searchActiveDecisionIds(ctx: AppContext, query: AlertsSearchQuery): Promise { + // `contains: false` means "decisions contained within the value" and is + // required for CIDR containment; the LAPI defaults to the opposite. + const searchQuery: AlertsSearchQuery = { + ...query, + contains: query.contains ?? false, + has_active_decision: true, + limit: 0 + }; + const alerts = await ctx.watcher!.Alerts.search(searchQuery); return collectDecisionIds(alerts); } @@ -76,9 +85,7 @@ const ListDecisionsInput = z .boolean() .optional() .default(false) - .describe( - 'Bouncer mode only: false: decisions contained within the value (CIDR containment); true: decisions that contain the value' - ), + .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') @@ -331,6 +338,26 @@ async function unbanByQuery(ctx: AppContext, query: UnbanQuery) { return formatUnbanResult(result.nbDeleted ?? 0); } +type ListDecisionsFilters = z.infer; + +function validateListDecisionsFilters(input: ListDecisionsFilters): void { + 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); + } + if (input.scope) { + assertValidDecisionScope(input.scope); + } + 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)`); + } +} + export const decisionTools: ToolDefinition[] = [ { name: 'list_decisions', @@ -342,32 +369,16 @@ export const decisionTools: ToolDefinition[] = [ handler: async (input, ctx) => { await ctx.ensureReady(); - const { ip, range, scope, value, type, origin, scenario, since, until, contains, no_simulated, all, limit } = - ListDecisionsInput.parse(input); - - if (ip && !isValidIp(ip) && !isValidCidr(ip)) { - throw new Error(`${ip} is not a valid ip or CIDR range`); - } - if (range) { - assertValidCidr(range); - } - if (scope) { - assertValidDecisionScope(scope); - } + const parsed = ListDecisionsInput.parse(input); + validateListDecisionsFilters(parsed); + const { ip, range, scope, value, type, origin, scenario, since, until, contains, no_simulated, all, limit } = parsed; 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, ctx.config.maxResults); const normalizedSince = since ? toGoDuration(since) : undefined; const normalizedUntil = until ? toGoDuration(until) : undefined; if (ctx.mode === 'watcher' && ctx.watcher) { - const query = { + const query: AlertsSearchQuery = { ip, range, scope, @@ -378,9 +389,10 @@ export const decisionTools: ToolDefinition[] = [ since: normalizedSince, until: normalizedUntil, simulated: no_simulated ? false : undefined, + contains, has_active_decision: true, limit: window.fetchLimit - } as APITypes.Alerts.SearchAlerts.RequestQuery; + }; const alerts = await ctx.watcher.Alerts.search(query); diff --git a/packages/crowdsec-mcp/src/utils.ts b/packages/crowdsec-mcp/src/utils.ts index 87cad9167..a4c85fa2b 100644 --- a/packages/crowdsec-mcp/src/utils.ts +++ b/packages/crowdsec-mcp/src/utils.ts @@ -1,8 +1,13 @@ import { isIP } from 'node:net'; +import type { APITypes } from 'crowdsec-client'; import { parseDuration } from 'crowdsec-client'; export { formatDuration, parseDuration, toGoDuration } from 'crowdsec-client'; +// `contains` is supported by the LAPI but missing from the upstream swagger, so +// it is not part of the generated request type yet. +export type AlertsSearchQuery = APITypes.Alerts.SearchAlerts.RequestQuery & { contains?: boolean }; + export function isValidIp(value: string): boolean { return isIP(value) !== 0; } diff --git a/packages/crowdsec-mcp/tests/tools/alerts.test.ts b/packages/crowdsec-mcp/tests/tools/alerts.test.ts index e0283120a..56ebce461 100644 --- a/packages/crowdsec-mcp/tests/tools/alerts.test.ts +++ b/packages/crowdsec-mcp/tests/tools/alerts.test.ts @@ -263,7 +263,8 @@ describe('alertTools', () => { expect(mockWatcher.Alerts.search).toHaveBeenCalledWith( expect.objectContaining({ - ip: '198.51.100.0/24' + ip: '198.51.100.0/24', + contains: false }) ); }); @@ -570,6 +571,7 @@ describe('alertTools', () => { expect(mockWatcher.Alerts.search).toHaveBeenCalledWith( expect.objectContaining({ ip: '198.51.100.0/24', + contains: false, limit: 0 }) ); diff --git a/packages/crowdsec-mcp/tests/tools/decisions.test.ts b/packages/crowdsec-mcp/tests/tools/decisions.test.ts index 8f1a07928..735c56a8c 100644 --- a/packages/crowdsec-mcp/tests/tools/decisions.test.ts +++ b/packages/crowdsec-mcp/tests/tools/decisions.test.ts @@ -186,7 +186,8 @@ describe('decisionTools', () => { expect(ctx.watcher!.Alerts.search).toHaveBeenCalledWith( expect.objectContaining({ - ip: '198.51.100.0/24' + ip: '198.51.100.0/24', + contains: false }) ); }); @@ -354,6 +355,7 @@ describe('decisionTools', () => { scenario: 'test', since: '24h', until: '1h', + contains: true, no_simulated: true }, ctx as any @@ -370,6 +372,7 @@ describe('decisionTools', () => { origin: 'cscli', since: '24h', until: '1h', + contains: true, simulated: false, has_active_decision: true }) @@ -823,6 +826,7 @@ describe('decisionTools', () => { expect(ctx.watcher!.Alerts.search).toHaveBeenCalledWith( expect.objectContaining({ ip: '198.51.100.0/24', + contains: false, has_active_decision: true, limit: 0 }) From 5b7a28fb1f9375165de5b9669bb3e92264f667e0 Mon Sep 17 00:00:00 2001 From: thibaut severac Date: Wed, 28 Oct 2026 11:56:52 +0100 Subject: [PATCH 20/24] fix(mcp): address review findings - config: default to watcher for TLS-only auth - unban_ip: require confirm for type/scenario-only deletes - alerts: keep since/until as Go durations on DELETE - allowlist: cover the year-1 never-expiration sentinel - docs/status: hide delete_alerts in read-only, clarify write capability - http: catch unhandled request failures - client: export GoDuration/SearchAlertsQuery/DeleteAlertsQuery overrides - build: use esbuild.js and generated pkg.ts for crowdsec-mcp --- packages/crowdsec-client/README.md | 16 +++++ packages/crowdsec-client/src/Alerts/Alerts.ts | 5 +- packages/crowdsec-client/src/duration.ts | 8 ++- packages/crowdsec-client/src/index.ts | 1 + packages/crowdsec-client/src/types/index.ts | 1 + .../crowdsec-client/src/types/overrides.ts | 34 ++++++++++ .../tests/Alerts/Alerts.test.ts | 26 ++++++-- packages/crowdsec-mcp/README.md | 8 ++- packages/crowdsec-mcp/esbuild.js | 40 ++++++++++++ packages/crowdsec-mcp/package.json | 5 +- packages/crowdsec-mcp/src/config.ts | 15 ++++- packages/crowdsec-mcp/src/docs.ts | 10 +-- packages/crowdsec-mcp/src/format/common.ts | 3 +- packages/crowdsec-mcp/src/pkg.ts | 1 + packages/crowdsec-mcp/src/tools/alerts.ts | 22 +++---- packages/crowdsec-mcp/src/tools/allowlists.ts | 6 -- packages/crowdsec-mcp/src/tools/decisions.ts | 53 ++++++++------- packages/crowdsec-mcp/src/tools/system.ts | 5 +- packages/crowdsec-mcp/src/transports/http.ts | 16 ++++- packages/crowdsec-mcp/src/utils.ts | 5 -- packages/crowdsec-mcp/src/version.ts | 6 +- packages/crowdsec-mcp/tests/config.test.ts | 30 +++++++++ packages/crowdsec-mcp/tests/docs.test.ts | 13 ++++ packages/crowdsec-mcp/tests/system.test.ts | 18 ++++- .../crowdsec-mcp/tests/tools/alerts.test.ts | 18 +++-- .../tests/tools/allowlists.test.ts | 23 +++++++ .../tests/tools/decisions.test.ts | 65 +++++++++++++++++-- pnpm-lock.yaml | 3 + 28 files changed, 363 insertions(+), 93 deletions(-) create mode 100644 packages/crowdsec-client/src/types/overrides.ts create mode 100644 packages/crowdsec-mcp/esbuild.js create mode 100644 packages/crowdsec-mcp/src/pkg.ts diff --git a/packages/crowdsec-client/README.md b/packages/crowdsec-client/README.md index 73ee12b4c..5091c7da8 100644 --- a/packages/crowdsec-client/README.md +++ b/packages/crowdsec-client/README.md @@ -146,6 +146,22 @@ await client.Alerts.delete({ 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 : 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 index c41f40d99..3e7fb908f 100644 --- a/packages/crowdsec-client/src/duration.ts +++ b/packages/crowdsec-client/src/duration.ts @@ -4,6 +4,12 @@ const UNIT_SECONDS: Record = { s: 1 }; +/** + * A duration string accepted by CrowdSec (`30m`, `4h`, `7d`, `1h30m`). + * `d` is a CrowdSec extension to Go's `time.ParseDuration`. + */ +export type GoDuration = string; + const DAY_SECONDS = 86400; function invalidDuration(input: string): Error { @@ -142,7 +148,7 @@ function formatSeconds(seconds: number): string { * * Numbers are interpreted as seconds. */ -export function toGoDuration(input: string | number): string { +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)); diff --git a/packages/crowdsec-client/src/index.ts b/packages/crowdsec-client/src/index.ts index 021b33350..560fe0e3d 100644 --- a/packages/crowdsec-client/src/index.ts +++ b/packages/crowdsec-client/src/index.ts @@ -12,4 +12,5 @@ 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-mcp/README.md b/packages/crowdsec-mcp/README.md index 55f121dc5..872f4d842 100644 --- a/packages/crowdsec-mcp/README.md +++ b/packages/crowdsec-mcp/README.md @@ -80,6 +80,8 @@ The authentication mode is auto-detected from the provided credentials, or can b 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 @@ -205,7 +207,7 @@ 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` must include a unit (`30m`, `4h`, `7d`, `1h30m`). Bare numbers such as `300` are rejected before the LAPI call. +- `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. @@ -222,7 +224,7 @@ unban_ip(id="42") unban_ip(all=true, confirm=true) ``` -`all=true` without `confirm=true` is rejected. `scope` is supported for `Ip`/`Range` and for non-IP scopes (`Country`, `AS`, `Session`, `Username`) via an active-decision lookup; matching decisions are deleted by ID. +`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) @@ -236,7 +238,7 @@ A remote watcher receives `403 access forbidden from this IP`. Use `unban_ip` to In passthrough mode, `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 from exposing the local-only destructive path. -`all=true` cannot be combined with filters. `ip` and `range` accept a CIDR and perform containment. Filters `id`, `origin`, `type`, `since`, `until` and `has_active_decision` are resolved through an alert search followed by deletion by ID, because the LAPI DELETE endpoint does not support all of them directly. `scope` must be combined with `value`; scope alone is rejected to avoid deleting every alert of that scope. Deleting an alert also deletes its active decisions; this cascade is a CrowdSec LAPI behavior. +`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 an alert also deletes its active decisions; this cascade is a CrowdSec LAPI behavior. ### CIDR semantics diff --git a/packages/crowdsec-mcp/esbuild.js b/packages/crowdsec-mcp/esbuild.js new file mode 100644 index 000000000..ab01ed7c0 --- /dev/null +++ b/packages/crowdsec-mcp/esbuild.js @@ -0,0 +1,40 @@ +import esbuild from 'esbuild'; +import path from 'path'; +import fs from 'fs'; +import * as url from '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 index 588390819..40bc837a5 100644 --- a/packages/crowdsec-mcp/package.json +++ b/packages/crowdsec-mcp/package.json @@ -49,8 +49,8 @@ }, "scripts": { "prepack": "npm run build", - "build": "rimraf dist && tsc -p tsconfig.json", - "dev": "tsc -p tsconfig.json --watch", + "build": "rimraf dist && node ./esbuild.js", + "dev": "node ./esbuild.js --watch", "start": "node dist/index.js", "test": "vitest run", "test:ci": "vitest run", @@ -70,6 +70,7 @@ "@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/config.ts b/packages/crowdsec-mcp/src/config.ts index c037337a5..b79a35231 100644 --- a/packages/crowdsec-mcp/src/config.ts +++ b/packages/crowdsec-mcp/src/config.ts @@ -132,7 +132,8 @@ function resolveAuth( apiKey: string | undefined, machineId: string | undefined, password: string | undefined, - passthrough: boolean + passthrough: boolean, + hasTls: boolean ): AuthMode { if (requestedAuth === 'bouncer' || requestedAuth === 'watcher') { return requestedAuth; @@ -151,7 +152,15 @@ function resolveAuth( // mode for the shared Config shape. return 'watcher'; } - throw new Error('unable to detect authentication mode: set CROWDSEC_API_KEY or CROWDSEC_MACHINE_ID/CROWDSEC_PASSWORD'); + 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'] { @@ -217,13 +226,13 @@ export function loadConfig(values: CliValues, env: NodeJS.ProcessEnv = process.e 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 auth = resolveAuth(requestedAuth, apiKey, machineId, password, passthrough); 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); diff --git a/packages/crowdsec-mcp/src/docs.ts b/packages/crowdsec-mcp/src/docs.ts index 63ea05344..47c275406 100644 --- a/packages/crowdsec-mcp/src/docs.ts +++ b/packages/crowdsec-mcp/src/docs.ts @@ -44,7 +44,9 @@ function writeSummary(ctx: AppContext): string { } function canDeleteAlerts(ctx: AppContext): boolean { - return isAlertDeleteAvailable(ctx.config); + // 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[] { @@ -77,11 +79,11 @@ function workingRules(ctx: AppContext): string[] { if (ctx.config.readonly) { add('Write tools are hidden: analyse and recommend, but never claim that a ban or unban was applied.'); } else { - add( - 'ban_ip, unban_ip and delete_alerts change real state: summarise the target, type and duration and wait for user confirmation.' - ); + 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('Never invent alert or decision IDs. If a lookup returns nothing, say so instead of guessing.'); diff --git a/packages/crowdsec-mcp/src/format/common.ts b/packages/crowdsec-mcp/src/format/common.ts index 3c3730f80..ff7a6e5a2 100644 --- a/packages/crowdsec-mcp/src/format/common.ts +++ b/packages/crowdsec-mcp/src/format/common.ts @@ -95,7 +95,8 @@ export function expiry(value?: string | Date, now = Date.now()): string { return String(value); } - // CrowdSec uses 0001-01-01T00:00:00Z for "no expiration". + // 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'; } 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/tools/alerts.ts b/packages/crowdsec-mcp/src/tools/alerts.ts index af093824e..9afacff50 100644 --- a/packages/crowdsec-mcp/src/tools/alerts.ts +++ b/packages/crowdsec-mcp/src/tools/alerts.ts @@ -15,7 +15,6 @@ import { resolveListWindow, toGoDuration } from '../utils.js'; -import type { AlertsSearchQuery } 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)))); @@ -176,7 +175,7 @@ async function deleteAlertsById(ctx: AppContext, id: string | number): 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: AlertsSearchQuery = { + const query: APITypes.SearchAlertsQuery = { ip: cidr, scope: filters.normalizedScope, value: filters.normalizedValue, @@ -198,9 +197,9 @@ async function deleteAlertsByCidr(ctx: AppContext, filters: NormalizedDeleteAler } async function deleteAlertsBySearch(ctx: AppContext, filters: NormalizedDeleteAlertInput): Promise { - // `decision_type` is not supported by the delete endpoint, so this is the - // only filter that still requires a search followed by id deletions. - const query: AlertsSearchQuery = { + // 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, @@ -222,7 +221,7 @@ async function deleteAlertsBySearch(ctx: AppContext, filters: NormalizedDeleteAl ); } -function buildDeleteQuery(filters: NormalizedDeleteAlertInput): APITypes.Alerts.DeleteAlerts.RequestQuery { +function buildDeleteQuery(filters: NormalizedDeleteAlertInput): APITypes.DeleteAlertsQuery { return { ip: filters.ip, scope: filters.normalizedScope, @@ -260,8 +259,6 @@ export const alertTools: ToolDefinition[] = [ idempotentHint: true }, handler: async (input, ctx) => { - await ctx.ensureReady(); - const { ip, range, scope, value, scenario, type, origin, kind, since, until, has_active_decision, all, limit } = ListAlertsInput.parse(input); @@ -286,7 +283,7 @@ export const alertTools: ToolDefinition[] = [ } const window = resolveListWindow(limit, all === true, ctx.config.maxResults); - const query: AlertsSearchQuery = { + const query: APITypes.SearchAlertsQuery = { ip, range, scope, @@ -294,6 +291,7 @@ export const alertTools: ToolDefinition[] = [ scenario, decision_type: decisionType, origin, + kind, since: since ? toGoDuration(since) : undefined, until: until ? toGoDuration(until) : undefined, contains: false, @@ -302,9 +300,8 @@ export const alertTools: ToolDefinition[] = [ }; const alerts = await ctx.watcher!.Alerts.search(query); - const filteredAlerts = kind ? alerts.filter((alert) => alert.kind === kind) : alerts; - return formatAlertList(filteredAlerts, window.displayLimit, window.maybeMore); + return formatAlertList(alerts, window.displayLimit, window.maybeMore); } }, { @@ -319,8 +316,6 @@ export const alertTools: ToolDefinition[] = [ idempotentHint: true }, handler: async (input, ctx) => { - await ctx.ensureReady(); - const { id } = GetAlertInput.parse(input); const alertId = parsePositiveId(id, 'alert id'); @@ -351,7 +346,6 @@ export const alertTools: ToolDefinition[] = [ idempotentHint: false }, handler: async (input, ctx) => { - await ctx.ensureReady(); const filters = normalizeDeleteInput(DeleteAlertsInput.parse(input)); if (filters.id !== undefined) { diff --git a/packages/crowdsec-mcp/src/tools/allowlists.ts b/packages/crowdsec-mcp/src/tools/allowlists.ts index 2464a282a..c1888eec7 100644 --- a/packages/crowdsec-mcp/src/tools/allowlists.ts +++ b/packages/crowdsec-mcp/src/tools/allowlists.ts @@ -45,8 +45,6 @@ export const allowlistTools: ToolDefinition[] = [ idempotentHint: true }, handler: async (input, ctx) => { - await ctx.ensureReady(); - const { limit } = ListAllowlistsInput.parse(input); const allowlists = await ctx.allowlists.list(); @@ -65,8 +63,6 @@ export const allowlistTools: ToolDefinition[] = [ idempotentHint: true }, handler: async (input, ctx) => { - await ctx.ensureReady(); - const { name, with_content: withContent, limit } = GetAllowlistInput.parse(input); try { @@ -94,8 +90,6 @@ export const allowlistTools: ToolDefinition[] = [ idempotentHint: true }, handler: async (input, ctx) => { - await ctx.ensureReady(); - const { value, values } = CheckAllowlistInput.parse(input); const targets: string[] = [...(values ?? []), ...(value ? [value] : [])]; diff --git a/packages/crowdsec-mcp/src/tools/decisions.ts b/packages/crowdsec-mcp/src/tools/decisions.ts index b0b784bc3..f170a1232 100644 --- a/packages/crowdsec-mcp/src/tools/decisions.ts +++ b/packages/crowdsec-mcp/src/tools/decisions.ts @@ -25,7 +25,6 @@ import { } from '../format/decisions.js'; import { cleanErrorMessage } from '../registry.js'; import type { AppContext, ToolDefinition } from '../types.js'; -import type { AlertsSearchQuery } from '../utils.js'; function collectDecisionIds(alerts: APITypes.GetAlertsResponse): number[] { const ids = new Set(); @@ -39,10 +38,10 @@ function collectDecisionIds(alerts: APITypes.GetAlertsResponse): number[] { return [...ids]; } -async function searchActiveDecisionIds(ctx: AppContext, query: AlertsSearchQuery): Promise { +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: AlertsSearchQuery = { + const searchQuery: APITypes.SearchAlertsQuery = { ...query, contains: query.contains ?? false, has_active_decision: true, @@ -120,12 +119,16 @@ const UnbanIpInput = z 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 (Ip or Range; other scopes are unsupported by the LAPI)'), + 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') + confirm: z + .boolean() + .optional() + .default(false) + .describe('Confirm deletion when all=true or when only type/scenario filters are used') }) .strict(); @@ -170,8 +173,9 @@ async function ensureBanAllowed(ctx: AppContext, scope: string, targetValue: str } const checkResult = await ctx.allowlists.check(targetValue); - if (checkResult.allowlisted && checkResult.reason) { - throw new Error(`${targetValue} is allowlisted by item ${checkResult.reason}, use force=true to add the decision anyway`); + 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`); } } @@ -225,11 +229,14 @@ function buildBanAlert(decisionType: string, scope: string, targetValue: string, } function validateUnbanTargets(ip?: string, range?: string, scope?: string, value?: string): void { - if (scope && value && (ip || range)) { + 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 (value && !scope && (ip || range)) { - throw new Error('Provide only one of ip, range, or value'); + if (Boolean(scope) !== Boolean(value)) { + throw new Error('scope and value must be provided together'); } } @@ -287,11 +294,6 @@ async function unbanById(ctx: AppContext, id: string | number): Promise } } -async function unbanByScope(ctx: AppContext, scope: string, type: string | undefined, scenario: string | undefined): Promise { - const normalized = assertValidDecisionScope(scope); - return unbanByQuery(ctx, { scope: normalized, type, scenario }); -} - async function unbanByScopeValue( ctx: AppContext, scope: string, @@ -367,8 +369,6 @@ export const decisionTools: ToolDefinition[] = [ kind: 'read', annotations: { readOnlyHint: true, idempotentHint: true }, handler: async (input, ctx) => { - await ctx.ensureReady(); - const parsed = ListDecisionsInput.parse(input); validateListDecisionsFilters(parsed); const { ip, range, scope, value, type, origin, scenario, since, until, contains, no_simulated, all, limit } = parsed; @@ -378,7 +378,7 @@ export const decisionTools: ToolDefinition[] = [ const normalizedUntil = until ? toGoDuration(until) : undefined; if (ctx.mode === 'watcher' && ctx.watcher) { - const query: AlertsSearchQuery = { + const query: APITypes.SearchAlertsQuery = { ip, range, scope, @@ -426,8 +426,6 @@ export const decisionTools: ToolDefinition[] = [ requires: ['watcher'], annotations: { readOnlyHint: true, idempotentHint: true }, handler: async (input, ctx) => { - await ctx.ensureReady(); - if (!ctx.watcher) { throw new Error('Watcher client not available'); } @@ -456,8 +454,6 @@ export const decisionTools: ToolDefinition[] = [ requires: ['watcher'], annotations: { readOnlyHint: false, destructiveHint: true }, handler: async (input, ctx) => { - await ctx.ensureReady(); - if (!ctx.watcher) { throw new Error('Watcher client not available'); } @@ -489,14 +485,13 @@ export const decisionTools: ToolDefinition[] = [ { name: 'unban_ip', title: 'Unban IP', - description: 'Delete decisions by ID or filters. Requires watcher mode. Mirrors `cscli decisions delete`.', + 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) => { - await ctx.ensureReady(); - if (!ctx.watcher) { throw new Error('Watcher client not available'); } @@ -523,9 +518,13 @@ export const decisionTools: ToolDefinition[] = [ const normalizedType = unbanInput.type ? assertValidDecisionType(unbanInput.type) : undefined; - if (unbanInput.scope && !unbanInput.value) { - return unbanByScope(ctx, unbanInput.scope, normalizedType, unbanInput.scenario); + 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); } diff --git a/packages/crowdsec-mcp/src/tools/system.ts b/packages/crowdsec-mcp/src/tools/system.ts index f63ddc0df..6468e8a1f 100644 --- a/packages/crowdsec-mcp/src/tools/system.ts +++ b/packages/crowdsec-mcp/src/tools/system.ts @@ -14,17 +14,16 @@ export const statusTool: ToolDefinition = { idempotentHint: true }, handler: async (_input, ctx) => { - await ctx.ensureReady(); - const machineSuffix = ctx.mode === 'watcher' && ctx.config.machineId ? ` (${ctx.config.machineId})` : ''; const auth = ctx.mode === 'watcher' ? `watcher${machineSuffix}` : 'bouncer'; + const writeLabel = ctx.capabilities.write ? (ctx.config.readonly ? 'yes (disabled by read-only)' : 'yes') : 'no'; const lines = [ 'CrowdSec MCP is connected.', '', bullets([ `URL: ${redactedUrl(ctx.config.url)}`, `Authentication: ${auth}`, - `Capabilities: read=yes, write=${ctx.capabilities.write ? 'yes' : 'no'}, allowlists=${ctx.capabilities.allowlists ? 'yes' : 'no'}`, + `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)`, diff --git a/packages/crowdsec-mcp/src/transports/http.ts b/packages/crowdsec-mcp/src/transports/http.ts index 5a33675e4..832d20066 100644 --- a/packages/crowdsec-mcp/src/transports/http.ts +++ b/packages/crowdsec-mcp/src/transports/http.ts @@ -221,7 +221,21 @@ export async function startHttpServer(createRequestServer: RequestServerFactory, const token = config.http.token; const httpServer = http.createServer((req, res) => { - void handleRequest(req, res, createRequestServer, token); + 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; diff --git a/packages/crowdsec-mcp/src/utils.ts b/packages/crowdsec-mcp/src/utils.ts index a4c85fa2b..87cad9167 100644 --- a/packages/crowdsec-mcp/src/utils.ts +++ b/packages/crowdsec-mcp/src/utils.ts @@ -1,13 +1,8 @@ import { isIP } from 'node:net'; -import type { APITypes } from 'crowdsec-client'; import { parseDuration } from 'crowdsec-client'; export { formatDuration, parseDuration, toGoDuration } from 'crowdsec-client'; -// `contains` is supported by the LAPI but missing from the upstream swagger, so -// it is not part of the generated request type yet. -export type AlertsSearchQuery = APITypes.Alerts.SearchAlerts.RequestQuery & { contains?: boolean }; - export function isValidIp(value: string): boolean { return isIP(value) !== 0; } diff --git a/packages/crowdsec-mcp/src/version.ts b/packages/crowdsec-mcp/src/version.ts index 45b7dbe8c..8768c433c 100644 --- a/packages/crowdsec-mcp/src/version.ts +++ b/packages/crowdsec-mcp/src/version.ts @@ -1,2 +1,4 @@ -export const VERSION = '0.1.0'; -export const NAME = 'crowdsec-mcp'; +import { pkg } from './pkg.js'; + +export const VERSION = pkg.version; +export const NAME = pkg.name; diff --git a/packages/crowdsec-mcp/tests/config.test.ts b/packages/crowdsec-mcp/tests/config.test.ts index 6ebaddb7d..c4cca325d 100644 --- a/packages/crowdsec-mcp/tests/config.test.ts +++ b/packages/crowdsec-mcp/tests/config.test.ts @@ -68,6 +68,36 @@ describe('config', () => { ).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( diff --git a/packages/crowdsec-mcp/tests/docs.test.ts b/packages/crowdsec-mcp/tests/docs.test.ts index 27c8de4e0..7ee0a967c 100644 --- a/packages/crowdsec-mcp/tests/docs.test.ts +++ b/packages/crowdsec-mcp/tests/docs.test.ts @@ -67,6 +67,19 @@ describe('server instructions', () => { expect(instructions).toContain('Alerts, allowlists and write tools are unavailable'); expect(instructions).toContain('read-only'); }); + + 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', () => { diff --git a/packages/crowdsec-mcp/tests/system.test.ts b/packages/crowdsec-mcp/tests/system.test.ts index a667be3a0..7ebbbb6a6 100644 --- a/packages/crowdsec-mcp/tests/system.test.ts +++ b/packages/crowdsec-mcp/tests/system.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { statusTool } from '../src/tools/system.js'; -import { makeContext } from './helpers.js'; +import { makeConfig, makeContext } from './helpers.js'; describe('crowdsec_status', () => { it('renders a human-readable status', async () => { @@ -13,4 +13,20 @@ describe('crowdsec_status', () => { 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 index 56ebce461..b5d836901 100644 --- a/packages/crowdsec-mcp/tests/tools/alerts.test.ts +++ b/packages/crowdsec-mcp/tests/tools/alerts.test.ts @@ -180,17 +180,15 @@ describe('alertTools', () => { ); }); - it('should filter by kind client-side', async () => { + 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: [] }, - { id: 2, created_at: CREATED_AT, kind: 'crowdsec', source: { scope: 'Ip', value: '2.2.2.2' }, decisions: [] } + { 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.not.objectContaining({ kind: expect.anything() })); + expect(mockWatcher.Alerts.search).toHaveBeenCalledWith(expect.objectContaining({ kind: 'waf' })); expect(result).toContain('1.1.1.1'); - expect(result).not.toContain('2.2.2.2'); }); it('should format output as markdown table with summary', async () => { @@ -536,6 +534,16 @@ describe('alertTools', () => { expect(result).toContain('Deleted 1 alert(s).'); }); + it('should send since and until to the delete endpoint as durations', async () => { + const tool = alertTools.find((t) => t.name === 'delete_alerts')!; + + const result = await tool.handler({ since: '24h', until: '1h' }, 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 delete alerts with active decisions with a direct delete', async () => { const tool = alertTools.find((t) => t.name === 'delete_alerts')!; const result = await tool.handler({ has_active_decision: true }, mockCtx); diff --git a/packages/crowdsec-mcp/tests/tools/allowlists.test.ts b/packages/crowdsec-mcp/tests/tools/allowlists.test.ts index cee5936c7..052042ac5 100644 --- a/packages/crowdsec-mcp/tests/tools/allowlists.test.ts +++ b/packages/crowdsec-mcp/tests/tools/allowlists.test.ts @@ -161,6 +161,29 @@ describe('allowlistTools', () => { 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' })); diff --git a/packages/crowdsec-mcp/tests/tools/decisions.test.ts b/packages/crowdsec-mcp/tests/tools/decisions.test.ts index 735c56a8c..fa1184a87 100644 --- a/packages/crowdsec-mcp/tests/tools/decisions.test.ts +++ b/packages/crowdsec-mcp/tests/tools/decisions.test.ts @@ -589,6 +589,15 @@ describe('decisionTools', () => { 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' }); @@ -769,18 +778,33 @@ describe('decisionTools', () => { expect(ctx.watcher!.Decisions.delete).not.toHaveBeenCalled(); }); - it('should delete decisions for a scope with a direct delete', async () => { + it('should reject scope without value', async () => { const ctx = createMockContext(); - ctx.watcher!.Decisions.delete.mockResolvedValue({ nbDeleted: '1' }); const tool = findTool('unban_ip'); - const result = await tool!.handler({ scope: 'Range' }, ctx as any); + 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(); + }); - expect(ctx.watcher!.Decisions.delete).toHaveBeenCalledWith( - expect.objectContaining({ scope: 'Range' }) + 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!.Alerts.search).not.toHaveBeenCalled(); - expect(result).toContain('1 decision(s) deleted'); + 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 () => { @@ -858,6 +882,33 @@ describe('decisionTools', () => { 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'); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ab0e50ffb..eca43265c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -447,6 +447,9 @@ importers: '@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 From a024a978a34f529d498d7fe40328ea38cb096413 Mon Sep 17 00:00:00 2001 From: thibaut severac Date: Wed, 28 Oct 2026 15:23:41 +0100 Subject: [PATCH 21/24] fix(mcp): address review and Sonar findings - normalize scope/value before sending list filters to the LAPI - keep CrowdSec casing (Ip/CAPI) in bouncer decision output - preserve real network/TLS errors during passthrough auth - handle aborted HTTP request bodies without leaking handlers - use the bulk allowlist endpoint for single checks too - fix Sonar issues: node: imports, regex escapes, nested ternary, async handler without await - keep GoDuration as a documented branded alias instead of a plain string --- packages/crowdsec-client/src/duration.ts | 5 ++- packages/crowdsec-mcp/esbuild.js | 8 ++--- packages/crowdsec-mcp/src/client.ts | 30 +++++++++++++--- packages/crowdsec-mcp/src/docs.ts | 9 ++--- packages/crowdsec-mcp/src/format/common.ts | 20 +++++++++++ packages/crowdsec-mcp/src/format/decisions.ts | 9 ++--- packages/crowdsec-mcp/src/prompts.ts | 5 ++- packages/crowdsec-mcp/src/tools/alerts.ts | 12 +++---- packages/crowdsec-mcp/src/tools/allowlists.ts | 11 ++---- packages/crowdsec-mcp/src/tools/decisions.ts | 25 +++++++------ packages/crowdsec-mcp/src/tools/system.ts | 7 ++-- packages/crowdsec-mcp/src/transports/http.ts | 20 ++++++++++- packages/crowdsec-mcp/src/types.ts | 2 +- packages/crowdsec-mcp/tests/docs.test.ts | 1 + .../crowdsec-mcp/tests/e2e/watcher.test.ts | 7 ++++ .../crowdsec-mcp/tests/tools/alerts.test.ts | 14 ++++++++ .../tests/tools/allowlists.test.ts | 36 +++++++++---------- .../tests/tools/decisions.test.ts | 14 ++++++++ 18 files changed, 168 insertions(+), 67 deletions(-) diff --git a/packages/crowdsec-client/src/duration.ts b/packages/crowdsec-client/src/duration.ts index 3e7fb908f..70c440edc 100644 --- a/packages/crowdsec-client/src/duration.ts +++ b/packages/crowdsec-client/src/duration.ts @@ -7,8 +7,11 @@ const UNIT_SECONDS: Record = { /** * 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; +export type GoDuration = string & { readonly __goDurationBrand?: never }; const DAY_SECONDS = 86400; diff --git a/packages/crowdsec-mcp/esbuild.js b/packages/crowdsec-mcp/esbuild.js index ab01ed7c0..df1247145 100644 --- a/packages/crowdsec-mcp/esbuild.js +++ b/packages/crowdsec-mcp/esbuild.js @@ -1,7 +1,7 @@ import esbuild from 'esbuild'; -import path from 'path'; -import fs from 'fs'; -import * as url from 'url'; +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)); @@ -14,7 +14,7 @@ fs.writeFileSync( let makeAllPackagesExternalPlugin = { name: 'make-all-packages-external', setup(build) { - let filter = /^[^.\/]|^\.[^.\/]|^\.\.[^\/]/; // Must not start with "/" or "./" or "../" + let filter = /^[^./]|^\.[^./]|^\.\.[^/]/; // Must not start with "/" or "./" or "../" build.onResolve({ filter }, (args) => ({ path: args.path, external: true })); } }; diff --git a/packages/crowdsec-mcp/src/client.ts b/packages/crowdsec-mcp/src/client.ts index 427686520..8413e48c2 100644 --- a/packages/crowdsec-mcp/src/client.ts +++ b/packages/crowdsec-mcp/src/client.ts @@ -1,6 +1,6 @@ import { readFile } from 'node:fs/promises'; import type { IncomingHttpHeaders } from 'node:http'; -import { BouncerClient, WatcherClient } from 'crowdsec-client'; +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'; @@ -13,6 +13,20 @@ interface TokenWatcherClientOptions { 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({ @@ -34,8 +48,11 @@ class TokenWatcherClient extends WatcherClient { public override async login(): Promise { try { await this.testConnection(); - } catch { - throw new Error('Invalid CrowdSec token'); + } catch (error) { + if (isAuthenticationFailure(error)) { + throw new Error('Invalid CrowdSec token'); + } + throw error; } } } @@ -151,9 +168,12 @@ export function createPassthroughContext(config: Config, headers: IncomingHttpHe try { await loginPromise; - } catch { + } catch (error) { loginPromise = undefined; - throw new Error('Invalid CrowdSec API key'); + if (isAuthenticationFailure(error)) { + throw new Error('Invalid CrowdSec API key'); + } + throw error; } }; diff --git a/packages/crowdsec-mcp/src/docs.ts b/packages/crowdsec-mcp/src/docs.ts index 47c275406..f4f572e7a 100644 --- a/packages/crowdsec-mcp/src/docs.ts +++ b/packages/crowdsec-mcp/src/docs.ts @@ -52,6 +52,7 @@ function canDeleteAlerts(ctx: AppContext): boolean { 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'}.` ]; @@ -67,7 +68,7 @@ function workingRules(ctx: AppContext): string[] { if (ctx.mode === 'bouncer') { add( - 'Only active decisions are visible: list_decisions then get_decision. Say when alerts or allowlists would need watcher credentials.' + '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.'); @@ -149,7 +150,7 @@ const OVERVIEW = (ctx: AppContext): string => '## What this MCP server exposes', '', '- Alerts: detection history (list_alerts, get_alert).', - '- Decisions: active remediation (list_decisions, get_decision, ban_ip, unban_ip).', + '- 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.', '', @@ -221,7 +222,7 @@ function triageModeLines(ctx: AppContext): string[] { '## Mode caveats', '', 'This server runs in bouncer mode: alerts and allowlists are unavailable, only active decisions can be read.', - 'Work from list_decisions and get_decision, and say when an investigation would need watcher credentials.' + 'Work from list_decisions, and say when get_decision, alerts or allowlists would need watcher credentials.' ]; } @@ -249,7 +250,7 @@ const TRIAGE = (ctx: AppContext): string => '', '## 2. Check the current remediation', '', - 'Use list_decisions with the same ip/range to see what is already blocked, and get_decision for context.', + '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', diff --git a/packages/crowdsec-mcp/src/format/common.ts b/packages/crowdsec-mcp/src/format/common.ts index ff7a6e5a2..3682c9c21 100644 --- a/packages/crowdsec-mcp/src/format/common.ts +++ b/packages/crowdsec-mcp/src/format/common.ts @@ -24,6 +24,26 @@ function stringifyCell(value: unknown): string { 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 '—'; diff --git a/packages/crowdsec-mcp/src/format/decisions.ts b/packages/crowdsec-mcp/src/format/decisions.ts index d0b6a29ca..80d15a48e 100644 --- a/packages/crowdsec-mcp/src/format/decisions.ts +++ b/packages/crowdsec-mcp/src/format/decisions.ts @@ -1,6 +1,6 @@ import type { APITypes } from 'crowdsec-client'; -import { resolveDecisionExpiry } from '../utils.js'; -import { escapeCell, expiry, formatDate, formatDecisionExpiry, markdownTable, plural, truncateRows } from './common.js'; +import { normalizeScope, resolveDecisionExpiry } from '../utils.js'; +import { escapeCell, expiry, formatDate, formatDecisionExpiry, formatOrigin, markdownTable, plural, truncateRows } from './common.js'; export interface DecisionRow { type: string; @@ -106,13 +106,14 @@ export function formatBouncerDecisionList(decisions: APITypes.GetDecisionsRespon 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(d.scope), + escapeCell(scope), escapeCell(d.value), - escapeCell(d.origin), + escapeCell(formatOrigin(d.origin)), escapeCell(d.scenario), escapeCell(expiry(expires)), escapeCell(d.simulated ? 'yes' : 'no') diff --git a/packages/crowdsec-mcp/src/prompts.ts b/packages/crowdsec-mcp/src/prompts.ts index c61f8b3c8..a3e065649 100644 --- a/packages/crowdsec-mcp/src/prompts.ts +++ b/packages/crowdsec-mcp/src/prompts.ts @@ -54,7 +54,10 @@ const DurationArg = z } }, 'Use a duration with a unit, e.g. 30m, 4h or 7d'); -const CountArg = z.string().trim().regex(/^\d+$/, 'Use a positive integer'); +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' }); diff --git a/packages/crowdsec-mcp/src/tools/alerts.ts b/packages/crowdsec-mcp/src/tools/alerts.ts index 9afacff50..4c0b614cf 100644 --- a/packages/crowdsec-mcp/src/tools/alerts.ts +++ b/packages/crowdsec-mcp/src/tools/alerts.ts @@ -268,12 +268,8 @@ export const alertTools: ToolDefinition[] = [ if (range) { assertValidCidr(range); } - if (scope) { - assertValidDecisionScope(scope); - } - if (scope && value) { - assertValidScopeValue(scope, value); - } + 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)`); @@ -286,8 +282,8 @@ export const alertTools: ToolDefinition[] = [ const query: APITypes.SearchAlertsQuery = { ip, range, - scope, - value, + scope: normalizedScope, + value: normalizedValue, scenario, decision_type: decisionType, origin, diff --git a/packages/crowdsec-mcp/src/tools/allowlists.ts b/packages/crowdsec-mcp/src/tools/allowlists.ts index c1888eec7..31734109e 100644 --- a/packages/crowdsec-mcp/src/tools/allowlists.ts +++ b/packages/crowdsec-mcp/src/tools/allowlists.ts @@ -105,14 +105,9 @@ export const allowlistTools: ToolDefinition[] = [ } } - if (uniqueTargets.length === 1) { - const result = await ctx.allowlists.check(uniqueTargets[0]); - return formatAllowlistCheck( - uniqueTargets, - result.allowlisted ? [{ target: uniqueTargets[0], allowlists: [result.reason || 'allowlist'] }] : [] - ); - } - + // 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 index f170a1232..8ad497fb8 100644 --- a/packages/crowdsec-mcp/src/tools/decisions.ts +++ b/packages/crowdsec-mcp/src/tools/decisions.ts @@ -342,22 +342,27 @@ async function unbanByQuery(ctx: AppContext, query: UnbanQuery) { type ListDecisionsFilters = z.infer; -function validateListDecisionsFilters(input: ListDecisionsFilters): void { +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); } - if (input.scope) { - assertValidDecisionScope(input.scope); - } + 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[] = [ @@ -370,8 +375,8 @@ export const decisionTools: ToolDefinition[] = [ annotations: { readOnlyHint: true, idempotentHint: true }, handler: async (input, ctx) => { const parsed = ListDecisionsInput.parse(input); - validateListDecisionsFilters(parsed); - const { ip, range, scope, value, type, origin, scenario, since, until, contains, no_simulated, all, limit } = parsed; + 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; @@ -381,8 +386,8 @@ export const decisionTools: ToolDefinition[] = [ const query: APITypes.SearchAlertsQuery = { ip, range, - scope, - value, + scope: normalizedScope, + value: normalizedValue, scenario, decision_type: decisionType, origin, @@ -403,8 +408,8 @@ export const decisionTools: ToolDefinition[] = [ const decisions = await ctx.bouncer.Decisions.search({ ip, range, - scope, - value, + scope: normalizedScope, + value: normalizedValue, type: decisionType, origins: origin, scenarios_containing: scenario, diff --git a/packages/crowdsec-mcp/src/tools/system.ts b/packages/crowdsec-mcp/src/tools/system.ts index 6468e8a1f..7fb1e88c5 100644 --- a/packages/crowdsec-mcp/src/tools/system.ts +++ b/packages/crowdsec-mcp/src/tools/system.ts @@ -13,10 +13,13 @@ export const statusTool: ToolDefinition = { readOnlyHint: true, idempotentHint: true }, - handler: async (_input, ctx) => { + handler: (_input, ctx) => { const machineSuffix = ctx.mode === 'watcher' && ctx.config.machineId ? ` (${ctx.config.machineId})` : ''; const auth = ctx.mode === 'watcher' ? `watcher${machineSuffix}` : 'bouncer'; - const writeLabel = ctx.capabilities.write ? (ctx.config.readonly ? 'yes (disabled by read-only)' : 'yes') : 'no'; + let writeLabel = 'no'; + if (ctx.capabilities.write) { + writeLabel = ctx.config.readonly ? 'yes (disabled by read-only)' : 'yes'; + } const lines = [ 'CrowdSec MCP is connected.', '', diff --git a/packages/crowdsec-mcp/src/transports/http.ts b/packages/crowdsec-mcp/src/transports/http.ts index 832d20066..a85667df9 100644 --- a/packages/crowdsec-mcp/src/transports/http.ts +++ b/packages/crowdsec-mcp/src/transports/http.ts @@ -15,6 +15,7 @@ export type RequestServerFactory = (headers: http.IncomingHttpHeaders) => Reques 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) { @@ -42,6 +43,15 @@ function readBody(req: http.IncomingMessage): Promise { 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. @@ -58,6 +68,10 @@ function readBody(req: http.IncomingMessage): Promise { }); req.on('end', () => { + if (settled) { + return; + } + settled = true; if (tooLarge) { reject(new BodyTooLargeError('Payload too large')); return; @@ -65,7 +79,9 @@ function readBody(req: http.IncomingMessage): Promise { resolve(Buffer.concat(chunks).toString('utf-8')); }); - req.on('error', reject); + req.on('error', (error) => fail(error)); + req.on('aborted', () => fail(new RequestAbortedError('Request aborted'))); + req.on('close', () => fail(new RequestAbortedError('Request aborted'))); }); } @@ -105,6 +121,8 @@ async function readJsonBody(req: http.IncomingMessage, res: http.ServerResponse) } 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`); diff --git a/packages/crowdsec-mcp/src/types.ts b/packages/crowdsec-mcp/src/types.ts index 34b2e49cf..617d8c276 100644 --- a/packages/crowdsec-mcp/src/types.ts +++ b/packages/crowdsec-mcp/src/types.ts @@ -42,5 +42,5 @@ export interface ToolDefinition { requires?: ToolRequirement[]; feature?: ToolFeature; annotations?: ToolAnnotations; - handler: (input: unknown, ctx: AppContext) => Promise; + handler: (input: unknown, ctx: AppContext) => CallToolResult | string | Promise; } diff --git a/packages/crowdsec-mcp/tests/docs.test.ts b/packages/crowdsec-mcp/tests/docs.test.ts index 7ee0a967c..40d8b647b 100644 --- a/packages/crowdsec-mcp/tests/docs.test.ts +++ b/packages/crowdsec-mcp/tests/docs.test.ts @@ -66,6 +66,7 @@ describe('server instructions', () => { 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', () => { diff --git a/packages/crowdsec-mcp/tests/e2e/watcher.test.ts b/packages/crowdsec-mcp/tests/e2e/watcher.test.ts index aba5b2bb5..b5ecfbbf9 100644 --- a/packages/crowdsec-mcp/tests/e2e/watcher.test.ts +++ b/packages/crowdsec-mcp/tests/e2e/watcher.test.ts @@ -63,6 +63,13 @@ describe('watcher stdio end-to-end', () => { 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('[]'); diff --git a/packages/crowdsec-mcp/tests/tools/alerts.test.ts b/packages/crowdsec-mcp/tests/tools/alerts.test.ts index b5d836901..5fadee94f 100644 --- a/packages/crowdsec-mcp/tests/tools/alerts.test.ts +++ b/packages/crowdsec-mcp/tests/tools/alerts.test.ts @@ -267,6 +267,20 @@ describe('alertTools', () => { ); }); + 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')!; diff --git a/packages/crowdsec-mcp/tests/tools/allowlists.test.ts b/packages/crowdsec-mcp/tests/tools/allowlists.test.ts index 052042ac5..75c85634d 100644 --- a/packages/crowdsec-mcp/tests/tools/allowlists.test.ts +++ b/packages/crowdsec-mcp/tests/tools/allowlists.test.ts @@ -269,31 +269,32 @@ describe('allowlistTools', () => { }); describe('check_allowlist', () => { - it('should check a single allowlisted value via check()', async () => { - mockAllowlists.check.mockResolvedValue({ - allowlisted: true, - reason: 'test-allowlist: Local network' - } satisfies APITypes.CheckAllowlistResponse); + 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.check).toHaveBeenCalledWith('192.168.1.1'); - expect(mockAllowlists.bulkCheck).not.toHaveBeenCalled(); + 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 fall back to a generic label when a single check has no reason', async () => { - mockAllowlists.check.mockResolvedValue({ allowlisted: true, reason: undefined } satisfies APITypes.CheckAllowlistResponse); + 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: allowlisted by allowlist'); - expect(result).toContain('1 target of 1 target matched an allowlist.'); + 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.check.mockResolvedValue({ allowlisted: false, reason: undefined } satisfies APITypes.CheckAllowlistResponse); + 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; @@ -302,10 +303,9 @@ describe('allowlistTools', () => { }); it('should distinguish a CIDR match from an exact IP match', async () => { - mockAllowlists.check.mockResolvedValue({ - allowlisted: true, - reason: '192.0.2.10' - } satisfies APITypes.CheckAllowlistResponse); + 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; @@ -313,11 +313,11 @@ describe('allowlistTools', () => { }); it('should trim checked values before calling the LAPI', async () => { - mockAllowlists.check.mockResolvedValue({ allowlisted: false } satisfies APITypes.CheckAllowlistResponse); + mockAllowlists.bulkCheck.mockResolvedValue({ results: [] } satisfies APITypes.BulkCheckAllowlistResponse); await findTool('check_allowlist').handler({ value: ' 192.168.1.1 ' }, mockCtx); - expect(mockAllowlists.check).toHaveBeenCalledWith('192.168.1.1'); + expect(mockAllowlists.bulkCheck).toHaveBeenCalledWith(['192.168.1.1']); }); it('should treat bulk targets missing from results as not allowlisted', async () => { diff --git a/packages/crowdsec-mcp/tests/tools/decisions.test.ts b/packages/crowdsec-mcp/tests/tools/decisions.test.ts index fa1184a87..62d583538 100644 --- a/packages/crowdsec-mcp/tests/tools/decisions.test.ts +++ b/packages/crowdsec-mcp/tests/tools/decisions.test.ts @@ -140,6 +140,20 @@ describe('decisionTools', () => { 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([ From a0f07a286ed0730cb3f9a67f7d17f51e3713fa05 Mon Sep 17 00:00:00 2001 From: thibaut severac Date: Thu, 29 Oct 2026 09:05:20 +0100 Subject: [PATCH 22/24] fix(mcp): hide delete_alerts over tokenless HTTP Gate the destructive delete_alerts tool on the HTTP transport itself: without a service token it stays hidden unless CROWDSEC_MCP_ALLOW_ALERT_DELETE_WITHOUT_TOKEN is explicitly set. This covers passthrough and any future tokenless HTTP setup. --- packages/crowdsec-mcp/README.md | 4 ++-- packages/crowdsec-mcp/src/config.ts | 15 +++++++++++-- .../tests/registry-watcher.test.ts | 22 +++++++++++++++++++ 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/packages/crowdsec-mcp/README.md b/packages/crowdsec-mcp/README.md index 872f4d842..990069faf 100644 --- a/packages/crowdsec-mcp/README.md +++ b/packages/crowdsec-mcp/README.md @@ -105,7 +105,7 @@ If both a bouncer key and machine credentials are present, set `CROWDSEC_AUTH` e | `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` | In passthrough, allow `delete_alerts` without a service token (dangerous) | `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` | @@ -236,7 +236,7 @@ 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. -In passthrough mode, `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 from exposing the local-only destructive path. +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 an alert also deletes its active decisions; this cascade is a CrowdSec LAPI behavior. diff --git a/packages/crowdsec-mcp/src/config.ts b/packages/crowdsec-mcp/src/config.ts index b79a35231..afec54fd7 100644 --- a/packages/crowdsec-mcp/src/config.ts +++ b/packages/crowdsec-mcp/src/config.ts @@ -305,7 +305,18 @@ export function loadConfig(values: CliValues, env: NodeJS.ProcessEnv = process.e } export function isAlertDeleteAvailable(config: Config): boolean { - return config.enableAlertDelete && (!config.passthrough || Boolean(config.http.token) || config.allowAlertDeleteWithoutToken); + 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 { @@ -344,7 +355,7 @@ MCP: --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 in passthrough without a service token (dangerous) + 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 diff --git a/packages/crowdsec-mcp/tests/registry-watcher.test.ts b/packages/crowdsec-mcp/tests/registry-watcher.test.ts index 2428c118c..724bbc2d4 100644 --- a/packages/crowdsec-mcp/tests/registry-watcher.test.ts +++ b/packages/crowdsec-mcp/tests/registry-watcher.test.ts @@ -56,6 +56,7 @@ describe('watcher registry', () => { const base = makeConfig({ readonly: false, enableAlertDelete: true, + transport: 'http', passthrough: true, http: { host: '127.0.0.1', port: 0, token: undefined } }); @@ -80,6 +81,27 @@ describe('watcher registry', () => { 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 }) })); From ec9e354bc99ce7c4aa1f1f28331d30ede0e1ed2d Mon Sep 17 00:00:00 2001 From: thibaut severac Date: Thu, 29 Oct 2026 09:42:15 +0100 Subject: [PATCH 23/24] chore: normalize non-ASCII punctuation to ASCII MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace em/en dashes, arrows, ellipsis, comparison and multiplication symbols in docs, comments, MCP output placeholders and tests. Keep the micro sign (µ) and the 2³² superscripts, which are meaningful units/values. --- docs/adr/0002-bouncer-decision-store.md | 34 ++++++------ docs/adr/README.md | 2 +- docs/agent-setup-guide.md | 32 ++++++------ .../crowdsec-client-scenarios/src/utils.ts | 2 +- .../bench/ip-lookup.ts | 6 +-- packages/crowdsec-http-middleware/readme.md | 4 +- .../crowdsec-http-middleware/src/utils.ts | 4 +- packages/crowdsec-mcp/README.md | 16 +++--- packages/crowdsec-mcp/src/format/alerts.ts | 52 +++++++++---------- .../crowdsec-mcp/src/format/allowlists.ts | 2 +- packages/crowdsec-mcp/src/format/common.ts | 8 +-- packages/crowdsec-mcp/src/format/decisions.ts | 24 ++++----- packages/crowdsec-mcp/tests/format.test.ts | 4 +- 13 files changed, 95 insertions(+), 95 deletions(-) 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 index beebb919e..84a24dc02 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -2,4 +2,4 @@ | ADR | Title | Status | | --- | --- | --- | -| [0002](./0002-bouncer-decision-store.md) | Bouncer decision store — numeric prefix index with walk-up lookup | Accepted | +| [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 dd3f9d271..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,10 +53,10 @@ 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` ? @@ -67,8 +67,8 @@ Both are Node.js bouncers. Rough comparison to present to the user : | 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,7 +162,7 @@ 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) @@ -251,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/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-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/readme.md b/packages/crowdsec-http-middleware/readme.md index 278aaddd5..112215f89 100644 --- a/packages/crowdsec-http-middleware/readme.md +++ b/packages/crowdsec-http-middleware/readme.md @@ -31,8 +31,8 @@ **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. 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/README.md b/packages/crowdsec-mcp/README.md index 990069faf..c14804ffd 100644 --- a/packages/crowdsec-mcp/README.md +++ b/packages/crowdsec-mcp/README.md @@ -90,12 +90,12 @@ If both a bouncer key and machine credentials are present, set `CROWDSEC_AUTH` e | --- | --- | --- | | `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_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` | @@ -389,8 +389,8 @@ Authorization: Bearer ``` Rules: -- Both `X-CrowdSec-Api-Key` and `X-CrowdSec-Token` → `400`. -- Neither → `401`. +- 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. diff --git a/packages/crowdsec-mcp/src/format/alerts.ts b/packages/crowdsec-mcp/src/format/alerts.ts index 091395c7e..741fa8688 100644 --- a/packages/crowdsec-mcp/src/format/alerts.ts +++ b/packages/crowdsec-mcp/src/format/alerts.ts @@ -14,7 +14,7 @@ function formatListHint(hidden: number, maybeMore: boolean, total: number, maxRe function formatDecisionsSummary(decisions: APITypes.Alert['decisions']): string { if (!decisions || decisions.length === 0) { - return '—'; + return '-'; } const counts = new Map(); @@ -45,7 +45,7 @@ function formatDecisionsSummary(decisions: APITypes.Alert['decisions']): string } } - return parts.length > 0 ? parts.join(' ') : '—'; + return parts.length > 0 ? parts.join(' ') : '-'; } export function formatAlertList(alerts: APITypes.GetAlertsResponse, maxResults: number, maybeMore = false): string { @@ -63,17 +63,17 @@ export function formatAlertList(alerts: APITypes.GetAlertsResponse, maxResults: 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 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 ?? '—'; + const kind = alert.kind ?? '-'; return [ - escapeCell(alert.id ?? '—'), + escapeCell(alert.id ?? '-'), escapeCell(source.scope), escapeCell(source.value), - escapeCell(alert.message ?? '—'), + escapeCell(alert.message ?? '-'), escapeCell(country), escapeCell(as), escapeCell(decisionsSummary), @@ -89,17 +89,17 @@ export function formatAlertList(alerts: APITypes.GetAlertsResponse, maxResults: } 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 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(decision.id ?? '-'), escapeCell(type), escapeCell(scope), escapeCell(value), @@ -117,7 +117,7 @@ function formatMeta(meta?: APITypes.Alert['meta']): string { const headers = ['Key', 'Value']; const rows = meta.map((m) => { - let value = m.value ?? '—'; + let value = m.value ?? '-'; // Try to parse JSON arrays/objects for readability try { const parsed = JSON.parse(value); @@ -129,7 +129,7 @@ function formatMeta(meta?: APITypes.Alert['meta']): string { } catch { // Not JSON, keep as is } - return [escapeCell(m.key ?? '—'), escapeCell(value)]; + return [escapeCell(m.key ?? '-'), escapeCell(value)]; }); return `\n### Meta\n\n${markdownTable(headers, rows)}`; @@ -137,17 +137,17 @@ function formatMeta(meta?: APITypes.Alert['meta']): string { function eventMetaKeys(meta: unknown): string { if (!meta) { - return '—'; + 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(', ') : '—'; + return keys.length > 0 ? keys.join(', ') : '-'; } if (typeof meta === 'object') { const keys = Object.keys(meta as Record); - return keys.length > 0 ? keys.join(', ') : '—'; + return keys.length > 0 ? keys.join(', ') : '-'; } - return '—'; + return '-'; } function formatEvents(events?: APITypes.Alert['events']): string { @@ -171,26 +171,26 @@ function formatEvents(events?: APITypes.Alert['events']): string { 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 country = source.cn ?? '-'; + const as = source.as_name ? `${source.as_number} (${source.as_name})` : (source.as_number ?? '-'); const lines: string[] = [ - `### Alert #${alert.id ?? '—'}`, + `### Alert #${alert.id ?? '-'}`, '', `- **Date:** ${formatDate(alert.created_at)} (${relativeTime(alert.created_at)})`, - `- **Machine:** ${alert.machine_id ?? '—'}`, + `- **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 ?? '—'}`, + `- **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 ?? '—'}` + `- **UUID:** ${alert.uuid ?? '-'}` ]; // Decisions table: only active decisions are shown. Deleted/expired decisions diff --git a/packages/crowdsec-mcp/src/format/allowlists.ts b/packages/crowdsec-mcp/src/format/allowlists.ts index 8decfe569..9160038bc 100644 --- a/packages/crowdsec-mcp/src/format/allowlists.ts +++ b/packages/crowdsec-mcp/src/format/allowlists.ts @@ -28,7 +28,7 @@ export function formatAllowlistDetail(allowlist: APITypes.GetAllowlistResponse, `**${escapeCell(allowlist.name)}**`, '', bullets([ - `Description: ${escapeCell(allowlist.description ?? '—')}`, + `Description: ${escapeCell(allowlist.description ?? '-')}`, `Created at: ${formatDate(allowlist.created_at)}`, `Updated at: ${formatDate(allowlist.updated_at)}`, `Managed by Console: ${allowlist.console_managed ? 'yes' : 'no'}` diff --git a/packages/crowdsec-mcp/src/format/common.ts b/packages/crowdsec-mcp/src/format/common.ts index 3682c9c21..2785c2d53 100644 --- a/packages/crowdsec-mcp/src/format/common.ts +++ b/packages/crowdsec-mcp/src/format/common.ts @@ -46,7 +46,7 @@ export function formatOrigin(value?: string): string | undefined { export function escapeCell(value: unknown): string { if (value === undefined || value === null || value === '') { - return '—'; + return '-'; } return stringifyCell(value) @@ -74,7 +74,7 @@ export function markdownTable(headers: string[], rows: string[][]): string { export function formatDate(value?: string | Date): string { if (!value) { - return '—'; + return '-'; } const date = value instanceof Date ? value : new Date(value); @@ -87,7 +87,7 @@ export function formatDate(value?: string | Date): string { export function relativeTime(value?: string | Date, now = Date.now()): string { if (!value) { - return '—'; + return '-'; } const date = value instanceof Date ? value : new Date(value); @@ -129,7 +129,7 @@ export function formatDecisionExpiry(expiryInfo: { expires?: string; active: boo return `${formatDate(expiryInfo.expires)} (${expiry(expiryInfo.expires)})`; } - return expiryInfo.active ? '—' : 'expired'; + return expiryInfo.active ? '-' : 'expired'; } export function truncateRows(rows: T[], maxResults: number): { rows: T[]; hidden: number } { diff --git a/packages/crowdsec-mcp/src/format/decisions.ts b/packages/crowdsec-mcp/src/format/decisions.ts index 80d15a48e..d1ede9b8e 100644 --- a/packages/crowdsec-mcp/src/format/decisions.ts +++ b/packages/crowdsec-mcp/src/format/decisions.ts @@ -73,8 +73,8 @@ export function formatDecisionList(alerts: APITypes.GetAlertsResponse, maxResult escapeCell(r.scenario), escapeCell(expiry(r.expires)), escapeCell(r.simulated ? 'yes' : 'no'), - escapeCell(r.alertId ?? '—'), - escapeCell(r.decisionId ?? '—') + escapeCell(r.alertId ?? '-'), + escapeCell(r.decisionId ?? '-') ]) ); @@ -130,14 +130,14 @@ export function formatDecisionDetail(decision: APITypes.Decision, alert?: APITyp const expires = formatDecisionExpiry(expiryInfo); const lines = [ - `### Decision #${decision.id ?? '—'}`, + `### Decision #${decision.id ?? '-'}`, '', - `- **Type:** ${decision.type ?? '—'}`, - `- **Scope:** ${decision.scope ?? '—'}`, - `- **Value:** ${decision.value ?? '—'}`, - `- **Origin:** ${decision.origin ?? '—'}`, - `- **Scenario:** ${decision.scenario ?? '—'}`, - `- **Remaining:** ${decision.duration ?? '—'}`, + `- **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'}` ]; @@ -145,10 +145,10 @@ export function formatDecisionDetail(decision: APITypes.Decision, alert?: APITyp if (alert) { lines.push( '', - `**Alert #${alert.id ?? '—'}**`, + `**Alert #${alert.id ?? '-'}**`, `- **Created at:** ${formatDate(alert.created_at)}`, - `- **Reason:** ${alert.message ?? '—'}`, - `- **Source:** ${alert.source?.scope ?? '—'}:${alert.source?.value ?? '—'}` + `- **Reason:** ${alert.message ?? '-'}`, + `- **Source:** ${alert.source?.scope ?? '-'}:${alert.source?.value ?? '-'}` ); } diff --git a/packages/crowdsec-mcp/tests/format.test.ts b/packages/crowdsec-mcp/tests/format.test.ts index 68f0c1f6c..53541cdd2 100644 --- a/packages/crowdsec-mcp/tests/format.test.ts +++ b/packages/crowdsec-mcp/tests/format.test.ts @@ -5,7 +5,7 @@ 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('—'); + expect(escapeCell(undefined)).toBe('-'); }); it('renders a markdown table', () => { @@ -25,7 +25,7 @@ describe('format', () => { 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('—'); + expect(relativeTime(undefined)).toBe('-'); }); it('formats expirations', () => { From aadf9c890a81c0290d2dfae3eee25d036bef4b1f Mon Sep 17 00:00:00 2001 From: thibaut severac Date: Thu, 29 Oct 2026 17:57:49 +0100 Subject: [PATCH 24/24] feat(mcp): require confirm for broad delete_alerts filters and document HTTP security delete_alerts without a narrow target (id, ip, range or scope+value) now requires confirm=true, like unban_ip type/scenario-only filters. Update the tool description, server instructions and README, and add tests for each broad filter. Also expand the README security section with the transport threat model, recommended deployments and security-relevant variables. --- packages/crowdsec-mcp/README.md | 80 +++++++++++++++++-- packages/crowdsec-mcp/src/docs.ts | 1 + packages/crowdsec-mcp/src/tools/alerts.ts | 11 ++- .../crowdsec-mcp/tests/tools/alerts.test.ts | 65 ++++++++++++--- 4 files changed, 137 insertions(+), 20 deletions(-) diff --git a/packages/crowdsec-mcp/README.md b/packages/crowdsec-mcp/README.md index c14804ffd..a65d9ca51 100644 --- a/packages/crowdsec-mcp/README.md +++ b/packages/crowdsec-mcp/README.md @@ -238,7 +238,7 @@ A remote watcher receives `403 access forbidden from this IP`. Use `unban_ip` to 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 an alert also deletes its active decisions; this cascade is a CrowdSec LAPI behavior. +`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 @@ -399,13 +399,77 @@ Rules: ## Security -- Read-only mode is enabled by default. Keep it that way unless the model really needs writes. -- Keep credentials in the environment, not in command-line arguments: arguments are visible in the process list. -- Outside passthrough mode, HTTP always requires a bearer token, including on `127.0.0.1`. The server refuses to start in HTTP mode without one. -- In passthrough mode, the service token is optional; the LAPI credential is still required on every request. Use TLS and never log request headers. -- `ban_ip`, `unban_ip` and `delete_alerts` are destructive. Restrict them with `CROWDSEC_MCP_TOOLS` or keep read-only mode. -- `ban_ip` creates a real CrowdSec alert and decision. It may be forwarded to CAPI depending on your CrowdSec configuration. -- Use `CROWDSEC_MCP_TOOLS` to expose the smallest useful surface to the model. +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 diff --git a/packages/crowdsec-mcp/src/docs.ts b/packages/crowdsec-mcp/src/docs.ts index f4f572e7a..feec1a5d8 100644 --- a/packages/crowdsec-mcp/src/docs.ts +++ b/packages/crowdsec-mcp/src/docs.ts @@ -85,6 +85,7 @@ function workingRules(ctx: AppContext): string[] { 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.'); diff --git a/packages/crowdsec-mcp/src/tools/alerts.ts b/packages/crowdsec-mcp/src/tools/alerts.ts index 4c0b614cf..45ecf3121 100644 --- a/packages/crowdsec-mcp/src/tools/alerts.ts +++ b/packages/crowdsec-mcp/src/tools/alerts.ts @@ -59,7 +59,7 @@ const DeleteAlertsInput = z 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') + confirm: z.boolean().optional().describe('Confirm deletion when using all=true or broad filters') }) .strict(); @@ -144,6 +144,13 @@ function normalizeDeleteInput(input: DeleteAlertInput): NormalizedDeleteAlertInp ); } + 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 }; } @@ -331,7 +338,7 @@ export const alertTools: ToolDefinition[] = [ 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. ip and range accept a CIDR and perform containment. CrowdSec only allows alert deletion from the LAPI host; remote watchers receive 403.', + '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'], diff --git a/packages/crowdsec-mcp/tests/tools/alerts.test.ts b/packages/crowdsec-mcp/tests/tools/alerts.test.ts index 5fadee94f..2b4903a62 100644 --- a/packages/crowdsec-mcp/tests/tools/alerts.test.ts +++ b/packages/crowdsec-mcp/tests/tools/alerts.test.ts @@ -517,9 +517,18 @@ describe('alertTools', () => { expect(mockWatcher.Alerts.deleteById).not.toHaveBeenCalled(); }); - it('should delete alerts by origin with a direct delete', async () => { + it('should require confirm for origin-only deletes', async () => { const tool = alertTools.find((t) => t.name === 'delete_alerts')!; - const result = await tool.handler({ origin: 'cscli' }, mockCtx); + + 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({ @@ -530,12 +539,21 @@ describe('alertTools', () => { expect(result).toContain('Deleted 5 alert(s).'); }); - it('should delete alerts by decision type and time filters through a search', async () => { + 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' }, mockCtx); + const result = await tool.handler({ type: 'Ban', since: '4h', until: '1h', confirm: true }, mockCtx); expect(mockWatcher.Alerts.search).toHaveBeenCalledWith( expect.objectContaining({ @@ -548,19 +566,37 @@ describe('alertTools', () => { expect(result).toContain('Deleted 1 alert(s).'); }); - it('should send since and until to the delete endpoint as durations', async () => { + 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' }, mockCtx); + 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 delete alerts with active decisions with a direct delete', async () => { + it('should require confirm for has_active_decision-only deletes', async () => { const tool = alertTools.find((t) => t.name === 'delete_alerts')!; - const result = await tool.handler({ has_active_decision: true }, mockCtx); + + 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({ @@ -623,9 +659,18 @@ describe('alertTools', () => { ); }); - it('should throw when only scenario provided without id', async () => { + 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' }, mockCtx); + const result = await tool.handler({ scenario: 'ssh-bf', confirm: true }, mockCtx); expect(mockWatcher.Alerts.delete).toHaveBeenCalledWith( expect.objectContaining({