diff --git a/CHANGELOG.md b/CHANGELOG.md index 949c42bd..3f39ab17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Implement pre-processing ECS sources types sanitization [(#628)](https://github.com/wazuh/wazuh-indexer-plugins/pull/628) - Add Security Compliance fields to the WCS [(#643)](https://github.com/wazuh/wazuh-indexer-plugins/pull/643) - Initialize indexer content manager [(#651)](https://github.com/wazuh/wazuh-indexer-plugins/pull/651) +- Implement Imposter mock server for CTI API [#661](https://github.com/wazuh/wazuh-indexer-plugins/pull/661) ### Dependencies - Upgrade to Gradle 8.14.3 [(#649)](https://github.com/wazuh/wazuh-indexer-plugins/pull/649) diff --git a/plugins/content-manager/imposter/.gitignore b/plugins/content-manager/imposter/.gitignore new file mode 100644 index 00000000..81871cac --- /dev/null +++ b/plugins/content-manager/imposter/.gitignore @@ -0,0 +1,8 @@ +# Ignore SSL certificates and keys +*.pem +*.key +*.crt +*.csr + +# Ignore OpenSSL temporary files +*.cnf diff --git a/plugins/content-manager/imposter/README.md b/plugins/content-manager/imposter/README.md new file mode 100644 index 00000000..8b7ce772 --- /dev/null +++ b/plugins/content-manager/imposter/README.md @@ -0,0 +1,368 @@ +# Imposter Mock Server + +This directory contains the configuration for an Imposter mock server. + +## Prerequisites + +- Docker installed on your machine +- Ports 8443 available + +## Directory Structure + +``` +imposter/ +├── imposter.sh # Helper script to start/stop/remove the server +├── README.md # This file +├── imposter-config.yml # Main Imposter configuration +├── openapi.yml # OpenAPI specification +├── images/ # Docker Compose configurations +│ ├── compose.yml # Docker Compose file +│ └── nginx/ # nginx reverse proxy for SSL +│ └── nginx.conf # nginx SSL configuration +└── scripts/ # Groovy response scripts + ├── tokenResponse.groovy # Token request logic + ├── tokenExchangeResponse.groovy # Token exchange logic + ├── instanceMeResponse.groovy # Instance me logic + └── catalogResponse.groovy # Catalog endpoint logic +``` + +## Quick Start + +### 1. Start the Server (Recommended) + +From the `imposter/` directory, run: +```bash +./imposter.sh up +``` + +The server will start on `https://localhost:8443`. + +> [!NOTE] +> This environment automatically: +> - Generates self-signed certificates for localhost (if not already present) +> - Starts nginx as a reverse proxy with SSL termination +> - Proxies requests to the Imposter container on port 8443 + +### 2. Verify the Server is Running + +```bash +curl -k https://localhost:8443/system/status +``` + +### 3. Managing the Server + +**Stop the server (without removing containers):** +```bash +./imposter.sh stop +``` + +**Stop and remove the server:** +```bash +./imposter.sh down +``` + +## Available Endpoints + +> [!NOTE] +> All endpoints are served via HTTPS on `https://localhost:8443`. Use the `-k` flag with curl commands to bypass SSL certificate verification (since we use self-signed certificates). + +### 1. Token Request (CTI Authentication) + +Request an CTI access token: + +```bash +curl -k -X POST https://localhost:8443/api/v1/instances/token \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=urn:ietf:params:oauth:grant-type:device_code" \ + -d "client_id=a17c21ed" \ + -d "device_code=NGU5OWFiNjQ5YmQwNGY3YTdmZTEyNzQ3YzQ1YSA" +``` + +Note that as it follows the real implementation, it returns the "pending" state a few times before returning the access token. + +**Expected Response (200 OK):** +```json +{ + "access_token": "AYjcyMzY3ZDhiNmJkNTY", + "token_type": "Bearer", + "expires_in": 3600 +} +``` + +> [!TIP] +> Use different `device_code` values to test different authentication flows: +> +> | `device_code` | Behavior | +> |--------------------|----------------------------------------------------------------------------------------------| +> | `` (default) | Returns `authorization_pending` for the first 4 requests, then grants the token. | +> | `pending_rejected` | Returns `authorization_pending` for the first 4 requests, then returns `access_denied` | +> | `pending` | Always returns `authorization_pending` (simulates a user who hasn't completed authorization) | +> | `expired` | Always returns `expired_token` error | +> | `granted` | Immediately returns an access token without pending state | + +### 2. Token Exchange (Get Signed URL) + +Exchange an access token for a signed URL: + +```bash +curl -k -X POST https://localhost:8443/api/v1/instances/token/exchange \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -H "Authorization: Bearer AYjcyMzY3ZDhiNmJkNTY" \ + -d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \ + -d "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \ + -d "requested_token_type=urn:wazuh:params:oauth:token-type:signed_url" \ + -d "resource=https://localhost:8443/api/v1/catalog/contexts/misp/consumer/virustotal/changes" +``` + +**Expected Response (200 OK):** +```json +{ + "issued_token_type": "urn:wazuh:params:oauth:token-type:signed_url", + "access_token": "https://localhost:8443/api/v1/catalog/contexts/misp/consumers/virustotal/changes?from_offset=0&to_offset=1000&with_empties=true&verify=1761383411-kJ9b8w%2BQ7kzRmF", + "token_type": "N_A", + "expires_in": 3600 +} +``` + +### 3. Instance Me (Get Plan Details) + +Get current instance details and plan information: + +**Pro Plan Response:** +```bash +curl -k -X GET https://localhost:8443/api/v1/instances/me \ + -H "Authorization: Bearer pro_token" \ + -H "Content-Type: application/json" +``` + +**Expected Response (200 OK):** +```json +{ + "data": { + "organization": { + "avatar": "https://acme.sl/avatar.png", + "name": "ACME S.L." + }, + "plans": [ + { + "name": "Pro Plan Deluxe", + "description": "Lorem ipsum…", + "products": [ + { + "type": "catalog:consumer:vulnerabilities", + "identifier": "vulnerabilities-pro", + "name": "Vulnerabilities Pro", + "description": "Vulnerabilities updated as soon as they are added to the catalog", + "resource": "https://localhost:8443/api/v1/catalog/plans/pro/contexts/vulnerabilities/consumer/realtime" + }, + { + "type": "catalog:consumer:iocs", + "identifier": "bad-guy-ips-pro", + "name": "Bad Guy IPs", + "description": "Dolor sit amet…", + "resource": "https://localhost:8443/api/v1/catalog/plans/pro/contexts/bad-guy-ips/consumer/realtime" + } + ] + } + ] + } +} +``` + +**Cloud Plan Response:** +```bash +curl -k -X GET https://localhost:8443/api/v1/instances/me \ + -H "Authorization: Bearer cloud_token" \ + -H "Content-Type: application/json" +``` + +**Expected Response (200 OK):** +```json +{ + "data": { + "organization": { + "avatar": "https://acme.sl/avatar.png", + "name": "ACME S.L." + }, + "plans": [ + { + "name": "Wazuh Cloud", + "description": "Managed instances in AWS by Wazuh's professional staf that…", + "products": [ + { + "identifier": "assistance-24h", + "type": "cloud:assistance:wazuh", + "name": "Technical assistance 24h", + "email": "cloud@wazuh.com", + "phone": "+34 123 456 789" + }, + { + "identifier": "vulnerabilities-pro", + "type": "catalog:consumer:vulnerabilities", + "name": "Vulnerabilities Pro", + "description": "Vulnerabilities updated as soon as they are added to the catalog", + "resource": "https://localhost:8443/api/v1/catalog/plans/pro/contexts/vulnerabilities/consumer/realtime" + }, + { + "identifier": "bad-guy-ips-pro", + "type": "catalog:consumer:iocs", + "name": "Bad Guy IPs", + "description": "Dolor sit amet…", + "resource": "https://localhost:8443/api/v1/catalog/plans/pro/contexts/bad-guy-ips/consumer/realtime" + } + ] + } + ] + } +} +``` + +### 4. Catalog Download (Use Token) + +Download catalog using a signed URL: + +```bash +curl -k -X GET "https://localhost:8443/api/v1/catalog/contexts/misp/consumers/virustotal/changes?from_offset=0&to_offset=1000&with_empties=true&verify=1761383411-kJ9b8w%2BQ7kzRmF" +``` + +**Expected Response (200 OK):** +```json +{ + "data": [ + { + "type": "create", + "context": "misp", + "resource": "indicator/12345", + "offset": 42, + "version": 1, + "payload": { + "type": "ip-address", + "value": "192.168.1.100", + "threat_level": "high", + "timestamp": "2025-11-17T10:30:00Z" + } + }, + { + "type": "update", + "context": "misp", + "resource": "indicator/12345", + "offset": 43, + "version": 2, + "operations": [ + { + "op": "replace", + "path": "/threat_level", + "value": "critical" + } + ] + }, + { + "type": "delete", + "context": "misp", + "resource": "indicator/12345", + "offset": 44, + "version": 3 + } + ] +} +``` + +## Testing Different Scenarios + +### Authorization Pending + +```bash +curl -k -X POST https://localhost:8443/api/v1/instances/token \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=urn:ietf:params:oauth:grant-type:device_code" \ + -d "client_id=test_client" \ + -d "device_code=pending_code" +``` + +**Expected Response (400 Bad Request):** +```json +{ + "error": "authorization_pending", + "error_description": "The authorization request is still pending" +} +``` + +### Invalid Token Exchange + +```bash +curl -k -X POST https://localhost:8443/api/v1/instances/token/exchange \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -H "Authorization: Bearer invalid_token" \ + -d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" +``` + +**Expected Response (401 Unauthorized):** +```json +{ + "error": "invalid_token", + "error_description": "The access token is invalid or expired" +} +``` + +### Missing Authorization Header (Instance Me) + +```bash +curl -k -X GET https://localhost:8443/api/v1/instances/me \ + -H "Content-Type: application/json" +``` + +**Expected Response (401 Unauthorized):** +```json +{ + "error": "unauthorized_client", + "error_description": "The provided token is invalid or expired" +} +``` + +## Stopping the Server + +To stop the server, use the helper script: + +```bash +./imposter.sh stop # Stop containers without removing them +./imposter.sh down # Stop and remove containers +``` + +## Customization + +To modify the mock responses: + +1. Edit the OpenAPI specifications +2. Update examples or add new response scenarios +3. Restart the Docker container + +## Troubleshooting + +**Port 8443 already in use:** + +Check what's using the port and stop it: +```bash +lsof -i :8443 +# Or change the port in images/compose.yml under nginx ports section +``` + +**SSL Certificate Issues:** + +If you need to regenerate certificates: +```bash +rm -rf images/nginx/certs/* +./imposter.sh up # Will regenerate certificates automatically +``` + +**Containers not starting:** + +Check the logs: +```bash +cd images/ +docker compose logs +``` + +## Resources + +- [Imposter Documentation](https://docs.imposter.sh/) +- [OpenAPI Specification](https://swagger.io/specification/) diff --git a/plugins/content-manager/imposter/images/compose.yml b/plugins/content-manager/imposter/images/compose.yml new file mode 100644 index 00000000..c485749c --- /dev/null +++ b/plugins/content-manager/imposter/images/compose.yml @@ -0,0 +1,15 @@ +services: + imposter: + image: outofcoffee/imposter-openapi + volumes: + - ../:/opt/imposter/config + ports: [] # Remove port exposure when using nginx + nginx: + image: nginx:alpine + ports: + - "8443:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./nginx/certs:/etc/nginx/certs:ro + depends_on: + - imposter diff --git a/plugins/content-manager/imposter/images/nginx/nginx.conf b/plugins/content-manager/imposter/images/nginx/nginx.conf new file mode 100644 index 00000000..21977711 --- /dev/null +++ b/plugins/content-manager/imposter/images/nginx/nginx.conf @@ -0,0 +1,21 @@ +events { + worker_connections 1024; +} + +http { + server { + listen 443 ssl; + server_name localhost; + + ssl_certificate /etc/nginx/certs/cert.pem; + ssl_certificate_key /etc/nginx/certs/key.pem; + + location / { + proxy_pass http://imposter:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + } +} diff --git a/plugins/content-manager/imposter/imposter-config.yml b/plugins/content-manager/imposter/imposter-config.yml new file mode 100644 index 00000000..a9c838a0 --- /dev/null +++ b/plugins/content-manager/imposter/imposter-config.yml @@ -0,0 +1,33 @@ +plugin: openapi +specFile: openapi.yml + +# Enable stores for stateful behavior (retry system) +stores: + - name: token_requests + type: memory + +# Custom resource configuration for conditional responses +resources: + # Token endpoint - conditional responses based on device_code + - path: /api/v1/instances/token + method: POST + response: + scriptFile: scripts/tokenResponse.groovy + + # Token exchange endpoint - conditional responses based on Authorization header + - path: /api/v1/instances/token/exchange + method: POST + response: + scriptFile: scripts/tokenExchangeResponse.groovy + + # Instance me endpoint - conditional responses based on Authorization token + - path: /api/v1/instances/me + method: GET + response: + scriptFile: scripts/instanceMeResponse.groovy + + # Catalog endpoint - conditional responses based on verify parameter + - path: /api/v1/catalog/contexts/{context}/consumers/{consumer}/changes + method: GET + response: + scriptFile: scripts/catalogResponse.groovy diff --git a/plugins/content-manager/imposter/imposter.sh b/plugins/content-manager/imposter/imposter.sh new file mode 100755 index 00000000..ec0815c3 --- /dev/null +++ b/plugins/content-manager/imposter/imposter.sh @@ -0,0 +1,113 @@ +#!/bin/bash + +set -e + +# Colors for output +GREEN='\033[0;32m' +BLUE='\033[0;34m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +IMAGES_DIR="$SCRIPT_DIR/images" +CERTS_DIR="$IMAGES_DIR/nginx/certs" + +usage() { + echo -e "${BLUE}Usage: $0 {up|stop|down}${NC}" + echo -e "${BLUE} up - Start the Imposter environment${NC}" + echo -e "${BLUE} stop - Stop the Imposter environment without removing containers${NC}" + echo -e "${BLUE} down - Remove the Imposter environment${NC}" + exit 1 +} + +generate_certs() { + # Create certs directory if it doesn't exist + mkdir -p "$CERTS_DIR" + + # Generate self-signed certificates if they don't exist + if [ ! -f "$CERTS_DIR/cert.pem" ] || [ ! -f "$CERTS_DIR/key.pem" ]; then + echo -e "${GREEN}Generating self-signed SSL certificates for localhost...${NC}" + openssl req -x509 -newkey rsa:4096 -nodes \ + -keyout "$CERTS_DIR/key.pem" \ + -out "$CERTS_DIR/cert.pem" \ + -days 365 \ + -subj "/C=US/ST=State/L=City/O=Wazuh/CN=localhost" + + # Set proper permissions + chmod 644 "$CERTS_DIR/cert.pem" + chmod 600 "$CERTS_DIR/key.pem" + + echo -e "${GREEN}Certificates generated successfully${NC}" + else + echo -e "${GREEN}Using existing certificates${NC}" + fi +} + +start_environment() { + echo -e "${BLUE}Setting up SSL environment for Imposter...${NC}" + + generate_certs + + # Change directory to images folder + cd "$IMAGES_DIR" + + # Stop any running containers + echo -e "${GREEN}Stopping existing containers...${NC}" + docker compose down 2>/dev/null || true + + # Start the environment + echo -e "${GREEN}Starting Docker Compose environment...${NC}" + docker compose up -d + + # Wait for services to be ready + echo -e "${BLUE}Waiting for services to start...${NC}" + sleep 5 + + # Test the endpoint + echo -e "${GREEN}Testing endpoint...${NC}" + if curl -k -s -o /dev/null -w "%{http_code}" https://localhost:8443/api/v1/instances/me | grep -q "200\|401"; then + echo -e "${GREEN}✓ Environment is up and running!${NC}" + echo -e "${BLUE}Access your mock at: https://localhost:8443${NC}" + else + echo -e "${BLUE}Environment started. Verify manually at: https://localhost:8443${NC}" + fi + + echo -e "${GREEN}Done!${NC}" +} + +stop_environment() { + echo -e "${GREEN}Stopping Imposter environment...${NC}" + cd "$IMAGES_DIR" + docker compose stop + echo -e "${GREEN}Done!${NC}" +} + +remove_environment() { + echo -e "${GREEN}Removing Imposter environment...${NC}" + cd "$IMAGES_DIR" + docker compose down + echo -e "${GREEN}Done!${NC}" +} + +# Check if at least one argument is provided +if [ $# -eq 0 ]; then + usage +fi + +# Parse command line arguments +case $1 in + up) + start_environment + ;; + stop) + stop_environment + ;; + down) + remove_environment + ;; + *) + echo -e "${RED}Error: Unknown command '$1'${NC}" + usage + ;; +esac + diff --git a/plugins/content-manager/imposter/openapi.yml b/plugins/content-manager/imposter/openapi.yml new file mode 100644 index 00000000..bf796983 --- /dev/null +++ b/plugins/content-manager/imposter/openapi.yml @@ -0,0 +1,728 @@ +openapi: 3.0.0 +info: + title: Wazuh Console & CTI Mock API + description: | + Mock API for testing Wazuh instance OAuth flows and CTI catalog access. + + This specification covers: + - Token Request (Device Authorization Grant) + - Token Exchange (for HMAC-signed URLs) + - Catalog Download (CTI consumer changes access) + version: 1.0.0 + license: + name: Apache 2.0 + url: https://www.apache.org/licenses/LICENSE-2.0.html + +servers: + - url: "{protocol}://{address}:{port}" + variables: + address: + default: localhost + port: + default: '8443' + protocol: + enum: + - "http" + - "https" + default: "https" + +tags: + - name: CTI Console + description: CTI Console endpoints + - name: CTI API + description: Wazuh CTI resource endpoints + +paths: + /api/v1/instances/token: + post: + summary: Token Request + description: | + Poll for access token using device code from authorization flow. + + This endpoint should be polled at the interval specified in the authorization response + until the user completes authorization or the device code expires. + operationId: tokenRequest + tags: + - CTI Console + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/TokenRequest' + examples: + valid_request: + summary: Valid token request + value: + grant_type: urn:ietf:params:oauth:grant-type:device_code + client_id: a17c21ed + device_code: NGU5OWFiNjQ5YmQwNGY3YTdmZTEyNzQ3YzQ1YSA + responses: + '200': + $ref: '#/components/responses/TokenSuccess' + '400': + $ref: '#/components/responses/TokenError' + + /api/v1/instances/token/exchange: + post: + summary: Token Exchange Request + description: | + Exchange access token for HMAC-signed URL to access CTI resources. + + The signed URL is valid for 300 seconds (5 minutes) and grants access + to the specific resource specified in the request. + operationId: tokenExchange + tags: + - CTI Console + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/TokenExchangeRequest' + examples: + catalog_access: + summary: Request access to CTI catalog consumer changes + value: + grant_type: urn:ietf:params:oauth:grant-type:token-exchange + subject_token_type: urn:ietf:params:oauth:token-type:access_token + requested_token_type: urn:wazuh:params:oauth:token-type:signed_url + resource: https://localhost:8080/api/v1/catalog/contexts/misp/consumer/virustotal/changes + responses: + '200': + $ref: '#/components/responses/TokenExchangeSuccess' + '400': + $ref: '#/components/responses/TokenExchangeError' + '401': + $ref: '#/components/responses/Unauthorized' + + /api/v1/instances/me: + get: + summary: Get current instance details + description: | + Retrieve the current instance's organization and plan details. + + Returns information about the organization and the plans associated with the instance, + including available products and resources. + operationId: getInstanceMe + tags: + - CTI Console + security: + - bearerAuth: [] + responses: + '200': + $ref: '#/components/responses/InstanceMeSuccess' + '401': + $ref: '#/components/responses/Unauthorized' + + /api/v1/catalog/contexts/{context}/consumers/{consumer}/changes: + get: + summary: Catalog Download (Consumer Changes) + description: | + Download CTI catalog consumer changes using HMAC-signed URL. + + This endpoint validates the HMAC signature in the query parameters + and returns the requested threat intelligence changes. + + The URL must include valid `verify` parameter with timestamp and signature. + + **Notes:** + - `from_offset` and `to_offset` are mandatory + - Maximum number of returned changes is 1000 + - Empty update changes are not returned if `with_empties=false` + - Empty creation changes are always returned + - No authorization header needed when using signed URL + operationId: catalogDownload + tags: + - CTI API + parameters: + - name: context + in: path + required: true + description: The context name (e.g., "misp", "otx", "threatfox") + schema: + type: string + example: misp + - name: consumer + in: path + required: true + description: The consumer name (e.g., "virustotal", "alienvault") + schema: + type: string + example: virustotal + - name: from_offset + in: query + required: true + description: Offset of the last asked resource (offset excluded) + schema: + type: integer + example: 0 + - name: to_offset + in: query + required: true + description: Offset of the last resource to return (offset included) + schema: + type: integer + example: 1000 + - name: with_empties + in: query + required: false + description: If false, empty update changes are not returned (default true) + schema: + type: boolean + default: true + example: true + - name: verify + in: query + required: true + description: HMAC signature with format "timestamp-signature" + schema: + type: string + example: 1761383411-kJ9b8w%2BQ7kzRmF + responses: + '200': + $ref: '#/components/responses/CatalogSuccess' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '422': + $ref: '#/components/responses/UnprocessableEntity' + +components: + # --- Data Schemas --- + schemas: + TokenRequest: + type: object + description: Request parameters for polling to obtain access token. + properties: + grant_type: + type: string + description: OAuth 2.0 grant type for device authorization. + enum: + - urn:ietf:params:oauth:grant-type:device_code + example: urn:ietf:params:oauth:grant-type:device_code + client_id: + type: string + description: The instance UID. + example: a17c21ed + device_code: + type: string + description: The device verification code from authorization response. + example: NGU5OWFiNjQ5YmQwNGY3YTdmZTEyNzQ3YzQ1YSA + required: + - grant_type + - client_id + - device_code + + TokenResponse: + type: object + description: Response containing OAuth 2.0 access token. + properties: + access_token: + type: string + description: The OAuth 2.0 access token. + example: AYjcyMzY3ZDhiNmJkNTY + refresh_token: + type: string + description: The refresh token for obtaining new access tokens. + example: RjY2NjM5NzA2OWJjuE7c + token_type: + type: string + description: The type of token issued. + enum: + - Bearer + example: Bearer + expires_in: + type: integer + description: The lifetime of the access token in seconds. + example: 3600 + required: + - access_token + - token_type + - expires_in + + TokenExchangeRequest: + type: object + description: Request parameters for exchanging access token for signed URL. + properties: + grant_type: + type: string + description: OAuth 2.0 grant type for token exchange. + enum: + - urn:ietf:params:oauth:grant-type:token-exchange + example: urn:ietf:params:oauth:grant-type:token-exchange + subject_token_type: + type: string + description: The type of the subject token being exchanged. + enum: + - urn:ietf:params:oauth:token-type:access_token + example: urn:ietf:params:oauth:token-type:access_token + requested_token_type: + type: string + description: The type of token being requested. + enum: + - urn:wazuh:params:oauth:token-type:signed_url + example: urn:wazuh:params:oauth:token-type:signed_url + resource: + type: string + format: uri + description: The full URL of the resource to access. + example: https://localhost:8080/api/v1/catalog/contexts/misp/consumer/virustotal/changes + required: + - grant_type + - subject_token_type + - requested_token_type + - resource + + TokenExchangeResponse: + type: object + description: Response containing HMAC-signed URL for resource access. + properties: + access_token: + type: string + format: uri + description: The HMAC-signed URL for accessing the protected resource. + example: https://localhost:8080/api/v1/catalog/contexts/misp/consumer/virustotal/changes?verify=1761383411-kJ9b8w%2BQ7kzRmF + issued_token_type: + type: string + description: The type of token that was issued. + enum: + - urn:wazuh:params:oauth:token-type:signed_url + example: urn:wazuh:params:oauth:token-type:signed_url + expires_in: + type: integer + description: The lifetime of the signed URL in seconds. + example: 300 + required: + - access_token + - issued_token_type + - expires_in + + CatalogChange: + type: object + description: A single change entry in the catalog. + properties: + type: + type: string + description: The type of change operation. + enum: + - create + - update + - delete + example: create + context: + type: string + description: The context name. + example: misp + resource: + type: string + description: The resource identifier. + example: indicator/12345 + offset: + type: integer + description: The offset number of this change. + example: 42 + version: + type: integer + description: The version number of the resource. + example: 1 + payload: + type: object + description: The resource payload (for create operations). + example: + type: "ip-address" + value: "192.168.1.100" + threat_level: "high" + timestamp: "2025-11-17T10:30:00Z" + operations: + type: array + description: JSON Patch operations (for update operations). + items: + type: object + properties: + op: + type: string + enum: [add, remove, replace, move, copy, test] + path: + type: string + value: + type: object + description: The value to set (type depends on the operation) + example: + - op: replace + path: /threat_level + value: critical + required: + - type + - context + - resource + - offset + - version + + CatalogChangesResponse: + type: object + description: Response containing catalog changes. + properties: + data: + type: array + description: Array of catalog changes. + items: + $ref: '#/components/schemas/CatalogChange' + required: + - data + + InstanceMeResponse: + type: object + description: Response containing instance details. + properties: + data: + type: object + properties: + organization: + type: object + properties: + avatar: + type: string + format: uri + description: URL to the organization's avatar image. + example: "https://acme.sl/avatar.png" + name: + type: string + description: Organization name. + example: "ACME S.L." + plans: + type: array + description: List of plans associated with the instance. + items: + $ref: '#/components/schemas/Plan' + required: + - data + + Plan: + type: object + description: A plan with associated products. + properties: + name: + type: string + description: Plan name. + example: "Pro Plan Deluxe" + description: + type: string + description: Plan description. + example: "Lorem ipsum…" + products: + type: array + description: List of products included in the plan. + items: + $ref: '#/components/schemas/Product' + required: + - name + - description + - products + + Product: + type: object + description: A product included in a plan. + properties: + type: + type: string + description: Product type identifier. + example: "catalog:consumer:vulnerabilities" + identifier: + type: string + description: Unique product identifier. + example: "vulnerabilities-pro" + name: + type: string + description: Product name. + example: "Vulnerabilities Pro" + description: + type: string + description: Product description. + example: "Vulnerabilities updated as soon as they are added to the catalog" + resource: + type: string + format: uri + description: Resource URL for catalog products. + example: "https://localhost:8080/api/v1/catalog/plans/pro/contexts/vulnerabilities/consumer/realtime" + email: + type: string + format: email + description: Contact email for assistance products. + example: "cloud@wazuh.com" + phone: + type: string + description: Contact phone for assistance products. + example: "+34 123 456 789" + required: + - type + - identifier + - name + + ErrorResponse: + type: object + description: Standard error response. + properties: + error: + type: string + description: The error code. + example: invalid_request + error_description: + type: string + description: Human-readable error description. + example: Missing required parameter + errors: + type: object + description: Detailed validation errors by field. + additionalProperties: + type: array + items: + type: string + example: + from_offset: ["must be less than to_offset"] + required: + - error + + # --- Response Schemas --- + responses: + # Success Responses + TokenSuccess: + description: OK - Access token granted. + headers: + Cache-Control: + schema: + type: string + example: no-store + content: + application/json: + schema: + $ref: '#/components/schemas/TokenResponse' + examples: + success: + summary: Access token granted + value: + access_token: AYjcyMzY3ZDhiNmJkNTY + refresh_token: RjY2NjM5NzA2OWJjuE7c + token_type: Bearer + expires_in: 3600 + + TokenExchangeSuccess: + description: OK - Token exchange successful. + headers: + Cache-Control: + schema: + type: string + example: no-store + content: + application/json: + schema: + $ref: '#/components/schemas/TokenExchangeResponse' + examples: + success: + summary: HMAC-signed URL issued + value: + access_token: https://localhost:8080/api/v1/catalog/contexts/misp/consumer/virustotal/changes?verify=1761383411-kJ9b8w%2BQ7kzRmF + issued_token_type: urn:wazuh:params:oauth:token-type:signed_url + expires_in: 300 + + CatalogSuccess: + description: OK - Catalog changes retrieved successfully. + content: + application/json: + schema: + $ref: '#/components/schemas/CatalogChangesResponse' + examples: + mixed_changes: + summary: Catalog changes with create, update, and delete operations + value: + data: + - type: create + context: misp + resource: indicator/12345 + offset: 42 + version: 1 + payload: + type: "ip-address" + value: "192.168.1.100" + threat_level: "high" + timestamp: "2025-11-17T10:30:00Z" + - type: update + context: misp + resource: indicator/12345 + offset: 43 + version: 2 + operations: + - op: replace + path: /threat_level + value: critical + - type: delete + context: misp + resource: indicator/12345 + offset: 44 + version: 3 + + InstanceMeSuccess: + description: OK - Instance details retrieved successfully. + content: + application/json: + schema: + $ref: '#/components/schemas/InstanceMeResponse' + examples: + pro_plan: + summary: Pro Plan response + value: + data: + organization: + avatar: "https://acme.sl/avatar.png" + name: "ACME S.L." + plans: + - name: "Pro Plan Deluxe" + description: "Lorem ipsum…" + products: + - type: "catalog:consumer:vulnerabilities" + identifier: "vulnerabilities-pro" + name: "Vulnerabilities Pro" + description: "Vulnerabilities updated as soon as they are added to the catalog" + resource: "https://localhost:8080/api/v1/catalog/plans/pro/contexts/vulnerabilities/consumer/realtime" + - type: "catalog:consumer:iocs" + identifier: "bad-guy-ips-pro" + name: "Bad Guy IPs" + description: "Dolor sit amet…" + resource: "https://localhost:8080/api/v1/catalog/plans/pro/contexts/bad-guy-ips/consumer/realtime" + cloud_plan: + summary: Cloud Plan with Pro Products + value: + data: + organization: + avatar: "https://acme.sl/avatar.png" + name: "ACME S.L." + plans: + - name: "Wazuh Cloud" + description: "Managed instances in AWS by Wazuh's professional staf that…" + products: + - identifier: "assistance-24h" + type: "cloud:assistance:wazuh" + name: "Technical assistance 24h" + email: "cloud@wazuh.com" + phone: "+34 123 456 789" + - identifier: "vulnerabilities-pro" + type: "catalog:consumer:vulnerabilities" + name: "Vulnerabilities Pro" + description: "Vulnerabilities updated as soon as they are added to the catalog" + resource: "https://localhost:8080/api/v1/catalog/plans/pro/contexts/vulnerabilities/consumer/realtime" + - identifier: "bad-guy-ips-pro" + type: "catalog:consumer:iocs" + name: "Bad Guy IPs" + description: "Dolor sit amet…" + resource: "https://localhost:8080/api/v1/catalog/plans/pro/contexts/bad-guy-ips/consumer/realtime" + + # Error Responses + TokenError: + description: Bad Request - Token request failed. + headers: + Cache-Control: + schema: + type: string + example: no-store + Pragma: + schema: + type: string + example: no-cache + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + authorization_pending: + summary: User has not yet authorized + value: + error: authorization_pending + expired_token: + summary: Device code has expired + value: + error: expired_token + error_description: The device code has expired + + TokenExchangeError: + description: Bad Request - Token exchange failed. + headers: + Cache-Control: + schema: + type: string + example: no-store + Pragma: + schema: + type: string + example: no-cache + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + invalid_request: + summary: Missing required parameter + value: + error: invalid_request + error_description: Missing required parameter resource + invalid_target: + summary: Invalid resource endpoint + value: + error: invalid_target + error_description: The resource parameter refers to an invalid endpoint + + Unauthorized: + description: Unauthorized - Authentication failed or token is invalid. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + invalid_token: + summary: Invalid or expired token + value: + error: unauthorized_client + error_description: The provided token is invalid or expired + + Forbidden: + description: Forbidden - HMAC signature validation failed. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + invalid_signature: + summary: Invalid HMAC signature + value: + error: access_denied + error_description: Invalid or expired HMAC signature + + UnprocessableEntity: + description: Unprocessable Entity - Invalid parameters. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + invalid_offset_range: + summary: Invalid offset range + value: + errors: + from_offset: ["must be less than to_offset"] + too_many_changes: + summary: Too many changes requested + value: + errors: + to_offset: ["range exceeds maximum of 1000 changes"] + + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: | + OAuth 2.0 Bearer token authentication. + Include the access token in the Authorization header: + `Authorization: Bearer ` diff --git a/plugins/content-manager/imposter/scripts/catalogResponse.groovy b/plugins/content-manager/imposter/scripts/catalogResponse.groovy new file mode 100644 index 00000000..949eb5d8 --- /dev/null +++ b/plugins/content-manager/imposter/scripts/catalogResponse.groovy @@ -0,0 +1,44 @@ +// Catalog Response Logic +// Returns different responses based on verify parameter and query params + +def verify = context.request.queryParams.verify?.toString() +def fromOffsetStr = context.request.queryParams.from_offset?.toString() +def toOffsetStr = context.request.queryParams.to_offset?.toString() +def fromOffset = fromOffsetStr ? fromOffsetStr.toInteger() : null +def toOffset = toOffsetStr ? toOffsetStr.toInteger() : null + +// Check for missing verify parameter (401 Unauthorized) +if (!verify) { + respond() + .withStatusCode(401) + .withHeader("Content-Type", "application/json") + .withContent('{"error": "unauthorized_client", "error_description": "The provided token is invalid or expired"}') +} +// Check for invalid signature (403 Forbidden) +else if (verify == "invalid" || verify == "expired" || verify.startsWith("0000")) { + respond() + .withStatusCode(403) + .withHeader("Content-Type", "application/json") + .withContent('{"error": "access_denied", "error_description": "Invalid or expired HMAC signature"}') +} +// Check for invalid offset range (422 Unprocessable Entity) +else if (fromOffset != null && toOffset != null && fromOffset >= toOffset) { + respond() + .withStatusCode(422) + .withHeader("Content-Type", "application/json") + .withContent('{"errors": {"from_offset": ["must be less than to_offset"]}}') +} +// Check if range exceeds maximum (422 Unprocessable Entity) +else if (fromOffset != null && toOffset != null && (toOffset - fromOffset) > 1000) { + respond() + .withStatusCode(422) + .withHeader("Content-Type", "application/json") + .withContent('{"errors": {"to_offset": ["range exceeds maximum of 1000 changes"]}}') +} +// Success scenario +else { + respond() + .withStatusCode(200) + .withHeader("Content-Type", "application/json") + .withContent('{"data": [{"type": "create", "context": "misp", "resource": "indicator/12345", "offset": 42, "version": 1, "payload": {"type": "ip-address", "value": "192.168.1.100", "threat_level": "high", "timestamp": "2025-11-17T10:30:00Z"}}, {"type": "update", "context": "misp", "resource": "indicator/12345", "offset": 43, "version": 2, "operations": [{"op": "replace", "path": "/threat_level", "value": "critical"}]}, {"type": "delete", "context": "misp", "resource": "indicator/12345", "offset": 44, "version": 3}]}') +} diff --git a/plugins/content-manager/imposter/scripts/instanceMeResponse.groovy b/plugins/content-manager/imposter/scripts/instanceMeResponse.groovy new file mode 100644 index 00000000..5f5db4bb --- /dev/null +++ b/plugins/content-manager/imposter/scripts/instanceMeResponse.groovy @@ -0,0 +1,101 @@ +// Instance Me Response Logic +// Returns different responses based on Authorization header token + +def authHeader = context.request.headers['Authorization']?.toString() + +// Check if Authorization header is present +if (!authHeader || !authHeader.startsWith('Bearer ')) { + respond() + .withStatusCode(401) + .withHeader("Content-Type", "application/json") + .withContent('{"error": "unauthorized_client", "error_description": "The provided token is invalid or expired"}') + return +} + +// Extract token from Authorization header +def token = authHeader.substring(7) // Remove "Bearer " prefix + +// Cloud plan scenario - token contains "cloud" +if (token.toLowerCase().contains('cloud')) { + respond() + .withStatusCode(200) + .withHeader("Content-Type", "application/json") + .withContent(''' +{ + "data": { + "organization": { + "avatar": "https://acme.sl/avatar.png", + "name": "ACME S.L." + }, + "plans": [ + { + "name": "Wazuh Cloud", + "description": "Managed instances in AWS by Wazuh's professional staf that…", + "products": [ + { + "identifier": "assistance-24h", + "type": "cloud:assistance:wazuh", + "name": "Technical assistance 24h", + "email": "cloud@wazuh.com", + "phone": "+34 123 456 789" + }, + { + "identifier": "vulnerabilities-pro", + "type": "catalog:consumer:vulnerabilities", + "name": "Vulnerabilities Pro", + "description": "Vulnerabilities updated as soon as they are added to the catalog", + "resource": "https://localhost:8080/api/v1/catalog/plans/pro/contexts/vulnerabilities/consumer/realtime" + }, + { + "identifier": "bad-guy-ips-pro", + "type": "catalog:consumer:iocs", + "name": "Bad Guy IPs", + "description": "Dolor sit amet…", + "resource": "https://localhost:8080/api/v1/catalog/plans/pro/contexts/bad-guy-ips/consumer/realtime" + } + ] + } + ] + } +} +''') +} +// Pro plan scenario - default for any other valid token +else { + respond() + .withStatusCode(200) + .withHeader("Content-Type", "application/json") + .withContent(''' +{ + "data": { + "organization": { + "avatar": "https://acme.sl/avatar.png", + "name": "ACME S.L." + }, + "plans": [ + { + "name": "Pro Plan Deluxe", + "description": "Lorem ipsum…", + "products": [ + { + "type": "catalog:consumer:vulnerabilities", + "identifier": "vulnerabilities-pro", + "name": "Vulnerabilities Pro", + "description": "Vulnerabilities updated as soon as they are added to the catalog", + "resource": "https://localhost:8080/api/v1/catalog/plans/pro/contexts/vulnerabilities/consumer/realtime" + }, + { + "type": "catalog:consumer:iocs", + "identifier": "bad-guy-ips-pro", + "name": "Bad Guy IPs", + "description": "Dolor sit amet…", + "resource": "https://localhost:8080/api/v1/catalog/plans/pro/contexts/bad-guy-ips/consumer/realtime" + } + ] + } + ] + } +} +''') +} + diff --git a/plugins/content-manager/imposter/scripts/tokenExchangeResponse.groovy b/plugins/content-manager/imposter/scripts/tokenExchangeResponse.groovy new file mode 100644 index 00000000..9518d1db --- /dev/null +++ b/plugins/content-manager/imposter/scripts/tokenExchangeResponse.groovy @@ -0,0 +1,46 @@ +// Token Exchange Response Logic +// Returns different responses based on Authorization header + +def authHeader = context.request.headers.Authorization?.toString() +def resource = context.request.formParams.resource?.toString() + +// Check for missing Authorization header +if (!authHeader) { + respond() + .withStatusCode(401) + .withHeader("Content-Type", "application/json") + .withContent('{"error": "unauthorized_client", "error_description": "The provided token is invalid or expired"}') +} +// Check for invalid token +else if (authHeader == "Bearer invalid_token" || authHeader == "Bearer expired_token") { + respond() + .withStatusCode(401) + .withHeader("Content-Type", "application/json") + .withContent('{"error": "unauthorized_client", "error_description": "The provided token is invalid or expired"}') +} +// Check for missing resource parameter +else if (!resource) { + respond() + .withStatusCode(400) + .withHeader("Cache-Control", "no-store") + .withHeader("Pragma", "no-cache") + .withHeader("Content-Type", "application/json") + .withContent('{"error": "invalid_request", "error_description": "Missing required parameter resource"}') +} +// Check for invalid resource endpoint +else if (resource && !resource.contains("localhost:8443")) { + respond() + .withStatusCode(400) + .withHeader("Cache-Control", "no-store") + .withHeader("Pragma", "no-cache") + .withHeader("Content-Type", "application/json") + .withContent('{"error": "invalid_target", "error_description": "The resource parameter refers to an invalid endpoint"}') +} +// Success scenario +else { + respond() + .withStatusCode(200) + .withHeader("Cache-Control", "no-store") + .withHeader("Content-Type", "application/json") + .withContent('{"access_token": "https://localhost:8443/api/v1/catalog/contexts/misp/consumers/virustotal/changes?from_offset=0&to_offset=1000&with_empties=true&verify=1761383411-kJ9b8w%2BQ7kzRmF", "issued_token_type": "urn:wazuh:params:oauth:token-type:signed_url", "expires_in": 300}') +} diff --git a/plugins/content-manager/imposter/scripts/tokenResponse.groovy b/plugins/content-manager/imposter/scripts/tokenResponse.groovy new file mode 100644 index 00000000..55359e2e --- /dev/null +++ b/plugins/content-manager/imposter/scripts/tokenResponse.groovy @@ -0,0 +1,81 @@ +// Token Request Response Logic +// Returns different responses based on device_code parameter and request count + +import java.util.concurrent.ConcurrentHashMap + +// Use a static map to track request counts across invocations +@groovy.transform.Field +static ConcurrentHashMap requestCounts = new ConcurrentHashMap<>() + +def deviceCode = context.request.formParams.device_code?.toString() + +// Expired token scenario +if (deviceCode == "expired") { + respond() + .withStatusCode(400) + .withHeader("Cache-Control", "no-store") + .withHeader("Pragma", "no-cache") + .withHeader("Content-Type", "application/json") + .withContent('{"error": "expired_token", "error_description": "The device code has expired"}') +} +// Pending authorization codes +else if (deviceCode == "pending") { + respond() + .withStatusCode(400) + .withHeader("Cache-Control", "no-store") + .withHeader("Pragma", "no-cache") + .withHeader("Content-Type", "application/json") + .withContent('{"error": "authorization_pending"}') +} +// Success scenario - immediate grant +else if (deviceCode == "granted") { + respond() + .withStatusCode(200) + .withHeader("Cache-Control", "no-store") + .withHeader("Content-Type", "application/json") + .withContent('{"access_token": "AYjcyMzY3ZDhiNmJkNTY", "refresh_token": "RjY2NjM5NzA2OWJjuE7c", "token_type": "Bearer", "expires_in": 3600}') +} +// Handle pending/rejected flow +else if (deviceCode == "pending_rejected") { + def count = requestCounts.getOrDefault(deviceCode, 0) + 1 + requestCounts.put(deviceCode, count) + + if (count <= 4) { + respond() + .withStatusCode(400) + .withHeader("Cache-Control", "no-store") + .withHeader("Pragma", "no-cache") + .withHeader("Content-Type", "application/json") + .withContent('{"error": "authorization_pending"}') + } else { + // Reject after 4 attempts + respond() + .withStatusCode(400) + .withHeader("Cache-Control", "no-store") + .withHeader("Pragma", "no-cache") + .withHeader("Content-Type", "application/json") + .withContent('{"error": "access_denied", "error_description": "Token authorization denied"}') + } +} +// Handle pending/granted flow +else { + def count = requestCounts.getOrDefault(deviceCode, 0) + 1 + requestCounts.put(deviceCode, count) + + // Return authorization_pending for first 4 attempts + if (count <= 4) { + respond() + .withStatusCode(400) + .withHeader("Cache-Control", "no-store") + .withHeader("Pragma", "no-cache") + .withHeader("Content-Type", "application/json") + .withContent('{"error": "authorization_pending"}') + } else { + // Grant token after 4 attempts + respond() + .withStatusCode(200) + .withHeader("Cache-Control", "no-store") + .withHeader("Content-Type", "application/json") + .withContent('{"access_token": "AYjcyMzY3ZDhiNmJkNTY", "refresh_token": "RjY2NjM5NzA2OWJjuE7c", "token_type": "Bearer", "expires_in": 3600}') + } +}