Implement Imposter mock server for CTI API (#661)

* Implement Imposter's mock for CTI endpoints

* Update CHANGELOG

* Add scripts to test the invalid scenarios

* Update README

* Fix typo

* Improve directory structure and files naming

Sanitized API references

Removed filtered endpoints

Add parametrization to servers urls

* Add instance/me endpoint mock

* Fix servers section

* Implement ssl support

* Simplify imposter environment, remove non-secure option

* Update documentation

* Add retries system to the Imposter environment

* Implement retry system for pending-granted/rejected scenarios

* Update catalog download example response to match real world

---------

Co-authored-by: Alex Ruiz <alejandro.ruiz.becerra@wazuh.com>
This commit is contained in:
Kevin Ledesma 2025-11-20 13:11:05 -03:00 committed by GitHub
parent f075ae7132
commit cf87f635c6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 1559 additions and 0 deletions

View File

@ -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)

View File

@ -0,0 +1,8 @@
# Ignore SSL certificates and keys
*.pem
*.key
*.crt
*.csr
# Ignore OpenSSL temporary files
*.cnf

View File

@ -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/)

View File

@ -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

View File

@ -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;
}
}
}

View File

@ -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

View File

@ -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

View File

@ -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 <access_token>`

View File

@ -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}]}')
}

View File

@ -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"
}
]
}
]
}
}
''')
}

View File

@ -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}')
}

View File

@ -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<String, Integer> 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}')
}
}